Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions backend/src/db/environment.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"KEYWORDS": [
"Java",
"Spring",
"RabbitMQ",
"Docker"
"Frontend",
"Backend",
"PHP",
"Nodejs"
]
}
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -36,7 +36,7 @@ function App() {
);

const handleKeywordFilterChange = useCallback(
(value: SetStateAction<string>) => {
(value: SetStateAction<string[]>) => {
setKeywordFilter((previous) => (typeof value === "function" ? value(previous) : value));
resetPagination();
},
Expand Down Expand Up @@ -82,10 +82,12 @@ function App() {
meta={meta}
actions={
<>
<Button onClick={handleScraper} disabled={scraping}>
<RefreshCcw
className={`h-4 w-4 ${scraping ? "animate-spin" : ""}`}
/>
<Button
onClick={handleScraper}
disabled={scraping}
className="h-12 rounded-2xl bg-[#0c6b35] px-5 text-white shadow-sm hover:bg-[#0a5b2d]"
>
<FiRefreshCw className={`h-4 w-4 ${scraping ? "animate-spin" : ""}`} />
{scraping ? "Buscando vagas..." : "Buscar vagas"}
</Button>
</>
Expand Down
171 changes: 129 additions & 42 deletions frontend/src/components/JobsFiltersCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetStateAction<string>>;
keywordFilter: string;
setKeywordFilter: Dispatch<SetStateAction<string>>;
keywordFilter: string[];
setKeywordFilter: Dispatch<SetStateAction<string[]>>;
keywords: string[];
selectedFile: string;
setSelectedFile: Dispatch<SetStateAction<string>>;
Expand All @@ -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,
Expand All @@ -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 (
<>
<Card className="border-border/70 bg-card/85 backdrop-blur dark:bg-card/95">
<CardContent className="flex flex-col gap-3 pt-6">
{/* Campo de busca */}
<div className="flex items-center gap-3">
<Card className="rounded-[24px] border border-slate-200 bg-white/95 shadow-[0_18px_45px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#2b3f58] dark:bg-[#131d31]/95 dark:shadow-[0_18px_45px_rgba(3,8,20,0.25)]">
<CardContent className="flex flex-col gap-4 p-5 md:p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<FiSearch className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 dark:text-slate-400" />
<Input
value={search}
onChange={(event) => 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"
/>
</div>
<div className="flex items-center gap-2">{actions}</div>
</div>

{/* Seção de filtros */}
<div className="flex items-center gap-2">
<select
className="h-10 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
value={keywordFilter}
onChange={(event) => setKeywordFilter(event.target.value)}
>
<option value="all">Todas as palavras-chave</option>
{keywords.map((keyword) => (
<option key={keyword} value={keyword}>
{keyword}
</option>
))}
</select>

<div>
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-1 flex-wrap items-center gap-2">
<select
aria-label="Filtrar por palavra-chave"
className="h-12 min-w-[220px] rounded-2xl border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#14AE5C]/40 dark:border-[#35506f] dark:bg-[#0b1527] dark:text-slate-100"
value={keywordFilter[keywordFilter.length - 1] ?? "all"}
onChange={(event) => handleKeywordSelect(event.target.value)}
>
<option value="all">Todas as palavras chaves</option>

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UI text: "Todas as palavras chaves" should be "Todas as palavras-chave" (hyphen). This also matches the phrasing used elsewhere in the UI/test descriptions.

Suggested change
<option value="all">Todas as palavras chaves</option>
<option value="all">Todas as palavras-chave</option>

Copilot uses AI. Check for mistakes.
{keywords.map((keyword) => (
<option key={keyword} value={keyword}>
{keyword}
</option>
))}
</select>

<select
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
aria-label="Selecionar arquivo"
className="h-12 min-w-[180px] rounded-2xl border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#14AE5C]/40 dark:border-[#35506f] dark:bg-[#0b1527] dark:text-slate-100"
value={selectedFile}
onChange={(event) => setSelectedFile(event.target.value)}
>
Expand All @@ -80,21 +123,65 @@ export function JobsFiltersCard({
</option>
))}
</select>

<Badge variant="secondary" className="gap-1.5 rounded-full border border-slate-300 bg-slate-100 px-3 py-1 text-xs text-slate-700 dark:border-[#35506f] dark:bg-[#24324c] dark:text-slate-100">
<FiFileText className="h-3.5 w-3.5" />
{meta.file || "Sem arquivo"}
</Badge>

<Badge className="gap-1.5 rounded-full bg-[#0c6b35] px-3 py-1 text-xs text-white">
<FiBriefcase className="h-3.5 w-3.5" />
{meta.total} vagas
</Badge>
</div>

<div className="flex items-center gap-2 self-start xl:self-auto">
<Button
type="button"
className="h-10 rounded-xl bg-[#0c6b35] px-4 text-white hover:bg-[#0a5b2d]"
onClick={handleManageFilters}
>
<FiFilter className="h-4 w-4" />
Filtrar
</Button>
<Button
type="button"
variant="outline"
aria-label="Limpar filtros"
className="h-10 w-10 rounded-md border-[#0c6b35] bg-[#0c6b35] p-0 text-white shadow-sm hover:bg-[#0a5b2d]"
onClick={handleClearFilters}
>
<FiSlash className="h-4 w-4" />
</Button>
</div>
</div>

<div className="flex justify-between w-full">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1 text-xs">
<FileSpreadsheet className="h-3.5 w-3.5" />
{meta.file || "Sem arquivo"}
</Badge>
<Badge className="gap-1 text-xs">
<BriefcaseBusiness className="h-3.5 w-3.5" />
{meta.total} vagas
</Badge>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-3 py-3 dark:border-[#2b3f58] dark:bg-[#12203a]/45">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">Filtros selecionados</span>
{selectedFilters.length > 0 ? (
selectedFilters.map((filter) => (
<Badge
key={filter}
className="gap-1.5 rounded-full border border-[#14AE5C]/30 bg-[#14AE5C]/12 px-2 py-1 text-xs font-semibold text-[#0c6b35] dark:text-[#8df0af]"
>
<FiCheck className="h-3 w-3" />
<FiTag className="h-3 w-3" />
<span>{filter}</span>
<button
type="button"
aria-label={`Remover filtro ${filter}`}
className="ml-1 rounded-full p-0.5 text-[#0c6b35] hover:bg-[#14AE5C]/20 dark:text-[#8df0af]"
onClick={() => handleRemoveFilter(filter)}
>
<FiSlash className="h-3 w-3" />
</button>
</Badge>
))
) : (
<span className="text-sm text-slate-500 dark:text-slate-400">Use o botão Filtrar para adicionar ou remover palavras-chave.</span>
)}
</div>
<Button variant={"outline"} onClick={() => setSeeKeywordsModal(true)}>Keywords</Button>
</div>
</CardContent>
</Card>
Expand Down
Loading
Loading