Vendaweb-portal/components/FilterSidebar.tsx

676 lines
28 KiB
TypeScript

import React, { useState, useEffect } from "react";
import {
productService,
ClasseMercadologica,
} from "../src/services/product.service";
import { authService } from "../src/services/auth.service";
import { CustomAutocomplete } from "./ui/autocomplete";
interface DepartmentItem {
name: string;
url: string;
path: string; // Adicionar path para seguir padrão do Angular
subcategories: DepartmentItem[];
}
interface FilterSidebarProps {
selectedDepartment: string;
onDepartmentChange: (department: string) => void;
selectedBrands: string[];
onBrandsChange: (brands: string[]) => void;
filters: {
onlyInStock: boolean;
stockBranch: string;
onPromotion: boolean;
discountRange: [number, number];
priceDropped: boolean;
opportunities: boolean;
unmissableOffers: boolean;
};
onFiltersChange: (filters: FilterSidebarProps["filters"]) => void;
onApplyFilters: () => void;
brands?: string[]; // Marcas podem ser passadas como prop opcional
onBrandsLoaded?: (brands: string[]) => void; // Callback quando marcas são carregadas
}
const FilterSidebar: React.FC<FilterSidebarProps> = ({
selectedDepartment,
onDepartmentChange,
selectedBrands,
onBrandsChange,
filters,
onFiltersChange,
onApplyFilters,
brands: brandsProp,
onBrandsLoaded,
}) => {
const [expandedDepartments, setExpandedDepartments] = useState<string[]>([]);
const [departments, setDepartments] = useState<DepartmentItem[]>([]);
const [brands, setBrands] = useState<string[]>(brandsProp || []);
const [stores, setStores] = useState<
Array<{ id: string; name: string; shortName: string }>
>([]);
const [loadingDepartments, setLoadingDepartments] = useState<boolean>(true);
const [loadingBrands, setLoadingBrands] = useState<boolean>(false);
const [loadingStores, setLoadingStores] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Sincronizar marcas quando a prop mudar
useEffect(() => {
if (brandsProp) {
setBrands(brandsProp);
if (onBrandsLoaded) {
onBrandsLoaded(brandsProp);
}
}
}, [brandsProp, onBrandsLoaded]);
/**
* Carrega as filiais disponíveis para o usuário
*/
useEffect(() => {
const loadStores = async () => {
try {
setLoadingStores(true);
const storesData = await productService.getStores();
setStores(storesData);
} catch (err: any) {
console.error("Erro ao carregar filiais:", err);
// Não define erro global pois as filiais são opcionais
} finally {
setLoadingStores(false);
}
};
loadStores();
}, []);
/**
* Mapeia os dados hierárquicos da API para a estrutura do componente
* Segue o mesmo padrão do Angular: mapItems
*/
/**
* Mapeia os dados hierárquicos da API para a estrutura do componente
* Segue EXATAMENTE o padrão do Angular: mapItems
* No Angular: path: item.url (cada item tem sua própria URL, usada diretamente)
*
* IMPORTANTE: No Angular, quando clica em uma seção dentro de um departamento,
* a URL da seção no banco já deve ser completa (ex: "ferragens/cadeados")
* ou precisa ser construída concatenando departamento/seção.
*
* Seguindo o exemplo do usuário "ferragens/cadeados", vamos construir o path
* completo quando há hierarquia, mas também manter a URL original do item.
*/
const mapItems = (
items: any[],
textFields: string[],
childFields: string[],
level: number = 0,
parentPath: string = "" // Path do item pai para construir URL completa quando necessário
): DepartmentItem[] => {
const childField = childFields[level];
const textField = textFields[level];
return items
.filter((item) => item && item[textField] != null)
.map((item) => {
// No Angular: path: item.url (usa diretamente a URL do item)
const itemUrl = item.url || "";
// Construir path completo seguindo hierarquia
// Se há parentPath e itemUrl não começa com parentPath, concatenar
// Caso contrário, usar apenas itemUrl (já vem completo da API)
let fullPath = itemUrl;
if (parentPath && itemUrl && !itemUrl.startsWith(parentPath)) {
// Se a URL não começa com o path do pai, construir concatenando
fullPath = `${parentPath}/${itemUrl}`;
} else if (!itemUrl && parentPath) {
// Se não tem URL mas tem parentPath, usar apenas parentPath
fullPath = parentPath;
}
const result: DepartmentItem = {
name: item[textField] || "",
url: itemUrl, // URL original do item
path: fullPath, // Path completo para usar no filtro (seguindo padrão do Angular)
subcategories: [],
};
if (
childField &&
item[childField] &&
Array.isArray(item[childField]) &&
item[childField].length > 0
) {
// Passar o path completo para os filhos (para construir hierarquia completa)
result.subcategories = mapItems(
item[childField],
textFields,
childFields,
level + 1,
fullPath // Passar path completo para construir hierarquia
);
}
return result;
});
};
/**
* Carrega os departamentos da API
*/
useEffect(() => {
const loadDepartments = async () => {
try {
setLoadingDepartments(true);
setError(null);
const data = await productService.getClasseMercadologica();
// Seguir EXATAMENTE o padrão do Angular: mapItems com os mesmos parâmetros
const mappedItems = mapItems(
data.filter((d) => d.tituloEcommerce != null), // Filtrar como no Angular
["tituloEcommerce", "descricaoSecao", "descricaoCategoria"],
["secoes", "categorias"],
0, // level inicial
"" // parentPath inicial vazio
);
setDepartments(mappedItems);
// Departamentos iniciam todos fechados (não expandidos)
} catch (err: any) {
console.error("Erro ao carregar departamentos:", err);
setError("Erro ao carregar departamentos. Tente novamente.");
} finally {
setLoadingDepartments(false);
}
};
loadDepartments();
}, []);
/**
* Carrega as marcas a partir dos produtos
* Segue o mesmo padrão do Angular: extrai marcas dos produtos retornados pela API
* No Angular: quando getProductByFilter é chamado, os produtos são processados para extrair marcas
*
* NOTA: As marcas serão carregadas quando os produtos forem buscados pela primeira vez
* Por enquanto, não carregamos automaticamente para evitar chamadas desnecessárias à API
* O componente pai pode passar as marcas via props quando necessário
*/
useEffect(() => {
// As marcas serão carregadas dinamicamente quando os produtos forem buscados
// Isso evita uma chamada extra à API no carregamento inicial
// O componente pai pode passar as marcas via props quando necessário
setLoadingBrands(false);
}, []);
return (
<aside className="w-full xl:w-80 bg-white border-r border-slate-200 p-4 xl:p-6 flex flex-col overflow-y-auto custom-scrollbar">
{/* Todos Departamentos */}
<div className="mb-8">
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4">
Todos Departamentos
</h3>
{loadingDepartments ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-orange-500"></div>
<span className="ml-2 text-xs text-slate-500">Carregando...</span>
</div>
) : error ? (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-xs text-red-600">{error}</p>
</div>
) : (
<nav className="space-y-0.5">
{departments.map((dept) => (
<div key={dept.name}>
<div className="flex items-center">
{/* Ícone para expandir/colapsar (apenas se tiver subcategorias) */}
{dept.subcategories.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setExpandedDepartments((prev) =>
prev.includes(dept.name)
? prev.filter((d) => d !== dept.name)
: [...prev, dept.name]
);
}}
className="p-1 hover:bg-slate-100 rounded transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${
expandedDepartments.includes(dept.name)
? "rotate-90"
: ""
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
)}
{/* Espaçador quando não tem subcategorias */}
{dept.subcategories.length === 0 && (
<div className="w-5"></div>
)}
{/* Botão do texto para aplicar filtro */}
<button
onClick={() => {
// Sempre aplicar o filtro ao clicar no texto
// Seguir padrão do Angular: usar path (que é item.url)
// No Angular: this.filters.urlCategory = data.dataItem.path;
onDepartmentChange(dept.path || dept.url || dept.name);
// Se tiver subcategorias e não estiver expandido, expandir também
if (
dept.subcategories.length > 0 &&
!expandedDepartments.includes(dept.name)
) {
setExpandedDepartments((prev) => [...prev, dept.name]);
}
}}
className={`flex-1 text-left px-3 py-2 rounded-lg text-xs font-bold transition-all ${
selectedDepartment ===
(dept.path || dept.url || dept.name)
? "bg-blue-50 text-blue-600"
: "text-slate-600 hover:bg-slate-50"
}`}
>
{dept.name}
</button>
</div>
{dept.subcategories.length > 0 &&
expandedDepartments.includes(dept.name) && (
<div className="ml-5 mt-0.5 space-y-0.5">
{dept.subcategories.map((sub) => {
const hasSubcategories =
sub.subcategories && sub.subcategories.length > 0;
const isSubExpanded = expandedDepartments.includes(
`${dept.name}-${sub.name}`
);
return (
<div key={sub.url || sub.name}>
<div className="flex items-center">
{/* Ícone para expandir/colapsar (apenas se tiver subcategorias) */}
{hasSubcategories && (
<button
onClick={(e) => {
e.stopPropagation();
setExpandedDepartments((prev) =>
isSubExpanded
? prev.filter(
(d) =>
d !== `${dept.name}-${sub.name}`
)
: [...prev, `${dept.name}-${sub.name}`]
);
}}
className="p-1 hover:bg-slate-100 rounded transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${
isSubExpanded ? "rotate-90" : ""
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
)}
{/* Espaçador quando não tem subcategorias */}
{!hasSubcategories && <div className="w-5"></div>}
{/* Botão do texto para aplicar filtro */}
<button
onClick={() => {
// Sempre aplicar o filtro ao clicar no texto
// Seguir EXATAMENTE o padrão do Angular: usar path (que já é a URL completa)
// No Angular: this.filters.urlCategory = data.dataItem.path;
// O path já foi construído no mapItems com a hierarquia completa
onDepartmentChange(
sub.path || sub.url || sub.name
);
// Se tiver subcategorias e não estiver expandido, expandir também
if (hasSubcategories && !isSubExpanded) {
setExpandedDepartments((prev) => [
...prev,
`${dept.name}-${sub.name}`,
]);
}
}}
className={`flex-1 text-left px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
selectedDepartment ===
(sub.path || sub.url || sub.name)
? "bg-blue-50 text-blue-600"
: "text-slate-500 hover:bg-slate-50"
}`}
>
{sub.name}
</button>
</div>
{/* Renderizar categorias (terceiro nível) se existirem */}
{hasSubcategories && isSubExpanded && (
<div className="ml-5 mt-0.5 space-y-0.5">
{sub.subcategories.map((category) => (
<button
key={category.url || category.name}
onClick={() => {
// Seguir EXATAMENTE o padrão do Angular: usar path (que já é a URL completa)
// No Angular: this.filters.urlCategory = data.dataItem.path;
onDepartmentChange(
category.path ||
category.url ||
category.name
);
}}
className={`w-full text-left px-3 py-1.5 rounded-lg text-xs font-medium transition-all flex items-center ${
selectedDepartment ===
(category.path ||
category.url ||
category.name)
? "bg-blue-50 text-blue-600"
: "text-slate-400 hover:bg-slate-50"
}`}
>
<svg
className="w-3 h-3 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
{category.name}
</button>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
))}
</nav>
)}
</div>
{/* Marcas */}
<div className="mb-8">
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4">
Marcas
</h3>
{loadingBrands ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-500"></div>
<span className="ml-2 text-xs text-slate-500">Carregando...</span>
</div>
) : brands.length === 0 ? (
<p className="text-xs text-slate-400 italic">
Nenhuma marca disponível
</p>
) : (
<div className="space-y-1.5 max-h-[300px] overflow-y-auto custom-scrollbar">
{brands.map((brand) => (
<label
key={brand}
className="flex items-center px-3 py-2 rounded-lg hover:bg-slate-50 cursor-pointer group"
>
<input
type="checkbox"
checked={selectedBrands.includes(brand)}
onChange={(e) => {
if (e.target.checked) {
onBrandsChange([...selectedBrands, brand]);
} else {
onBrandsChange(selectedBrands.filter((b) => b !== brand));
}
}}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
{brand}
</span>
</label>
))}
</div>
)}
</div>
{/* Filtros */}
<div className="mb-8">
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4">
Filtros
</h3>
<div className="space-y-4">
{/* Somente produtos com estoque */}
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={filters.onlyInStock}
onChange={(e) =>
onFiltersChange({ ...filters, onlyInStock: e.target.checked })
}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
Somente produtos com estoque
</span>
</label>
{/* Filial de estoque */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1.5">
Filial de estoque
</label>
{loadingStores ? (
<div className="flex items-center justify-center py-2">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-orange-500"></div>
<span className="ml-2 text-xs text-slate-400">
Carregando...
</span>
</div>
) : (
<CustomAutocomplete
options={[
{ value: "", label: "Selecione a filial" },
...stores.map((store) => ({
value: store.id,
label: store.shortName || store.name || store.id,
})),
]}
value={filters.stockBranch}
onValueChange={(value) =>
onFiltersChange({ ...filters, stockBranch: value })
}
placeholder="Selecione a filial"
className="h-9 text-xs"
/>
)}
</div>
{/* Produtos em promoção */}
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={filters.onPromotion}
onChange={(e) =>
onFiltersChange({ ...filters, onPromotion: e.target.checked })
}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
Produtos em promoção
</span>
</label>
{/* Faixa de desconto */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-2">
Faixa de desconto
</label>
<div className="px-1">
<div className="relative h-2 bg-slate-200 rounded-full mb-3">
{/* Barra de progresso visual */}
<div
className="absolute h-2 bg-orange-500 rounded-full"
style={{
left: `${filters.discountRange[0]}%`,
width: `${
filters.discountRange[1] - filters.discountRange[0]
}%`,
}}
></div>
{/* Input mínimo */}
<input
type="range"
min="0"
max="100"
step="5"
value={filters.discountRange[0]}
onChange={(e) => {
const min = parseInt(e.target.value);
const max = Math.max(min, filters.discountRange[1]);
onFiltersChange({ ...filters, discountRange: [min, max] });
}}
className="absolute w-full h-2 bg-transparent appearance-none cursor-pointer z-10 opacity-0"
style={{
background: "transparent",
}}
/>
{/* Input máximo */}
<input
type="range"
min="0"
max="100"
step="5"
value={filters.discountRange[1]}
onChange={(e) => {
const max = parseInt(e.target.value);
const min = Math.min(max, filters.discountRange[0]);
onFiltersChange({ ...filters, discountRange: [min, max] });
}}
className="absolute w-full h-2 bg-transparent appearance-none cursor-pointer z-10 opacity-0"
/>
{/* Handles visuais */}
<div
className="absolute w-4 h-4 bg-orange-500 rounded-full border-2 border-white shadow-md cursor-grab active:cursor-grabbing z-20 top-1/2 -translate-y-1/2 hover:scale-110 transition-transform"
style={{
left: `calc(${filters.discountRange[0]}% - 8px)`,
}}
></div>
<div
className="absolute w-4 h-4 bg-orange-500 rounded-full border-2 border-white shadow-md cursor-grab active:cursor-grabbing z-20 top-1/2 -translate-y-1/2 hover:scale-110 transition-transform"
style={{
left: `calc(${filters.discountRange[1]}% - 8px)`,
}}
></div>
</div>
<div className="flex justify-between mb-2 text-[10px] text-slate-400">
<span>0</span>
<span>20</span>
<span>40</span>
<span>60</span>
<span>80</span>
<span>100</span>
</div>
<div className="text-center text-xs font-medium text-slate-700">
{filters.discountRange[0]}% - {filters.discountRange[1]}%
</div>
</div>
</div>
{/* Produtos que baixaram de preço */}
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={filters.priceDropped}
onChange={(e) =>
onFiltersChange({ ...filters, priceDropped: e.target.checked })
}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
Produtos que baixaram de preço
</span>
</label>
{/* Oportunidades */}
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={filters.opportunities}
onChange={(e) =>
onFiltersChange({ ...filters, opportunities: e.target.checked })
}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
Oportunidades
</span>
</label>
{/* Ofertas imperdiveis */}
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={filters.unmissableOffers}
onChange={(e) =>
onFiltersChange({
...filters,
unmissableOffers: e.target.checked,
})
}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
Ofertas imperdiveis
</span>
</label>
</div>
</div>
{/* Botão Aplicar Filtros */}
<button
onClick={onApplyFilters}
className="w-full py-3 lg:py-3 bg-orange-500 text-white rounded-lg font-bold text-xs uppercase tracking-wider hover:bg-orange-600 transition-all shadow-lg shadow-orange-500/20 touch-manipulation"
>
Aplicar Filtros
</button>
{/* Informação da Loja */}
<div className="mt-auto pt-6 border-t border-slate-200">
<div className="bg-slate-50 rounded-xl p-4 border border-slate-100">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">
Sua Loja
</span>
<span className="text-sm font-extrabold text-[#002147]">
Jurunense BR-316
</span>
<div className="mt-3 flex items-center text-xs font-bold text-emerald-600">
<span className="w-2 h-2 bg-emerald-500 rounded-full mr-2"></span>
Loja aberta agora
</div>
</div>
</div>
</aside>
);
};
export default FilterSidebar;