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 { InstitutionForm } from "./InstitutionForm";
|
||||||
import { MapboxMap } from "./MapboxMap";
|
import { MapboxMap } from "./MapboxMap";
|
||||||
import { getEventTypes, EventTypeResponse, getCadastroFot, createAgenda, getCompanies } from "../services/apiService";
|
import { getEventTypes, EventTypeResponse, getCadastroFot, createAgenda, getCompanies } from "../services/apiService";
|
||||||
|
import { SearchableSelect, SearchableSelectOption } from "./SearchableSelect";
|
||||||
|
|
||||||
interface EventFormProps {
|
interface EventFormProps {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
|
@ -855,39 +856,31 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
|
|
||||||
{/* Consolidated Turma Selection */}
|
{/* Consolidated Turma Selection */}
|
||||||
<div className="mb-0">
|
<div className="mb-0">
|
||||||
<label className="block text-sm text-gray-600 mb-1">Selecione a Turma</label>
|
<SearchableSelect
|
||||||
<select
|
label="Selecione a Turma"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-brand-gold focus:border-brand-gold"
|
placeholder="Selecione a Turma (Curso - Instituição - Ano)"
|
||||||
value={formData.fotId || ""}
|
value={formData.fotId || ""}
|
||||||
onChange={e => {
|
onChange={(value) => {
|
||||||
const selectedFotId = e.target.value;
|
const selectedFot = availableFots.find(f => f.id === value);
|
||||||
const selectedFot = availableFots.find(f => f.id === selectedFotId);
|
if (selectedFot) {
|
||||||
|
setFormData({
|
||||||
if (selectedFot) {
|
...formData,
|
||||||
setFormData({
|
fotId: value,
|
||||||
...formData,
|
});
|
||||||
fotId: selectedFotId,
|
} else {
|
||||||
// Optional: You might want to store denormalized data if needed, but ID is usually enough
|
setFormData({ ...formData, fotId: "" });
|
||||||
});
|
}
|
||||||
} else {
|
|
||||||
setFormData({ ...formData, fotId: "" });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={loadingFots || ((user?.role === UserRole.BUSINESS_OWNER || user?.role === UserRole.SUPERADMIN) && !selectedCompanyId)}
|
disabled={loadingFots || ((user?.role === UserRole.BUSINESS_OWNER || user?.role === UserRole.SUPERADMIN) && !selectedCompanyId)}
|
||||||
>
|
options={availableFots.map(f => ({
|
||||||
<option value="">Selecione a Turma (Curso - Instituição - Ano)</option>
|
value: f.id,
|
||||||
{availableFots.map(f => (
|
label: `${f.curso_nome} - ${f.instituicao} - ${f.ano_formatura_label} (FOT ${f.fot})${f.finalizada ? " (FINALIZADA)" : ""}`,
|
||||||
<option
|
disabled: f.finalizada,
|
||||||
key={f.id}
|
className: f.finalizada ? "text-red-600 bg-red-50 font-bold" : "",
|
||||||
value={f.id}
|
style: f.finalizada ? { color: 'red', fontWeight: 'bold' } : undefined
|
||||||
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>
|
|
||||||
{loadingFots && <p className="text-xs text-gray-500 mt-1">Carregando turmas...</p>}
|
{loadingFots && <p className="text-xs text-gray-500 mt-1">Carregando turmas...</p>}
|
||||||
</div>
|
</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