feat:(agenda) implementado filtro e busca de fot no cadastro de evento
This commit is contained in:
parent
f26d40dcff
commit
c9e34af619
2 changed files with 183 additions and 30 deletions
|
|
@ -27,6 +27,7 @@ import { UserRole } from "../types";
|
|||
import { InstitutionForm } from "./InstitutionForm";
|
||||
import { MapboxMap } from "./MapboxMap";
|
||||
import { getEventTypes, EventTypeResponse, getCadastroFot, createAgenda, getCompanies } from "../services/apiService";
|
||||
import { SearchableSelect, SearchableSelectOption } from "./SearchableSelect";
|
||||
|
||||
interface EventFormProps {
|
||||
onCancel: () => void;
|
||||
|
|
@ -855,39 +856,31 @@ export const EventForm: React.FC<EventFormProps> = ({
|
|||
|
||||
{/* Consolidated Turma Selection */}
|
||||
<div className="mb-0">
|
||||
<label className="block text-sm text-gray-600 mb-1">Selecione a Turma</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-brand-gold focus:border-brand-gold"
|
||||
<SearchableSelect
|
||||
label="Selecione a Turma"
|
||||
placeholder="Selecione a Turma (Curso - Instituição - Ano)"
|
||||
value={formData.fotId || ""}
|
||||
onChange={e => {
|
||||
const selectedFotId = e.target.value;
|
||||
const selectedFot = availableFots.find(f => f.id === selectedFotId);
|
||||
|
||||
if (selectedFot) {
|
||||
setFormData({
|
||||
...formData,
|
||||
fotId: selectedFotId,
|
||||
// Optional: You might want to store denormalized data if needed, but ID is usually enough
|
||||
});
|
||||
} else {
|
||||
setFormData({ ...formData, fotId: "" });
|
||||
}
|
||||
onChange={(value) => {
|
||||
const selectedFot = availableFots.find(f => f.id === value);
|
||||
if (selectedFot) {
|
||||
setFormData({
|
||||
...formData,
|
||||
fotId: value,
|
||||
});
|
||||
} else {
|
||||
setFormData({ ...formData, fotId: "" });
|
||||
}
|
||||
}}
|
||||
disabled={loadingFots || ((user?.role === UserRole.BUSINESS_OWNER || user?.role === UserRole.SUPERADMIN) && !selectedCompanyId)}
|
||||
>
|
||||
<option value="">Selecione a Turma (Curso - Instituição - Ano)</option>
|
||||
{availableFots.map(f => (
|
||||
<option
|
||||
key={f.id}
|
||||
value={f.id}
|
||||
disabled={f.finalizada}
|
||||
style={{ color: f.finalizada ? 'red' : 'inherit', fontWeight: f.finalizada ? 'bold' : 'normal' }}
|
||||
className={f.finalizada ? "text-red-600 bg-red-50" : ""}
|
||||
>
|
||||
{f.curso_nome} - {f.instituicao} - {f.ano_formatura_label} (FOT {f.fot}){f.finalizada ? " (FINALIZADA)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={availableFots.map(f => ({
|
||||
value: f.id,
|
||||
label: `${f.curso_nome} - ${f.instituicao} - ${f.ano_formatura_label} (FOT ${f.fot})${f.finalizada ? " (FINALIZADA)" : ""}`,
|
||||
disabled: f.finalizada,
|
||||
className: f.finalizada ? "text-red-600 bg-red-50 font-bold" : "",
|
||||
style: f.finalizada ? { color: 'red', fontWeight: 'bold' } : undefined
|
||||
}))}
|
||||
/>
|
||||
|
||||
{loadingFots && <p className="text-xs text-gray-500 mt-1">Carregando turmas...</p>}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
160
frontend/components/SearchableSelect.tsx
Normal file
160
frontend/components/SearchableSelect.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { ChevronDown, Search, X } from "lucide-react";
|
||||
|
||||
export interface SearchableSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
className?: string; // For custom styling like red text
|
||||
style?: React.CSSProperties; // For custom inline styles
|
||||
}
|
||||
|
||||
interface SearchableSelectProps {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
options: SearchableSelectOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
||||
label,
|
||||
placeholder = "Selecione...",
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = "",
|
||||
error,
|
||||
required = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Close when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Focus search input when opening
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle Selection
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onChange(optionValue);
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
// Filter options
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchTerm) return options;
|
||||
const lowerTerm = searchTerm.toLowerCase();
|
||||
return options.filter((opt) => opt.label.toLowerCase().includes(lowerTerm));
|
||||
}, [options, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`} ref={containerRef}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
||||
{label} {required && "*"}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-left border rounded-sm flex items-center justify-between transition-colors bg-white
|
||||
${error ? "border-red-500" : "border-gray-300"}
|
||||
${disabled ? "bg-gray-100 cursor-not-allowed text-gray-400" : "hover:border-gray-400 focus:ring-1 focus:ring-brand-gold focus:border-brand-gold"}
|
||||
`}
|
||||
>
|
||||
<span className={`block truncate ${!selectedOption ? "text-gray-500" : "text-gray-900"}`}>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown size={16} className={`text-gray-500 transition-transform ${isOpen ? "transform rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mb-1 bottom-full bg-white border border-gray-200 rounded-sm shadow-lg max-h-60 flex flex-col">
|
||||
{/* Search Input */}
|
||||
<div className="p-2 border-b border-gray-100 sticky top-0 bg-white z-10">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="w-full pl-8 pr-8 py-1.5 text-xs sm:text-sm border border-gray-200 rounded-sm focus:outline-none focus:border-brand-gold"
|
||||
placeholder="Buscar..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options List */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={opt.disabled}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-50 flex items-center justify-between
|
||||
${opt.value === value ? "bg-blue-50 text-brand-gold font-medium" : "text-gray-700"}
|
||||
${opt.disabled ? "opacity-50 cursor-not-allowed bg-gray-50/50" : "cursor-pointer"}
|
||||
${opt.className || ""}
|
||||
`}
|
||||
style={opt.style}
|
||||
>
|
||||
<span className="truncate">{opt.label}</span>
|
||||
{opt.value === value && <span className="text-brand-gold text-xs ml-2">✓</span>}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-3 text-center text-sm text-gray-500">
|
||||
Nenhum resultado encontrado
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <span className="text-xs text-red-500 mt-1">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue