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..0519c68
--- /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/ui/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 (
+
+
+
+ }
+ text="Filtrar"
+ onClick={onOpenFilters}
+ style={styles.filterButton}
+ data-testid="arreglos-open-filters"
+ outline
+ />
+ }
+ text="Crear arreglo"
+ onClick={onOpenCreate}
+ data-testid="arreglos-open-create"
+ />
+
+
+ {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/ui/FilterChip.tsx b/src/app/components/ui/FilterChip.tsx
new file mode 100644
index 0000000..2f6a8d8
--- /dev/null
+++ b/src/app/components/ui/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', () => {