From 9bcd17085cae34fb337ddf45a5968b53d14da3a1 Mon Sep 17 00:00:00 2001 From: Luisfp0 <luis.oliveirabr1@gmail.com> Date: Fri, 1 Nov 2024 16:00:35 -0300 Subject: [PATCH] feat: skillsFilter on applicationManagement --- .../[roleId]/ApplicationManagement.tsx | 162 ++++++++++++------ .../[id]/applications/[roleId]/page.tsx | 20 ++- apps/web/app/components/SkillsFilter.tsx | 106 ++++++++++++ 3 files changed, 231 insertions(+), 57 deletions(-) create mode 100644 apps/web/app/components/SkillsFilter.tsx diff --git a/apps/web/app/(roles)/dashboard/[id]/applications/[roleId]/ApplicationManagement.tsx b/apps/web/app/(roles)/dashboard/[id]/applications/[roleId]/ApplicationManagement.tsx index 84e2bcc9..f8616269 100644 --- a/apps/web/app/(roles)/dashboard/[id]/applications/[roleId]/ApplicationManagement.tsx +++ b/apps/web/app/(roles)/dashboard/[id]/applications/[roleId]/ApplicationManagement.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { Eye, ChevronLeft, Filter, ChevronDown } from 'lucide-react' +import SkillsFilter from 'app/components/SkillsFilter' const statusColors = { pending: 'bg-yellow-100 text-yellow-800', @@ -31,6 +32,7 @@ export default function ApplicationsManagement({ roleId, applications: initialApplications, userId, + allSkills, }) { const router = useRouter() const [applications, setApplications] = useState(initialApplications) @@ -39,6 +41,16 @@ export default function ApplicationsManagement({ englishLevel: '', yearsOfExperience: '', }) + const [selectedSkills, setSelectedSkills] = useState([]) + + const filterApplicationsBySkills = (applications) => { + if (selectedSkills.length === 0) return applications + + return applications.filter((application) => { + const applicantSkills = application.Subscribers.skillsId || [] + return selectedSkills.some((skillId) => applicantSkills.includes(skillId)) + }) + } const calculateExperience = (startDate: string) => { const start = new Date(startDate) @@ -65,12 +77,22 @@ export default function ApplicationsManagement({ }) } + // Adicione o filtro de skills + if (selectedSkills.length > 0) { + filtered = filtered.filter((application) => { + const applicantSkills = application.Subscribers.skillsId || [] + return selectedSkills.some((skillId) => + applicantSkills.includes(skillId) + ) + }) + } + setApplications(filtered) } useEffect(() => { applyFilters() - }, [filters]) + }, [filters, selectedSkills]) return ( <div className="container mx-auto px-4 py-8"> @@ -96,12 +118,10 @@ export default function ApplicationsManagement({ </div> <div className="p-4"> - <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"> - <div className="flex flex-col gap-1.5"> - <label - htmlFor="englishLevel" - className="text-sm font-medium text-gray-700" - > + {/* Grid de filtros */} + <div className="grid grid-cols-1 gap-6 sm:grid-cols-3"> + <div> + <label className="mb-1.5 block text-sm font-medium text-gray-700"> Nível de Inglês </label> <div className="relative"> @@ -127,11 +147,8 @@ export default function ApplicationsManagement({ </div> </div> - <div className="flex flex-col gap-1.5"> - <label - htmlFor="experience" - className="text-sm font-medium text-gray-700" - > + <div> + <label className="mb-1.5 block text-sm font-medium text-gray-700"> Experiência Mínima </label> <div className="relative"> @@ -157,57 +174,90 @@ export default function ApplicationsManagement({ </div> </div> - <div className="flex items-end lg:col-span-2"> - {filters.englishLevel || filters.yearsOfExperience ? ( - <div className="flex w-full flex-wrap items-center justify-between gap-2"> - <div className="flex flex-wrap gap-2"> - {filters.englishLevel && ( - <span className="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-3 py-1 text-sm font-medium text-indigo-600"> - {englishLevelTranslations[filters.englishLevel]} - <button - onClick={() => - setFilters((prev) => ({ - ...prev, - englishLevel: '', - })) - } - className="ml-1 rounded-full p-0.5 hover:bg-indigo-100" - > - × - </button> - </span> - )} - {filters.yearsOfExperience && ( - <span className="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-3 py-1 text-sm font-medium text-indigo-600"> - {filters.yearsOfExperience}+ anos - <button - onClick={() => - setFilters((prev) => ({ - ...prev, - yearsOfExperience: '', - })) - } - className="ml-1 rounded-full p-0.5 hover:bg-indigo-100" - > - × - </button> - </span> - )} - </div> + <SkillsFilter + allSkills={allSkills} + applications={initialApplications} + selectedSkills={selectedSkills} + onFilterChange={(newSkills) => { + setSelectedSkills(newSkills) + }} + onSelectedSkillRemove={(skillId) => { + setSelectedSkills((prev) => prev.filter((id) => id !== skillId)) + }} + /> + </div> + + {/* Tags dos filtros selecionados */} + {(filters.englishLevel || + filters.yearsOfExperience || + selectedSkills.length > 0) && ( + <div className="mt-4 flex flex-wrap items-center gap-2"> + {filters.englishLevel && ( + <span className="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-3 py-1 text-sm font-medium text-indigo-600"> + {englishLevelTranslations[filters.englishLevel]} <button onClick={() => - setFilters({ englishLevel: '', yearsOfExperience: '' }) + setFilters((prev) => ({ + ...prev, + englishLevel: '', + })) } - className="text-sm font-medium text-gray-500 hover:text-gray-700" + className="ml-1 rounded-full p-0.5 hover:bg-indigo-100" > - Limpar todos + × </button> - </div> - ) : ( - <></> + </span> )} + {filters.yearsOfExperience && ( + <span className="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-3 py-1 text-sm font-medium text-indigo-600"> + {filters.yearsOfExperience}+ anos + <button + onClick={() => + setFilters((prev) => ({ + ...prev, + yearsOfExperience: '', + })) + } + className="ml-1 rounded-full p-0.5 hover:bg-indigo-100" + > + × + </button> + </span> + )} + {selectedSkills.map((skillId) => { + const skill = allSkills.find((s) => s.id.toString() === skillId) + if (!skill) return null + + return ( + <span + key={skillId} + className="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-3 py-1 text-sm font-medium text-indigo-600" + > + {skill.emoji} {skill.name} + <button + onClick={() => + setSelectedSkills((prev) => + prev.filter((id) => id !== skillId) + ) + } + className="ml-1 rounded-full p-0.5 hover:bg-indigo-100" + > + × + </button> + </span> + ) + })} + <button + onClick={() => { + setFilters({ englishLevel: '', yearsOfExperience: '' }) + setSelectedSkills([]) + }} + className="text-sm font-medium text-gray-500 hover:text-gray-700" + > + Limpar todos + </button> </div> - </div> + )} </div> </div> diff --git a/apps/web/app/(roles)/dashboard/[id]/applications/[roleId]/page.tsx b/apps/web/app/(roles)/dashboard/[id]/applications/[roleId]/page.tsx index 45ffddef..68096c8a 100644 --- a/apps/web/app/(roles)/dashboard/[id]/applications/[roleId]/page.tsx +++ b/apps/web/app/(roles)/dashboard/[id]/applications/[roleId]/page.tsx @@ -1,7 +1,6 @@ import { getSupabaseClient } from 'db' import { notFound } from 'next/navigation' import ApplicationsManagement from './ApplicationManagement' - interface PageProps { params: { id: string @@ -40,6 +39,22 @@ async function getApplications(roleId: string) { return applications } +async function getAllSkills() { + const supabase = getSupabaseClient() + + const { data: skills, error } = await supabase + .from('Skills') + .select('*') + .order('name') + + if (error) { + console.error('Error fetching skills:', error) + return null + } + + return skills +} + async function verifyRoleOwnership(roleId: string, userId: string) { const supabase = getSupabaseClient() @@ -70,11 +85,14 @@ export default async function ApplicationsPage({ params }: PageProps) { notFound() } + const allSkills = await getAllSkills() + return ( <ApplicationsManagement roleId={roleId} userId={userId} applications={applications} + allSkills={allSkills} /> ) } diff --git a/apps/web/app/components/SkillsFilter.tsx b/apps/web/app/components/SkillsFilter.tsx new file mode 100644 index 00000000..e8cf02a0 --- /dev/null +++ b/apps/web/app/components/SkillsFilter.tsx @@ -0,0 +1,106 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react' +import { ChevronDown } from 'lucide-react' + +const SkillsFilter = ({ + allSkills, + applications, + selectedSkills = [], + onFilterChange, + onSelectedSkillRemove, +}) => { + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + // Filtrar apenas as skills que existem entre os candidatos + const availableSkills = useMemo(() => { + // Coletar todas as skills de todos os candidatos + const candidateSkillIds = new Set() + applications.forEach((application) => { + const skills = application.Subscribers?.skillsId || [] + skills.forEach((skillId) => candidateSkillIds.add(skillId)) + }) + + // Filtrar o array de todas as skills para incluir apenas as que existem nos candidatos + return allSkills + .filter((skill) => candidateSkillIds.has(skill.id.toString())) + .sort((a, b) => a.name.localeCompare(b.name)) + }, [applications, allSkills]) + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleSkillSelect = (skillId) => { + const isSelected = selectedSkills.includes(skillId) + let newSelectedSkills + + if (isSelected) { + newSelectedSkills = selectedSkills.filter((id) => id !== skillId) + } else { + newSelectedSkills = [...selectedSkills, skillId] + } + + onFilterChange(newSelectedSkills) + } + + const getDisplayText = () => { + if (selectedSkills.length === 0) { + return 'Selecionar skills' + } + return `${selectedSkills.length} skill${ + selectedSkills.length === 1 ? '' : 's' + } selecionada${selectedSkills.length === 1 ? '' : 's'}` + } + + return ( + <div ref={dropdownRef} className="relative"> + <label className="mb-1.5 block text-sm font-medium text-gray-700"> + Skills + </label> + <div className="relative"> + <button + onClick={() => setIsOpen(!isOpen)} + className="relative h-10 w-full appearance-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" + > + <span className="block truncate">{getDisplayText()}</span> + <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> + <ChevronDown className="h-4 w-4 text-gray-900" /> + </span> + </button> + + {isOpen && availableSkills.length > 0 && ( + <div className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"> + <div className="divide-y divide-gray-100"> + {availableSkills.map((skill) => ( + <div + key={skill.id} + onClick={() => handleSkillSelect(skill.id.toString())} + className={`flex cursor-pointer items-center px-4 py-2 text-sm hover:bg-gray-50 ${ + selectedSkills.includes(skill.id.toString()) + ? 'bg-indigo-50' + : '' + }`} + > + <span className="mr-2">{skill.emoji}</span> + <span>{skill.name}</span> + {selectedSkills.includes(skill.id.toString()) && ( + <span className="ml-auto text-indigo-600">✓</span> + )} + </div> + ))} + </div> + </div> + )} + </div> + </div> + ) +} + +export default SkillsFilter