From 123efc916c7bbd76851e4cc9b9eb20066b7185d4 Mon Sep 17 00:00:00 2001 From: matias Date: Mon, 22 Dec 2025 21:55:44 -0300 Subject: [PATCH 1/3] refactoriza filtros para separarlo en componentens --- postcss.config.mjs | 4 +- src/app/(user)/arreglos/page.test.tsx | 139 ++++++++++ src/app/(user)/arreglos/page.tsx | 259 ++---------------- src/app/(user)/clientes/page.tsx | 41 ++- .../arreglos/ArregloFiltersModal.tsx | 33 +-- .../components/arreglos/ArreglosResults.tsx | 35 +++ .../components/arreglos/ArreglosToolbar.tsx | 139 ++++++++++ src/app/components/arreglos/FilterChip.tsx | 55 ++++ src/app/components/ui/SearchBar.tsx | 3 + .../vehiculos/VehiculoCard.test.tsx | 2 +- .../hooks/arreglos/useArreglosFilters.test.ts | 80 ++++++ src/app/hooks/arreglos/useArreglosFilters.ts | 189 +++++++++++++ src/clients/clientes/clientesClient.test.ts | 2 +- src/clients/clientes/empresaClient.test.ts | 2 +- src/clients/clientes/particularClient.test.ts | 2 +- src/clients/vehiculoClient.test.ts | 2 +- src/globals.css | 54 ---- src/{testing => tests}/factories.ts | 0 src/tests/testUtils.ts | 11 + src/utils/vehiculos.test.ts | 2 +- 20 files changed, 733 insertions(+), 321 deletions(-) create mode 100644 src/app/(user)/arreglos/page.test.tsx create mode 100644 src/app/components/arreglos/ArreglosResults.tsx create mode 100644 src/app/components/arreglos/ArreglosToolbar.tsx create mode 100644 src/app/components/arreglos/FilterChip.tsx create mode 100644 src/app/hooks/arreglos/useArreglosFilters.test.ts create mode 100644 src/app/hooks/arreglos/useArreglosFilters.ts rename src/{testing => tests}/factories.ts (100%) create mode 100644 src/tests/testUtils.ts diff --git a/postcss.config.mjs b/postcss.config.mjs index c7bcb4b..61e3684 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,5 +1,7 @@ const config = { - plugins: ["@tailwindcss/postcss"], + plugins: { + "@tailwindcss/postcss": {}, + }, }; export default config; diff --git a/src/app/(user)/arreglos/page.test.tsx b/src/app/(user)/arreglos/page.test.tsx new file mode 100644 index 0000000..353448e --- /dev/null +++ b/src/app/(user)/arreglos/page.test.tsx @@ -0,0 +1,139 @@ +import { describe, expect, it, beforeAll, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Arreglo } from "@/model/types"; +import { createArreglo, createVehiculo } from "@/tests/factories"; +import { runPendingPromises } from "@/tests/testUtils"; + +let arreglosMock: Arreglo[] = []; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + back: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + }), +})); + +vi.mock("@/app/providers/ArreglosProvider", () => ({ + useArreglos: () => ({ + arreglos: arreglosMock, + loading: false, + }), +})); + +vi.mock("@/app/providers/SheetProvider", () => ({ + useSheet: () => ({ + openSheet: vi.fn(), + }), +})); + +vi.mock("@/app/components/arreglos/ArregloModal", () => ({ + __esModule: true, + default: () => null, +})); + +vi.mock("@/app/providers/VehiculosProvider", () => ({ + useVehiculos: () => ({ + vehiculos: [], + fetchAll: vi.fn(), + }), +})); + +import ArreglosPage from "./page"; + +async function aplicarFiltros(params: { + patente: string; + fechaDesde: string; + fechaHasta: string; +}) { + await userEvent.click(screen.getByTestId("arreglos-open-filters")); + expect(screen.getByTestId("modal-overlay")).toBeInTheDocument(); + + await userEvent.clear(screen.getByTestId("arreglos-filter-patente")); + await userEvent.type(screen.getByTestId("arreglos-filter-patente"), params.patente); + + fireEvent.change(screen.getByTestId("arreglos-filter-fecha-desde"), { + target: { value: params.fechaDesde }, + }); + fireEvent.change(screen.getByTestId("arreglos-filter-fecha-hasta"), { + target: { value: params.fechaHasta }, + }); + + await userEvent.click(screen.getByTestId("modal-submit")); + await runPendingPromises(); + expect(screen.queryByTestId("modal-overlay")).not.toBeInTheDocument(); +} + +beforeAll(() => { + Object.defineProperty(window, "scrollTo", { + value: vi.fn(), + writable: true, + }); +}); + +describe("ArreglosPage", () => { + it("permite buscar por texto y aplicar filtros desde el modal", async () => { + arreglosMock = [ + createArreglo({ + id: 1, + descripcion: "Cambio de aceite", + tipo: "Mecanica", + fecha: "2025-01-10", + vehiculo: createVehiculo({ patente: "AAA111" }), + }), + createArreglo({ + id: 2, + descripcion: "Reparación de frenos", + tipo: "Revision", + fecha: "2025-02-10", + vehiculo: createVehiculo({ patente: "BBB222" }), + }), + createArreglo({ + id: 3, + descripcion: "Pintura completa", + tipo: "Chapa y pintura", + fecha: "2025-01-15", + vehiculo: createVehiculo({ patente: "CCC333" }), + }), + ]; + + const user = userEvent.setup(); + render(); + + expect(screen.getByTestId("arreglo-item-1")).toBeInTheDocument(); + expect(screen.getByTestId("arreglo-item-2")).toBeInTheDocument(); + expect(screen.getByTestId("arreglo-item-3")).toBeInTheDocument(); + + const searchInput = screen.getByTestId("arreglos-search"); + await user.type(searchInput, "frenos"); + + await runPendingPromises(); + expect(screen.queryByTestId("arreglo-item-1")).not.toBeInTheDocument(); + expect(screen.getByTestId("arreglo-item-2")).toBeInTheDocument(); + expect(screen.queryByTestId("arreglo-item-3")).not.toBeInTheDocument(); + + await userEvent.clear(searchInput); + await aplicarFiltros({ + patente: "CCC", + fechaDesde: "2025-01-01", + fechaHasta: "2025-01-31", + }); + + await runPendingPromises(); + expect(screen.queryByTestId("arreglo-item-1")).not.toBeInTheDocument(); + expect(screen.queryByTestId("arreglo-item-2")).not.toBeInTheDocument(); + expect(screen.getByTestId("arreglo-item-3")).toBeInTheDocument(); + + expect(screen.getByTestId("arreglos-active-filters")).toBeInTheDocument(); + await user.click(screen.getByTestId("arreglos-clear-filters")); + + await runPendingPromises(); + expect(screen.getByTestId("arreglo-item-1")).toBeInTheDocument(); + expect(screen.getByTestId("arreglo-item-2")).toBeInTheDocument(); + expect(screen.getByTestId("arreglo-item-3")).toBeInTheDocument(); + }); +}); + + diff --git a/src/app/(user)/arreglos/page.tsx b/src/app/(user)/arreglos/page.tsx index 55ec4de..9db91dd 100644 --- a/src/app/(user)/arreglos/page.tsx +++ b/src/app/(user)/arreglos/page.tsx @@ -1,18 +1,13 @@ "use client"; import ScreenHeader from "@/app/components/ui/ScreenHeader"; -import { Arreglo } from "@/model/types"; -import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import SearchBar from "@/app/components/ui/SearchBar"; -import ListSkeleton from "@/app/components/ui/ListSkeleton"; -import { Filter, PlusIcon } from "lucide-react"; -import Button from "@/app/components/ui/Button"; import { useArreglos } from "@/app/providers/ArreglosProvider"; -import ArregloItem from "@/app/components/arreglos/ArregloItem"; import ArregloModal from "@/app/components/arreglos/ArregloModal"; -import ArregloFiltersModal, { ArregloFilters } from "@/app/components/arreglos/ArregloFiltersModal"; -import { BREAKPOINTS, COLOR } from "@/theme/theme"; -import { css } from "@emotion/react"; +import ArregloFiltersModal from "@/app/components/arreglos/ArregloFiltersModal"; +import ArreglosToolbar from "@/app/components/arreglos/ArreglosToolbar"; +import ArreglosResults from "@/app/components/arreglos/ArreglosResults"; +import { useArreglosFilters } from "@/app/hooks/arreglos/useArreglosFilters"; +import { useState } from "react"; export default function ArreglosPage() { return ; @@ -21,195 +16,27 @@ export default function ArreglosPage() { function ArreglosPageContent() { const router = useRouter(); const { arreglos, loading } = useArreglos(); + const state = useArreglosFilters(arreglos); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isFilterModalOpen, setIsFilterModalOpen] = useState(false); - const [search, setSearch] = useState(""); - const [filters, setFilters] = useState({ - fechaDesde: "", - fechaHasta: "", - patente: "", - tipo: "", - }); - - const emptyFilters: ArregloFilters = { - fechaDesde: "", - fechaHasta: "", - patente: "", - tipo: "", - }; - const arreglosFiltrados = useMemo(() => { - if (!arreglos) return []; - const q = search.trim().toLowerCase(); - let result = arreglos; - if (q) { - result = result.filter((a: Arreglo) => { - // Busca en las propiedades planas y en la patente del vehiculo asociado - const inFlat = Object.values(a ?? {}).some((v) => - String(v ?? "").toLowerCase().includes(q) - ); - const patente = String(a?.vehiculo?.patente ?? "").toLowerCase(); - const inPatente = patente.includes(q); - return inFlat || inPatente; - }); - } - - const patenteFilter = filters.patente.trim().toLowerCase(); - if (patenteFilter) { - result = result.filter((a: Arreglo) => - String(a?.vehiculo?.patente ?? "").toLowerCase().includes(patenteFilter) - ); - } - - const tipoFilter = filters.tipo.trim().toLowerCase(); - if (tipoFilter) { - result = result.filter((a: Arreglo) => - String(a?.tipo ?? "").toLowerCase().includes(tipoFilter) - ); - } - - const hasDateFilter = filters.fechaDesde || filters.fechaHasta; - if (hasDateFilter) { - const from = filters.fechaDesde ? new Date(filters.fechaDesde) : null; - const to = filters.fechaHasta ? new Date(filters.fechaHasta) : null; - if (from) from.setHours(0, 0, 0, 0); - if (to) to.setHours(23, 59, 59, 999); - - result = result.filter((a: Arreglo) => { - const fecha = new Date(a.fecha); - if (Number.isNaN(fecha.getTime())) return false; - if (from && fecha < from) return false; - if (to && fecha > to) return false; - return true; - }); - } - - return result; - }, [arreglos, search, filters]); - - const formatDateLabel = (dateString: string) => { - if (!dateString) return ""; - const normalized = dateString.replace(" ", "T"); - const d = new Date(normalized); - if (Number.isNaN(d.getTime())) { - const base = dateString.slice(0, 10); - const [y, m, da] = base.split("-"); - if (y && m && da) return `${da}/${m}/${y}`; - return base; - } - return new Intl.DateTimeFormat("es-ES", { - year: "numeric", - month: "2-digit", - day: "2-digit", - timeZone: "UTC", - }).format(d); - }; - - type ChipKind = "fechaRange" | "fechaDesde" | "fechaHasta" | "patente" | "tipo"; - type Chip = { key: string; label: string; kind: ChipKind }; - - const chips = useMemo(() => { - const items: Chip[] = []; - - if (filters.fechaDesde || filters.fechaHasta) { - const desde = formatDateLabel(filters.fechaDesde); - const hasta = formatDateLabel(filters.fechaHasta); - if (filters.fechaDesde && filters.fechaHasta) { - items.push({ key: "fechaRange", label: `${desde} - ${hasta}`, kind: "fechaRange" }); - } else if (filters.fechaDesde) { - items.push({ key: "fechaDesde", label: `Desde: ${desde}`, kind: "fechaDesde" }); - } else if (filters.fechaHasta) { - items.push({ key: "fechaHasta", label: `Hasta: ${hasta}`, kind: "fechaHasta" }); - } - } - - if (filters.patente.trim()) { - items.push({ key: "patente", label: filters.patente.trim(), kind: "patente" }); - } - - if (filters.tipo.trim()) { - items.push({ key: "tipo", label: filters.tipo.trim(), kind: "tipo" }); - } - - return items; - }, [filters]); - - const removeChip = (kind: ChipKind) => { - setFilters((prev) => { - switch (kind) { - case "fechaRange": - return { ...prev, fechaDesde: "", fechaHasta: "" }; - case "fechaDesde": - return { ...prev, fechaDesde: "" }; - case "fechaHasta": - return { ...prev, fechaHasta: "" }; - case "patente": - return { ...prev, patente: "" }; - case "tipo": - return { ...prev, tipo: "" }; - default: - return prev; - } - }); - }; return (
-
-
- -
- {chips.length > 0 && ( -
-
- {chips.map((chip) => ( - - ))} -
- -
- )} -
- {loading ? ( - - ) : ( -
- {arreglosFiltrados.map((arreglo) => ( - router.push(`/arreglos/${a.id}`)} - /> - ))} -
- )} + setIsFilterModalOpen(true)} + onOpenCreate={() => setIsCreateModalOpen(true)} + chips={state.chips} + onChipClick={state.removeFilter} + onClearFilters={state.clearFilters} + /> + router.push(`/arreglos/${a.id}`)} + /> setIsFilterModalOpen(false)} - onApply={setFilters} - initial={filters} + onApply={state.applyFilters} + initial={state.filters} />
); } - -const styles = { - searchBarContainer: { - display: "flex", - flexDirection: "column" as const, - gap: 10, - marginBottom: 16, - marginTop: 8, - }, - searchRow: { - display: "flex", - gap: 12, - alignItems: "center", - }, - searchBar: { - width: "100%", - flex: 1, - }, - newButton: { - height: '40px', - width: '44px', - }, - filterButton: { - height: '40px', - width: '48px', - minWidth: '100px', - }, - chip: css({ - [`@media (max-width: ${BREAKPOINTS.sm}px)`]: { - fontSize: '14px', - padding: '6px 12px', - }, - }), - clearButton: { - background: COLOR.BACKGROUND.SUBTLE, - border: `1px solid ${COLOR.BORDER.SUBTLE}`, - color: COLOR.TEXT.PRIMARY, - padding: "0.5rem 1rem", - borderRadius: 8, - cursor: "pointer", - }, -} as const; diff --git a/src/app/(user)/clientes/page.tsx b/src/app/(user)/clientes/page.tsx index 54ccc39..6042149 100644 --- a/src/app/(user)/clientes/page.tsx +++ b/src/app/(user)/clientes/page.tsx @@ -58,7 +58,7 @@ export default function ClientesPage() { /> @@ -147,10 +150,38 @@ const styles = { width: "100%", gap: 12, }, - chip: css({ + chipsContainer: css({ + display: "flex", + gap: "10px", + alignItems: "center", + flexWrap: "wrap", + }), + chipBase: css({ + padding: "8px 16px", + borderRadius: "24px", + border: "1px solid var(--color-border-subtle)", + background: "var(--color-background-subtle)", + color: "var(--color-text-primary)", + cursor: "pointer", + fontWeight: 500, + transition: + "transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease, background-color 150ms ease, color 150ms ease", + "&:hover": { + borderColor: "var(--color-accent-secondary)", + transform: "translateY(-2px)", + boxShadow: "0 4px 12px rgba(0, 128, 162, 0.15)", + }, + }), + chipSelected: css({ + background: "var(--color-button-primary-background)", + borderColor: "var(--color-accent-secondary)", + color: "var(--color-button-primary-text)", + boxShadow: "none", + }), + chipResponsive: css({ [`@media (max-width: ${BREAKPOINTS.sm}px)`]: { fontSize: '14px', padding : '6px 12px', }, - }) + }), }; diff --git a/src/app/components/arreglos/ArregloFiltersModal.tsx b/src/app/components/arreglos/ArregloFiltersModal.tsx index 6127908..f25e8b6 100644 --- a/src/app/components/arreglos/ArregloFiltersModal.tsx +++ b/src/app/components/arreglos/ArregloFiltersModal.tsx @@ -20,13 +20,6 @@ type Props = { onApply: (filters: ArregloFilters) => void; }; -const emptyFilters: ArregloFilters = { - fechaDesde: "", - fechaHasta: "", - patente: "", - tipo: "", -}; - export default function ArregloFiltersModal({ open, initial, onClose, onApply }: Props) { const [fechaDesde, setFechaDesde] = useState(initial?.fechaDesde ?? ""); const [fechaHasta, setFechaHasta] = useState(initial?.fechaHasta ?? ""); @@ -54,15 +47,6 @@ export default function ArregloFiltersModal({ open, initial, onClose, onApply }: onClose(); }; - const handleClear = () => { - setFechaDesde(""); - setFechaHasta(""); - setPatente(""); - setTipo(""); - onApply(emptyFilters); - onClose(); - }; - const opcionesDefault: AutocompleteOption[] = [ { value: "Mecanica", label: "Mecanica" }, { value: "Chapa y pintura", label: "Chapa y pintura" }, @@ -83,11 +67,23 @@ export default function ArregloFiltersModal({ open, initial, onClose, onApply }:
- setFechaDesde(e.target.value)} /> + setFechaDesde(e.target.value)} + />
- setFechaHasta(e.target.value)} /> + setFechaHasta(e.target.value)} + />
@@ -95,6 +91,7 @@ export default function ArregloFiltersModal({ open, initial, onClose, onApply }:
setPatente(e.target.value)} diff --git a/src/app/components/arreglos/ArreglosResults.tsx b/src/app/components/arreglos/ArreglosResults.tsx new file mode 100644 index 0000000..fcaaa8a --- /dev/null +++ b/src/app/components/arreglos/ArreglosResults.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { Arreglo } from "@/model/types"; +import ListSkeleton from "@/app/components/ui/ListSkeleton"; +import ArregloItem from "@/app/components/arreglos/ArregloItem"; + +type Props = { + loading: boolean; + items: Arreglo[]; + onSelect: (arreglo: Arreglo) => void; +}; + +export default function ArreglosResults({ loading, items, onSelect }: Props) { + if (loading) return ; + + return ( +
+ {items.map((arreglo) => ( +
+ +
+ ))} +
+ ); +} + +const styles = { + listContainer: { + display: "flex", + flexDirection: "column" as const, + gap: 12, + }, +} as const; + + diff --git a/src/app/components/arreglos/ArreglosToolbar.tsx b/src/app/components/arreglos/ArreglosToolbar.tsx new file mode 100644 index 0000000..9de02f7 --- /dev/null +++ b/src/app/components/arreglos/ArreglosToolbar.tsx @@ -0,0 +1,139 @@ +"use client"; + +import SearchBar from "@/app/components/ui/SearchBar"; +import Button from "@/app/components/ui/Button"; +import { Filter, PlusIcon } from "lucide-react"; +import FilterChip from "@/app/components/arreglos/FilterChip"; +import type { ChipKind } from "@/app/hooks/arreglos/useArreglosFilters"; +import { COLOR } from "@/theme/theme"; +import { css } from "@emotion/react"; + +type Chip = { key: string; text: string; kind: ChipKind }; + +type Props = { + search: string; + onSearchChange: (value: string) => void; + onOpenFilters: () => void; + onOpenCreate: () => void; + chips: Chip[]; + onChipClick: (kind: ChipKind) => void; + onClearFilters: () => void; +}; + +export default function ArreglosToolbar({ + search, + onSearchChange, + onOpenFilters, + onOpenCreate, + chips, + onChipClick, + onClearFilters, +}: Props) { + return ( +
+
+ +
+ + {chips.length > 0 && ( +
+
+ {chips.map((chip) => ( + onChipClick(chip.kind)} + /> + ))} +
+ +
+ )} +
+ ); +} + +const styles = { + searchBarContainer: { + display: "flex", + flexDirection: "column" as const, + gap: 10, + marginBottom: 16, + marginTop: 8, + }, + searchRow: { + display: "flex", + gap: 12, + alignItems: "center", + }, + searchBar: { + width: "100%", + flex: 1, + }, + newButton: { + height: "40px", + width: "44px", + }, + filterButton: { + height: "40px", + width: "48px", + minWidth: "100px", + }, + clearButton: { + background: COLOR.BACKGROUND.SUBTLE, + border: `1px solid ${COLOR.BORDER.SUBTLE}`, + color: COLOR.TEXT.PRIMARY, + padding: "0.5rem 1rem", + borderRadius: 8, + cursor: "pointer", + }, + chipsContainer: css({ + display: "flex", + gap: "10px", + alignItems: "center", + flexWrap: "nowrap", + }), + chipsItems: css({ + display: "flex", + gap: "10px", + alignItems: "center", + flexWrap: "wrap", + flex: 1, + minWidth: 0, + }), + chipsClear: css({ + marginLeft: "auto", + flexShrink: 0, + }), +} as const; + + diff --git a/src/app/components/arreglos/FilterChip.tsx b/src/app/components/arreglos/FilterChip.tsx new file mode 100644 index 0000000..2f6a8d8 --- /dev/null +++ b/src/app/components/arreglos/FilterChip.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { BREAKPOINTS, COLOR } from "@/theme/theme"; +import { css } from "@emotion/react"; + +type Props = { + text: string; + onClick: () => void; + selected?: boolean; +}; + +export default function FilterChip({ text, onClick, selected = true }: Props) { + return ( + + ); +} + +const styles = { + chipBase: css({ + padding: "8px 16px", + borderRadius: "24px", + border: `1px solid ${COLOR.BORDER.SUBTLE}`, + background: COLOR.BACKGROUND.SUBTLE, + color: COLOR.TEXT.PRIMARY, + cursor: "pointer", + fontWeight: 500, + transition: + "transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease, background-color 150ms ease, color 150ms ease", + "&:hover": { + borderColor: COLOR.ACCENT.PRIMARY, + transform: "translateY(-2px)", + boxShadow: "0 4px 12px rgba(0, 128, 162, 0.15)", + }, + }), + chipSelected: css({ + background: COLOR.BUTTON.PRIMARY.BACKGROUND, + borderColor: COLOR.ACCENT.PRIMARY, + color: COLOR.BUTTON.PRIMARY.TEXT, + boxShadow: "none", + }), + chipResponsive: css({ + [`@media (max-width: ${BREAKPOINTS.sm}px)`]: { + fontSize: "14px", + padding: "6px 12px", + }, + }), +} as const; + + diff --git a/src/app/components/ui/SearchBar.tsx b/src/app/components/ui/SearchBar.tsx index 05537b5..7261444 100644 --- a/src/app/components/ui/SearchBar.tsx +++ b/src/app/components/ui/SearchBar.tsx @@ -11,6 +11,7 @@ interface SearchBarProps { ariaLabel?: string; style?: React.CSSProperties; className?: string; + inputTestId?: string; } export default function SearchBar({ @@ -21,6 +22,7 @@ export default function SearchBar({ ariaLabel = "barra de búsqueda", style, className, + inputTestId, }: SearchBarProps) { return (
@@ -30,6 +32,7 @@ export default function SearchBar({ onChange={(e) => onChange(e.target.value)} placeholder={placeholder} aria-label={ariaLabel} + data-testid={inputTestId} autoFocus={autoFocus} style={styles.input} /> diff --git a/src/app/components/vehiculos/VehiculoCard.test.tsx b/src/app/components/vehiculos/VehiculoCard.test.tsx index c95fb2d..5e70c2b 100644 --- a/src/app/components/vehiculos/VehiculoCard.test.tsx +++ b/src/app/components/vehiculos/VehiculoCard.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import VehiculoCard from "./VehiculoCard"; -import { createVehiculo } from "@/testing/factories"; +import { createVehiculo } from "@/tests/factories"; describe("VehiculoCard", () => { it("Cuando se hace click en el card, se debe ejecutar onClick", async () => { diff --git a/src/app/hooks/arreglos/useArreglosFilters.test.ts b/src/app/hooks/arreglos/useArreglosFilters.test.ts new file mode 100644 index 0000000..d241a84 --- /dev/null +++ b/src/app/hooks/arreglos/useArreglosFilters.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { ArregloFilters } from "@/app/components/arreglos/ArregloFiltersModal"; +import { filterArreglos } from "@/app/hooks/arreglos/useArreglosFilters"; +import { createArreglo, createVehiculo } from "@/tests/factories"; + +const emptyFilters: ArregloFilters = { + fechaDesde: "", + fechaHasta: "", + patente: "", + tipo: "", +}; + +describe("filterArreglos", () => { + it("devuelve todos los arreglos cuando la búsqueda y los filtros están vacíos", () => { + const arreglos = [ + createArreglo({ id: 1 }), + createArreglo({ id: 2, tipo: "Revision" }), + ]; + const result = filterArreglos(arreglos, { search: "", filters: emptyFilters }); + expect(result.map((a) => a.id)).toEqual([1, 2]); + }); + + it("el filtro por búsqueda es case-insensitive e ignora espacios extra", () => { + const arreglos = [ + createArreglo({ id: 1, descripcion: "Cambio de aceite" }), + createArreglo({ id: 2, descripcion: "Reparación de frenos" }), + ]; + const result = filterArreglos(arreglos, { search: " frEnoS ", filters: emptyFilters }); + expect(result.map((a) => a.id)).toEqual([2]); + }); + + it("filtra por búsqueda coincidiendo con la patente del vehículo", () => { + const arreglos = [ + createArreglo({ id: 1, vehiculo: createVehiculo({ patente: "AAA111" }) }), + createArreglo({ id: 2, vehiculo: createVehiculo({ patente: "BBB222" }) }), + ]; + const result = filterArreglos(arreglos, { search: "bbB", filters: emptyFilters }); + expect(result.map((a) => a.id)).toEqual([2]); + }); + + it("filtra por filtro de patente", () => { + const arreglos = [ + createArreglo({ id: 1, vehiculo: createVehiculo({ patente: "ABC123" }) }), + createArreglo({ id: 2, vehiculo: createVehiculo({ patente: "XYZ999" }) }), + ]; + const result = filterArreglos(arreglos, { + search: "", + filters: { ...emptyFilters, patente: "xYz" }, + }); + expect(result.map((a) => a.id)).toEqual([2]); + }); + + it("filtra por filtro de tipo", () => { + const arreglos = [ + createArreglo({ id: 1, tipo: "Chapa y pintura" }), + createArreglo({ id: 2, tipo: "Mantenimiento" }), + ]; + const result = filterArreglos(arreglos, { + search: "", + filters: { ...emptyFilters, tipo: "PINT" }, + }); + expect(result.map((a) => a.id)).toEqual([1]); + }); + + it("el filtro por rango de fechas es inclusivo", () => { + const arreglos = [ + createArreglo({ id: 1, fecha: "2025-01-01" }), + createArreglo({ id: 2, fecha: "2025-01-10" }), + createArreglo({ id: 3, fecha: "2025-01-31" }), + ]; + const result = filterArreglos(arreglos, { + search: "", + filters: { ...emptyFilters, fechaDesde: "2025-01-10", fechaHasta: "2025-01-31" }, + }); + expect(result.map((a) => a.id)).toEqual([2, 3]); + }); + +}); + + diff --git a/src/app/hooks/arreglos/useArreglosFilters.ts b/src/app/hooks/arreglos/useArreglosFilters.ts new file mode 100644 index 0000000..f8b9b45 --- /dev/null +++ b/src/app/hooks/arreglos/useArreglosFilters.ts @@ -0,0 +1,189 @@ +"use client"; + +import type { Arreglo } from "@/model/types"; +import { useMemo, useState } from "react"; +import type { ArregloFilters } from "@/app/components/arreglos/ArregloFiltersModal"; + +export type ChipKind = "fechaRange" | "fechaDesde" | "fechaHasta" | "patente" | "tipo"; +export type Chip = { key: string; text: string; kind: ChipKind }; + +type DateRange = { from: Date | null; to: Date | null }; + +function createEmptyFilters(): ArregloFilters { + return { + fechaDesde: "", + fechaHasta: "", + patente: "", + tipo: "", + }; +} + +function getDateRange(filters: ArregloFilters): DateRange { + const hasDateFilter = filters.fechaDesde || filters.fechaHasta; + if (!hasDateFilter) return { from: null, to: null }; + + const from = filters.fechaDesde ? new Date(filters.fechaDesde) : null; + const to = filters.fechaHasta ? new Date(filters.fechaHasta) : null; + if (from) from.setHours(0, 0, 0, 0); + if (to) to.setHours(23, 59, 59, 999); + return { from, to }; +} + +function matchesSearch(arreglo: Arreglo, query: string) { + if (!query) return true; + const inFlat = Object.values(arreglo ?? {}).some((v) => + String(v ?? "").toLowerCase().includes(query) + ); + const patente = String(arreglo?.vehiculo?.patente ?? "").toLowerCase(); + const inPatente = patente.includes(query); + return inFlat || inPatente; +} + +function matchesPatenteFilter(arreglo: Arreglo, patenteFilter: string) { + if (!patenteFilter) return true; + return String(arreglo?.vehiculo?.patente ?? "") + .toLowerCase() + .includes(patenteFilter); +} + +function matchesTipoFilter(arreglo: Arreglo, tipoFilter: string) { + if (!tipoFilter) return true; + return String(arreglo?.tipo ?? "").toLowerCase().includes(tipoFilter); +} + +function matchesDateRange(arreglo: Arreglo, range: DateRange) { + if (!range.from && !range.to) return true; + + const fecha = new Date(arreglo.fecha); + if (Number.isNaN(fecha.getTime())) return false; + if (range.from && fecha < range.from) return false; + if (range.to && fecha > range.to) return false; + return true; +} + +export function filterArreglos( + arreglos: Arreglo[] | undefined, + params: { search: string; filters: ArregloFilters } +) { + if (!arreglos) return []; + const query = params.search.trim().toLowerCase(); + const patenteFilter = params.filters.patente.trim().toLowerCase(); + const tipoFilter = params.filters.tipo.trim().toLowerCase(); + const dateRange = getDateRange(params.filters); + + return arreglos.filter( + (a) => + matchesSearch(a, query) && + matchesPatenteFilter(a, patenteFilter) && + matchesTipoFilter(a, tipoFilter) && + matchesDateRange(a, dateRange) + ); +} + +function formatDateLabel(dateString: string) { + if (!dateString) return ""; + const normalized = dateString.replace(" ", "T"); + const d = new Date(normalized); + if (Number.isNaN(d.getTime())) { + const base = dateString.slice(0, 10); + const [y, m, da] = base.split("-"); + if (y && m && da) return `${da}/${m}/${y}`; + return base; + } + return new Intl.DateTimeFormat("es-ES", { + year: "numeric", + month: "2-digit", + day: "2-digit", + timeZone: "UTC", + }).format(d); +} + +export function useArreglosFilters(arreglos?: Arreglo[]) { + const [search, setSearch] = useState(""); + const [filters, setFilters] = useState(createEmptyFilters); + + const arreglosFiltrados = useMemo(() => { + return filterArreglos(arreglos, { search, filters }); + }, [arreglos, search, filters]); + + const chips = useMemo(() => { + const items: Chip[] = []; + + if (filters.fechaDesde || filters.fechaHasta) { + const desde = formatDateLabel(filters.fechaDesde); + const hasta = formatDateLabel(filters.fechaHasta); + if (filters.fechaDesde && filters.fechaHasta) { + items.push({ + key: "fechaRange", + text: `${desde} - ${hasta}`, + kind: "fechaRange", + }); + } else if (filters.fechaDesde) { + items.push({ + key: "fechaDesde", + text: `Desde: ${desde}`, + kind: "fechaDesde", + }); + } else if (filters.fechaHasta) { + items.push({ + key: "fechaHasta", + text: `Hasta: ${hasta}`, + kind: "fechaHasta", + }); + } + } + + if (filters.patente.trim()) { + items.push({ + key: "patente", + text: filters.patente.trim(), + kind: "patente", + }); + } + + if (filters.tipo.trim()) { + items.push({ + key: "tipo", + text: filters.tipo.trim(), + kind: "tipo", + }); + } + + return items; + }, [filters]); + + const removeFilter = (kind: ChipKind) => { + setFilters((prev) => { + switch (kind) { + case "fechaRange": + return { ...prev, fechaDesde: "", fechaHasta: "" }; + case "fechaDesde": + return { ...prev, fechaDesde: "" }; + case "fechaHasta": + return { ...prev, fechaHasta: "" }; + case "patente": + return { ...prev, patente: "" }; + case "tipo": + return { ...prev, tipo: "" }; + default: + return prev; + } + }); + }; + + const clearFilters = () => setFilters(createEmptyFilters()); + const applyFilters = (next: ArregloFilters) => setFilters(next); + + return { + search, + setSearch, + filters, + chips, + arreglosFiltrados, + applyFilters, + clearFilters, + removeFilter, + }; +} + + diff --git a/src/clients/clientes/clientesClient.test.ts b/src/clients/clientes/clientesClient.test.ts index 0a49abc..e27bbf5 100644 --- a/src/clients/clientes/clientesClient.test.ts +++ b/src/clients/clientes/clientesClient.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { clientesClient } from './clientesClient'; -import { createCliente } from '@/testing/factories'; +import { createCliente } from '@/tests/factories'; const mockApi = vi.fn(); global.fetch = mockApi; diff --git a/src/clients/clientes/empresaClient.test.ts b/src/clients/clientes/empresaClient.test.ts index c37e221..edbd834 100644 --- a/src/clients/clientes/empresaClient.test.ts +++ b/src/clients/clientes/empresaClient.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { empresaClient } from './empresaClient'; -import { createEmpresa, createCliente } from '@/testing/factories'; +import { createEmpresa, createCliente } from '@/tests/factories'; import { TipoCliente } from '@/model/types'; const mockApi = vi.fn(); diff --git a/src/clients/clientes/particularClient.test.ts b/src/clients/clientes/particularClient.test.ts index c80ec01..1fb1968 100644 --- a/src/clients/clientes/particularClient.test.ts +++ b/src/clients/clientes/particularClient.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { particularClient } from './particularClient'; -import { createParticular, createCliente } from '@/testing/factories'; +import { createParticular, createCliente } from '@/tests/factories'; import { TipoCliente } from '@/model/types'; const mockApi = vi.fn(); diff --git a/src/clients/vehiculoClient.test.ts b/src/clients/vehiculoClient.test.ts index 949256e..a1814d0 100644 --- a/src/clients/vehiculoClient.test.ts +++ b/src/clients/vehiculoClient.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { vehiculoClient } from './vehiculoClient'; -import { createVehiculo, createCliente, createArreglo } from '@/testing/factories'; +import { createVehiculo, createCliente, createArreglo } from '@/tests/factories'; const mockApi = vi.fn(); global.fetch = mockApi; diff --git a/src/globals.css b/src/globals.css index 2ce9ade..034409c 100644 --- a/src/globals.css +++ b/src/globals.css @@ -123,60 +123,6 @@ h2{ background-color: rgba(0, 0, 0, 0.08); } -.chips-container { - display: flex; - gap: 10px; - align-items: center; - flex-wrap: wrap; -} - -.chips-container.chips-container--with-clear { - flex-wrap: nowrap; -} - -.chips-container.chips-container--with-clear .chips-container__items { - display: flex; - gap: 10px; - align-items: center; - flex-wrap: wrap; - flex: 1; - min-width: 0; -} - -.chips-container.chips-container--with-clear .chips-container__clear { - margin-left: auto; - flex-shrink: 0; -} - -.chip-filter { - padding: 8px 16px; - border-radius: 24px; - border: 1px solid var(--color-border-subtle); - background: var(--color-background-subtle); - color: var(--color-text-primary); - cursor: pointer; - font-weight: 500; - transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease, background-color 150ms ease, color 150ms ease; -} - -.chip-filter:hover { - border-color: var(--color-accent-secondary); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 128, 162, 0.15); -} - -.chip-filter--selected { - background: var(--color-button-primary-background); - border-color: var(--color-accent-secondary); - color: var(--color-button-primary-text); - box-shadow: none; -} - -.chip-filter--selected:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 128, 162, 0.15); -} - @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); diff --git a/src/testing/factories.ts b/src/tests/factories.ts similarity index 100% rename from src/testing/factories.ts rename to src/tests/factories.ts diff --git a/src/tests/testUtils.ts b/src/tests/testUtils.ts new file mode 100644 index 0000000..7c9d4cf --- /dev/null +++ b/src/tests/testUtils.ts @@ -0,0 +1,11 @@ +"use client"; + +import { act } from "@testing-library/react"; + +/** + * Útil para evitar `waitFor` cuando las actualizaciones son sincrónicas + * pero React aplica el re-render de forma async. + */ +export const runPendingPromises = async () => act(async () => {}); + + diff --git a/src/utils/vehiculos.test.ts b/src/utils/vehiculos.test.ts index 18c6276..1cf052a 100644 --- a/src/utils/vehiculos.test.ts +++ b/src/utils/vehiculos.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { formatPatente, formatPatenteConMarcaYModelo } from './vehiculos'; -import { createVehiculo } from '@/testing/factories'; +import { createVehiculo } from '@/tests/factories'; describe('formatPatente', () => { it('dada una patente de 7 caracteres, deberia formatearla correctamente', () => { From e0feb0bab4732eba05a1a0bf2406f7e8a0008342 Mon Sep 17 00:00:00 2001 From: matias Date: Mon, 22 Dec 2025 22:17:56 -0300 Subject: [PATCH 2/3] agrega filtros a detalle de vehiculo --- src/app/(user)/arreglos/page.tsx | 2 +- src/app/(user)/vehiculos/[id]/page.tsx | 63 ++++++++++++-------------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/app/(user)/arreglos/page.tsx b/src/app/(user)/arreglos/page.tsx index 9db91dd..4817381 100644 --- a/src/app/(user)/arreglos/page.tsx +++ b/src/app/(user)/arreglos/page.tsx @@ -36,7 +36,7 @@ function ArreglosPageContent() { loading={loading} items={state.arreglosFiltrados} onSelect={(a) => router.push(`/arreglos/${a.id}`)} - /> + /> (); @@ -35,7 +34,8 @@ export default function VehiculoDetailsPage() { const [openEditVehiculo, setOpenEditVehiculo] = useState(false); const [openReassignOwner, setOpenReassignOwner] = useState(false); const { confirm, alert } = useModalMessage(); - const [search, setSearch] = useState(""); + const [isArreglosFilterModalOpen, setIsArreglosFilterModalOpen] = useState(false); + const arreglosFilters = useArreglosFilters(arreglos); // Kilometraje más alto a partir de los arreglos const maxKilometraje = useMemo(() => { @@ -43,20 +43,7 @@ export default function VehiculoDetailsPage() { return Math.max(...arreglos.map((a) => Number(a.kilometraje_leido) || 0)); }, [arreglos]); - const arreglosFiltrados = useMemo(() => { - if (!arreglos) return []; - const q = search.trim().toLowerCase(); - if (!q) return arreglos; - return arreglos.filter((a: Arreglo) => { - // Busca en las propiedades planas y en la patente del vehículo asociado - const inFlat = Object.values(a ?? {}).some((v) => - String(v ?? "").toLowerCase().includes(q) - ); - const patente = String(a?.vehiculo?.patente ?? "").toLowerCase(); - const inPatente = patente.includes(q); - return inFlat || inPatente; - }); - }, [arreglos, search]); + // El filtrado (search + patente/tipo/fecha) se maneja con useArreglosFilters const reload = useCallback(async () => { try { @@ -199,24 +186,30 @@ export default function VehiculoDetailsPage() {
-

- Ultimos arreglos -

- -
+ setIsArreglosFilterModalOpen(true)} + onOpenCreate={handleOpenCreate} + chips={arreglosFilters.chips} + onChipClick={arreglosFilters.removeFilter} + onClearFilters={arreglosFilters.clearFilters} + /> - + router.push(`/arreglos/${a.id}`)} + />
+ setIsArreglosFilterModalOpen(false)} + onApply={arreglosFilters.applyFilters} + initial={arreglosFilters.filters} + /> {vehiculo && ( Date: Mon, 22 Dec 2025 22:34:09 -0300 Subject: [PATCH 3/3] mueve filterchip a carpeta ui generica --- src/app/components/arreglos/ArreglosToolbar.tsx | 2 +- src/app/components/{arreglos => ui}/FilterChip.tsx | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/app/components/{arreglos => ui}/FilterChip.tsx (100%) diff --git a/src/app/components/arreglos/ArreglosToolbar.tsx b/src/app/components/arreglos/ArreglosToolbar.tsx index 9de02f7..0519c68 100644 --- a/src/app/components/arreglos/ArreglosToolbar.tsx +++ b/src/app/components/arreglos/ArreglosToolbar.tsx @@ -3,7 +3,7 @@ import SearchBar from "@/app/components/ui/SearchBar"; import Button from "@/app/components/ui/Button"; import { Filter, PlusIcon } from "lucide-react"; -import FilterChip from "@/app/components/arreglos/FilterChip"; +import FilterChip from "@/app/components/ui/FilterChip"; import type { ChipKind } from "@/app/hooks/arreglos/useArreglosFilters"; import { COLOR } from "@/theme/theme"; import { css } from "@emotion/react"; diff --git a/src/app/components/arreglos/FilterChip.tsx b/src/app/components/ui/FilterChip.tsx similarity index 100% rename from src/app/components/arreglos/FilterChip.tsx rename to src/app/components/ui/FilterChip.tsx