diff --git a/frontend/package.json b/frontend/package.json index 7b9ace6f..1d645497 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "lucide-react": "^0.577.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-icons": "^5.6.0", "rolldown": "^1.0.0-rc.10", "tailwind-merge": "^3.5.0" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c55c69d..cce0636c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,8 +6,8 @@ import { useJobsData } from "@/hooks/useJobsData"; import { useJobsFiltering } from "@/hooks/useJobsFiltering"; import { useJobsPagination } from "@/hooks/useJobsPagination"; import type { JobsMeta } from "@/types/jobs"; -import { RefreshCcw } from "lucide-react"; import { useCallback, type SetStateAction } from "react"; +import { FiRefreshCw } from "react-icons/fi"; function formatDate(timestamp: JobsMeta["modifiedAt"]): string { if (!timestamp) { @@ -36,7 +36,7 @@ function App() { ); const handleKeywordFilterChange = useCallback( - (value: SetStateAction) => { + (value: SetStateAction) => { setKeywordFilter((previous) => (typeof value === "function" ? value(previous) : value)); resetPagination(); }, @@ -82,10 +82,12 @@ function App() { meta={meta} actions={ <> - diff --git a/frontend/src/components/JobsFiltersCard.tsx b/frontend/src/components/JobsFiltersCard.tsx index cbb0c167..04a2d24f 100644 --- a/frontend/src/components/JobsFiltersCard.tsx +++ b/frontend/src/components/JobsFiltersCard.tsx @@ -2,17 +2,15 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import type { JobFile, JobsMeta } from "@/types/jobs"; +import { useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; +import { FiBriefcase, FiCheck, FiFileText, FiFilter, FiSearch, FiSlash, FiTag } from "react-icons/fi"; import { KeywordsModal } from "./KeywordsModal"; -import { Search } from "lucide-react"; -import { useState, Dispatch, ReactNode, SetStateAction } from "react"; - -import { BriefcaseBusiness, FileSpreadsheet } from "lucide-react"; import { Button } from "./ui/button"; interface JobsFiltersCardProps { search: string; setSearch: Dispatch>; - keywordFilter: string; - setKeywordFilter: Dispatch>; + keywordFilter: string[]; + setKeywordFilter: Dispatch>; keywords: string[]; selectedFile: string; setSelectedFile: Dispatch>; @@ -21,6 +19,16 @@ interface JobsFiltersCardProps { actions?: ReactNode; } +function getSelectedFilters(search: string, keywordFilter: string[]) { + const searchTerms = search + .split(/[,;/]+/) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 5); + + return Array.from(new Set([...keywordFilter.filter(Boolean), ...searchTerms])); +} + export function JobsFiltersCard({ search, setSearch, @@ -35,42 +43,77 @@ export function JobsFiltersCard({ }: JobsFiltersCardProps) { const [seeKeywordsModal, setSeeKeywordsModal] = useState(false); + const selectedFilters = useMemo(() => getSelectedFilters(search, keywordFilter), [search, keywordFilter]); + + const handleManageFilters = () => { + setSeeKeywordsModal(true); + }; + + const handleKeywordSelect = (value: string) => { + if (!value) { + return; + } + + if (value === "all") { + setKeywordFilter([]); + return; + } + + setKeywordFilter((current) => (current.includes(value) ? current : [...current, value])); + }; + + const handleRemoveFilter = (filterToRemove: string) => { + setKeywordFilter((current) => current.filter((item) => item !== filterToRemove)); + setSearch((current) => + current + .split(/[,;/]+/) + .map((item) => item.trim()) + .filter((item) => item && item !== filterToRemove) + .join(", "), + ); + }; + + const handleClearFilters = () => { + setSearch(""); + setKeywordFilter([]); + }; + return ( <> - - - {/* Campo de busca */} -
+ + +
- + setSearch(event.target.value)} - className="pl-9 w-full" - placeholder="Buscar por titulo, empresa, local ou link" + className="h-14 w-full rounded-2xl border border-slate-300 bg-white pl-11 text-base text-slate-900 placeholder:text-slate-500 focus-visible:ring-[#14AE5C]/40 dark:border-[#35506f] dark:bg-[#091224] dark:text-slate-100 dark:placeholder:text-slate-400" + placeholder="Buscar por título, empresa, local ou link" />
{actions}
- {/* Seção de filtros */} -
- - -
+
+
+ + + + + + {meta.file || "Sem arquivo"} + + + + + {meta.total} vagas + +
+ +
+ +
+
-
-
- - - {meta.file || "Sem arquivo"} - - - - {meta.total} vagas - -
+
+
+ Filtros selecionados + {selectedFilters.length > 0 ? ( + selectedFilters.map((filter) => ( + + + + {filter} + + + )) + ) : ( + Use o botão Filtrar para adicionar ou remover palavras-chave. + )}
-
diff --git a/frontend/src/components/JobsTableCard.tsx b/frontend/src/components/JobsTableCard.tsx index ab8c9aac..eab3323d 100644 --- a/frontend/src/components/JobsTableCard.tsx +++ b/frontend/src/components/JobsTableCard.tsx @@ -1,7 +1,11 @@ +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { cn } from "@/lib/utils"; import type { Job, JobsMeta } from "@/types/jobs"; +import { useState } from "react"; +import { FiCheckSquare, FiSquare } from "react-icons/fi"; interface JobsTableCardProps { meta: JobsMeta; @@ -18,8 +22,47 @@ interface JobsTableCardProps { onPageSizeChange: (size: number) => void; } +type StatusToggleButtonProps = { + label: string; + pressed: boolean; + activeClassName: string; + onClick: () => void; +}; + const PAGE_SIZE_OPTIONS = [15, 25, 50, 100]; +function getJobId(job: Job, index: number) { + return job.link?.trim() || [job.titulo, job.empresa, job.local, String(index)].filter(Boolean).join("|"); +} + +function getKeywordTags(keywordValue?: string | null) { + if (!keywordValue) { + return []; + } + + return keywordValue + .split(/[,;|]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function StatusToggleButton({ label, pressed, activeClassName, onClick }: StatusToggleButtonProps) { + return ( + + ); +} + export function JobsTableCard({ meta, filteredJobs, @@ -34,8 +77,11 @@ export function JobsTableCard({ onPageChange, onPageSizeChange, }: JobsTableCardProps) { + const [spamMarks, setSpamMarks] = useState>({}); + const [readMarks, setReadMarks] = useState>({}); + return ( - + Vagas Encontradas @@ -44,7 +90,7 @@ export function JobsTableCard({ {error ? ( -
+
{error}
) : null} @@ -95,38 +141,84 @@ export function JobsTableCard({ Empresa Local Link - Palavra-chave + Palavras chaves + Spam + Lido Fonte - {paginatedJobs.map((job, index) => ( - - {job.titulo || "-"} - {job.empresa || "-"} - {job.local || "-"} - - {job.link ? ( - - Abrir vaga - - ) : ( - "-" - )} - - {job.palavra || "-"} - {job.source || "-"} - - ))} + {paginatedJobs.map((job, index) => { + const jobId = getJobId(job, index); + const keywords = getKeywordTags(job.palavra); + const isSpamMarked = Boolean(spamMarks[jobId]); + const isReadMarked = Boolean(readMarks[jobId]); + + return ( + + {job.titulo || "-"} + {job.empresa || "-"} + {job.local || "-"} + + {job.link ? ( + + Abrir vaga + + ) : ( + "-" + )} + + + {keywords.length > 0 ? ( +
+ {keywords.map((keyword, keywordIndex) => ( +
+ {keyword} + {keywordIndex < keywords.length - 1 ? : null} +
+ ))} +
+ ) : ( + - + )} +
+ + setSpamMarks((current) => ({ ...current, [jobId]: !current[jobId] }))} + /> + + + setReadMarks((current) => ({ ...current, [jobId]: !current[jobId] }))} + /> + + + {job.source ? ( + + {job.source} + + ) : ( + "-" + )} + +
+ ); + })} {!loading && filteredJobs.length === 0 ? ( - + Nenhuma vaga encontrada com os filtros atuais. diff --git a/frontend/src/components/KeywordsModal.tsx b/frontend/src/components/KeywordsModal.tsx index bbdc895a..b5f3a0d6 100644 --- a/frontend/src/components/KeywordsModal.tsx +++ b/frontend/src/components/KeywordsModal.tsx @@ -1,10 +1,10 @@ +import { fetchKeywords, saveKeywords } from "@/services/jobsService"; +import { X } from "lucide-react"; import { useEffect, useState } from "react"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "./ui/card"; +import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; import { Input } from "./ui/input"; -import { fetchKeywords, saveKeywords } from "@/services/jobsService"; -import { Badge } from "./ui/badge"; -import { X } from "lucide-react"; interface KeywordsModalProps { onClose: () => void; @@ -59,21 +59,22 @@ export function KeywordsModal({ onClose }: KeywordsModalProps) {
- Gerenciar Keywords + Gerenciar filtros - Adicione ou remova as palavras-chave usadas para filtrar as vagas. + Adicione ou remova as palavras-chave usadas na busca e nos filtros de vagas.
setNewKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleAddKeyword()} />
diff --git a/frontend/src/hooks/useJobsFiltering.ts b/frontend/src/hooks/useJobsFiltering.ts index a8c55b1b..a54460c4 100644 --- a/frontend/src/hooks/useJobsFiltering.ts +++ b/frontend/src/hooks/useJobsFiltering.ts @@ -1,9 +1,9 @@ -import { useMemo, useState } from "react"; import type { Job } from "@/types/jobs"; +import { useMemo, useState } from "react"; export function useJobsFiltering(jobs: Job[]) { const [search, setSearch] = useState(""); - const [keywordFilter, setKeywordFilter] = useState("all"); + const [keywordFilter, setKeywordFilter] = useState([]); const keywords = useMemo(() => { const values = Array.from(new Set(jobs.map((job) => String(job.palavra || "").trim()).filter(Boolean))); @@ -14,7 +14,8 @@ export function useJobsFiltering(jobs: Job[]) { const term = search.trim().toLowerCase(); return jobs.filter((job) => { - const byKeyword = keywordFilter === "all" || String(job.palavra || "") === keywordFilter; + const currentKeyword = String(job.palavra || "").trim(); + const byKeyword = keywordFilter.length === 0 || keywordFilter.includes(currentKeyword); if (!byKeyword) { return false; } diff --git a/frontend/tests/unit/components/JobsFiltersCard.test.tsx b/frontend/tests/unit/components/JobsFiltersCard.test.tsx index 3adc718f..f3d88197 100644 --- a/frontend/tests/unit/components/JobsFiltersCard.test.tsx +++ b/frontend/tests/unit/components/JobsFiltersCard.test.tsx @@ -2,6 +2,10 @@ import { JobsFiltersCard } from "@/components/JobsFiltersCard"; import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +vi.mock("@/components/KeywordsModal", () => ({ + KeywordsModal: () =>
Gerenciar filtros
, +})); + describe("JobsFiltersCard", () => { it("dispara callbacks de filtro e refresh", () => { const setSearch = vi.fn(); @@ -13,7 +17,7 @@ describe("JobsFiltersCard", () => { { expect(onRefresh).toHaveBeenCalledTimes(1); }); - it("renderiza badge de arquivo e total de vagas", () => { + it("renderiza badge de arquivo, total e area de filtros", () => { render( { expect(screen.getAllByText("vagas.xlsx").length).toBeGreaterThan(0); expect(screen.getByText("1 vagas")).toBeInTheDocument(); + expect(screen.getByText(/filtros selecionados/i)).toBeInTheDocument(); + expect(screen.getByText(/use o botão filtrar/i)).toBeInTheDocument(); }); it("dispara mudancas nos filtros de keyword e arquivo", () => { @@ -62,7 +68,7 @@ describe("JobsFiltersCard", () => { { expect(setKeywordFilter).toHaveBeenCalled(); expect(setSelectedFile).toHaveBeenCalled(); }); + + it("abre o gerenciador ao clicar em filtrar e permite limpar os filtros selecionados", () => { + const setSearch = vi.fn(); + const setKeywordFilter = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^filtrar$/i })); + expect(screen.getByText(/gerenciar filtros/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /limpar filtros/i })); + + expect(screen.getAllByText("React").length).toBeGreaterThan(0); + expect(setSearch).toHaveBeenCalledWith(""); + expect(setKeywordFilter).toHaveBeenCalledWith([]); + }); }); diff --git a/frontend/tests/unit/components/JobsTableCard.test.tsx b/frontend/tests/unit/components/JobsTableCard.test.tsx index 9e982ade..9b206a25 100644 --- a/frontend/tests/unit/components/JobsTableCard.test.tsx +++ b/frontend/tests/unit/components/JobsTableCard.test.tsx @@ -4,8 +4,26 @@ import { describe, expect, it, vi } from "vitest"; const baseProps = { meta: { file: "vagas.xlsx", modifiedAt: 1, total: 1 }, - filteredJobs: [{ titulo: "Dev", empresa: "ACME", local: "BR", palavra: "React", link: "https://x" }], - paginatedJobs: [{ titulo: "Dev", empresa: "ACME", local: "BR", palavra: "React", link: "https://x" }], + filteredJobs: [ + { + titulo: "Dev", + empresa: "ACME", + local: "BR", + palavra: "JavaScript, UX-UI", + source: "LinkedIn", + link: "https://x", + }, + ], + paginatedJobs: [ + { + titulo: "Dev", + empresa: "ACME", + local: "BR", + palavra: "JavaScript, UX-UI", + source: "LinkedIn", + link: "https://x", + }, + ], jobs: [{ titulo: "Dev" }], loading: false, error: "", @@ -18,10 +36,15 @@ const baseProps = { }; describe("JobsTableCard", () => { - it("renderiza tabela e paginacao", () => { + it("renderiza tabela com palavras-chave em tags e colunas de spam e lido", () => { render(); + expect(screen.getByText("Vagas Encontradas")).toBeInTheDocument(); expect(screen.getByText("Abrir vaga")).toBeInTheDocument(); + expect(screen.getByText("JavaScript")).toBeInTheDocument(); + expect(screen.getByText("UX-UI")).toBeInTheDocument(); + expect(screen.getByText(/spam/i)).toBeInTheDocument(); + expect(screen.getByText(/lido/i)).toBeInTheDocument(); }); it("aciona paginacao", () => { @@ -30,6 +53,19 @@ describe("JobsTableCard", () => { expect(baseProps.onPageChange).toHaveBeenCalled(); }); + it("permite marcar a vaga como spam e lida", () => { + render(); + + const spamButton = screen.getByLabelText(/marcar vaga como spam/i); + const readButton = screen.getByLabelText(/marcar vaga como lida/i); + + fireEvent.click(spamButton); + fireEvent.click(readButton); + + expect(spamButton).toHaveAttribute("aria-pressed", "true"); + expect(readButton).toHaveAttribute("aria-pressed", "true"); + }); + it("exibe mensagem de erro quando error esta preenchido", () => { render(); expect(screen.getByText("falha ao carregar")).toBeInTheDocument(); diff --git a/frontend/tests/unit/components/KeywordsModal.test.tsx b/frontend/tests/unit/components/KeywordsModal.test.tsx index 800228f8..02f8f989 100644 --- a/frontend/tests/unit/components/KeywordsModal.test.tsx +++ b/frontend/tests/unit/components/KeywordsModal.test.tsx @@ -73,6 +73,8 @@ describe("KeywordsModal", () => { const onClose = vi.fn(); render(); + await waitFor(() => expect(screen.queryByText(/carregando/i)).not.toBeInTheDocument()); + const cancelButton = screen.getByRole("button", { name: /cancelar/i }); fireEvent.click(cancelButton); diff --git a/frontend/tests/unit/pages/App.test.tsx b/frontend/tests/unit/pages/App.test.tsx index 4d7f6953..15d75494 100644 --- a/frontend/tests/unit/pages/App.test.tsx +++ b/frontend/tests/unit/pages/App.test.tsx @@ -13,8 +13,10 @@ vi.mock("@/hooks/useJobsData", () => ({ jobs: [{ titulo: "Dev", empresa: "ACME", local: "BR", palavra: "React", link: "x" }], meta: { file: "vagas.xlsx", modifiedAt: 1, total: 1 }, loading: false, + scraping: false, error: "", loadJobs: vi.fn(), + triggerScraper: vi.fn(), }), })); @@ -22,7 +24,7 @@ vi.mock("@/hooks/useJobsFiltering", () => ({ useJobsFiltering: () => ({ search: "", setSearch: vi.fn(), - keywordFilter: "all", + keywordFilter: [], setKeywordFilter: vi.fn(), keywords: ["React"], filteredJobs: [{ titulo: "Dev", empresa: "ACME", local: "BR", palavra: "React", link: "x" }], diff --git a/frontend/tests/unit/utils/useJobsFiltering.test.ts b/frontend/tests/unit/utils/useJobsFiltering.test.ts index 21dffd93..3813d8c5 100644 --- a/frontend/tests/unit/utils/useJobsFiltering.test.ts +++ b/frontend/tests/unit/utils/useJobsFiltering.test.ts @@ -21,4 +21,21 @@ describe("useJobsFiltering", () => { expect(result.current.filteredJobs).toHaveLength(1); expect(result.current.filteredJobs[0].titulo).toBe("Backend"); }); + + it("permite selecionar varias palavras-chave ao mesmo tempo", () => { + const { result } = renderHook(() => useJobsFiltering(JOBS)); + + act(() => { + result.current.setKeywordFilter(["React", "Node"]); + }); + + expect(result.current.filteredJobs).toHaveLength(2); + + act(() => { + result.current.setKeywordFilter(["React"]); + }); + + expect(result.current.filteredJobs).toHaveLength(1); + expect(result.current.filteredJobs[0].titulo).toBe("Frontend"); + }); }); diff --git a/package-lock.json b/package-lock.json index 65c5ba5c..499a284f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "lucide-react": "^0.577.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-icons": "^5.6.0", "rolldown": "^1.0.0-rc.10", "tailwind-merge": "^3.5.0" }, @@ -10092,6 +10093,15 @@ "react": "^19.2.4" } }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",