diff --git a/src/components/common/EntityBadge/EntityBadge.tsx b/src/components/common/EntityBadge/EntityBadge.tsx
new file mode 100644
index 000000000..24555d0f4
--- /dev/null
+++ b/src/components/common/EntityBadge/EntityBadge.tsx
@@ -0,0 +1,184 @@
+import React from "react";
+import { cn } from "@/lib/utils";
+import { X } from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+/**
+ * Generic entity badge — base for MaterialBadge, RamoAtividadeBadge, etc.
+ *
+ * Pattern extracted in F1 Onda D (auditoria de duplicação): two near-identical
+ * 177-line components were merged into this generic component + thin wrappers.
+ *
+ * Behavior:
+ * - Optional color dot from `hexCode`.
+ * - Optional contextual label rendered before name with `groupSeparator`.
+ * - Optional `icon` rendered before color dot (e.g. for "Ramo de Atividade").
+ * - Optional remove button (`onRemove`).
+ * - Optional tooltip (auto-rendered when `groupLabel` or `productCount` set).
+ */
+export interface EntityBadgeProps {
+ /** Name shown as badge label (always rendered) */
+ name: string;
+ /** Optional contextual label (e.g. "Plásticos" for a material; "Hotel" for a ramo) */
+ groupLabel?: string;
+ /** Hex color for the leading dot. If null/undefined, dot is omitted. */
+ hexCode?: string | null;
+ /** Optional emoji or single-character icon shown before the dot */
+ icon?: string | null;
+ /** Visual size */
+ size?: "sm" | "md" | "lg";
+ /** Visual variant */
+ variant?: "default" | "outline" | "solid" | "ghost";
+ /** When true and `groupLabel` is set, renders `${groupLabel}${groupSeparator}${name}` */
+ showGroup?: boolean;
+ /** Separator between group label and name. Default: ": " */
+ groupSeparator?: string;
+ /** Click handler (full badge) */
+ onClick?: () => void;
+ /** Remove handler — when set, renders an `×` button */
+ onRemove?: () => void;
+ /** Extra classes merged via cn() */
+ className?: string;
+ /** Disable tooltip render even when context exists */
+ showTooltip?: boolean;
+ /** Number of products linked — appended to tooltip when present */
+ productCount?: number;
+ /** Per-size cap for the label (Tailwind max-width classes) */
+ truncateMaxWidth?: { sm: string; md: string; lg: string };
+ /** Tooltip content override — when not provided, builds from groupLabel + productCount */
+ tooltipContent?: React.ReactNode;
+}
+
+const DEFAULT_MAX_WIDTH = {
+ sm: "max-w-[100px]",
+ md: "max-w-[120px]",
+ lg: "max-w-[150px]",
+};
+
+export function EntityBadge({
+ name,
+ groupLabel,
+ hexCode,
+ icon,
+ size = "md",
+ variant = "default",
+ showGroup = false,
+ groupSeparator = ": ",
+ onClick,
+ onRemove,
+ className,
+ showTooltip = true,
+ productCount,
+ truncateMaxWidth = DEFAULT_MAX_WIDTH,
+ tooltipContent,
+}: EntityBadgeProps) {
+ const sizeClasses = {
+ sm: "text-[11px] px-2 py-0.5 gap-1",
+ md: "text-xs px-2.5 py-1 gap-1.5",
+ lg: "text-sm px-3 py-1.5 gap-2",
+ };
+
+ const dotSizeClasses = {
+ sm: "w-1.5 h-1.5",
+ md: "w-2 h-2",
+ lg: "w-2.5 h-2.5",
+ };
+
+ const variantClasses = {
+ default: "bg-muted text-muted-foreground hover:bg-muted/80",
+ outline: "border border-border bg-transparent hover:bg-muted/50",
+ solid: "bg-foreground text-background hover:bg-foreground/90",
+ ghost: "bg-transparent text-muted-foreground hover:bg-muted/50",
+ };
+
+ const displayText =
+ showGroup && groupLabel ? `${groupLabel}${groupSeparator}${name}` : name;
+
+ const badgeContent = (
+
+ {icon && {icon}}
+ {hexCode && (
+
+ )}
+
+ {displayText}
+
+ {onRemove && (
+
+ )}
+
+ );
+
+ // Default tooltip text (when not overridden)
+ const defaultTooltip = (
+ <>
+ {groupLabel &&
{groupLabel}
}
+ {name}
+ {productCount !== undefined && (
+
+ {productCount} produto{productCount !== 1 ? "s" : ""}
+
+ )}
+ >
+ );
+
+ // With tooltip
+ if (showTooltip && (groupLabel || productCount !== undefined)) {
+ return (
+
+
+ {badgeContent}
+ {tooltipContent ?? defaultTooltip}
+
+
+ );
+ }
+
+ return badgeContent;
+}
diff --git a/src/components/common/EntityBadge/index.ts b/src/components/common/EntityBadge/index.ts
new file mode 100644
index 000000000..b4c492c95
--- /dev/null
+++ b/src/components/common/EntityBadge/index.ts
@@ -0,0 +1,2 @@
+export { EntityBadge } from "./EntityBadge";
+export type { EntityBadgeProps } from "./EntityBadge";
diff --git a/src/components/materials/MaterialBadge.tsx b/src/components/materials/MaterialBadge.tsx
index 6520e61e5..4bc2eee2b 100644
--- a/src/components/materials/MaterialBadge.tsx
+++ b/src/components/materials/MaterialBadge.tsx
@@ -1,13 +1,12 @@
-import React from "react";
-import { cn } from "@/lib/utils";
-import { X } from "lucide-react";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-
+import { EntityBadge } from "@/components/common/EntityBadge";
+
+/**
+ * Badge for displaying a Material (Plástico, Metal, Tecido, etc).
+ *
+ * Thin wrapper around `EntityBadge` — see common/EntityBadge for the actual
+ * implementation. API kept identical for backwards compatibility with all
+ * existing callers.
+ */
interface MaterialBadgeProps {
name: string;
groupName?: string;
@@ -35,143 +34,20 @@ export function MaterialBadge({
showTooltip = true,
productCount,
}: MaterialBadgeProps) {
- const sizeClasses = {
- sm: "text-[11px] px-2 py-0.5 gap-1",
- md: "text-xs px-2.5 py-1 gap-1.5",
- lg: "text-sm px-3 py-1.5 gap-2",
- };
-
- const colorDotSizes = {
- sm: "w-2 h-2",
- md: "w-2.5 h-2.5",
- lg: "w-3 h-3",
- };
-
- const variantClasses = {
- default: "bg-muted/60 text-muted-foreground hover:bg-muted",
- outline: "border border-border bg-background text-foreground hover:bg-muted/50",
- solid: "bg-primary/15 text-primary border border-primary/20 hover:bg-primary/25",
- ghost: "bg-transparent text-muted-foreground hover:bg-muted/50",
- };
-
- const displayText = showGroup && groupName ? `${groupName}: ${name}` : name;
-
- const badgeContent = (
-
-
- {/* Texto */}
-
- {displayText}
-
-
- {/* Contador de produtos */}
- {productCount !== undefined && productCount > 0 && (
-
- {productCount}
-
- )}
-
- {/* Botão remover */}
- {onRemove && (
-
- )}
-
- );
-
- // Com tooltip
- if (showTooltip && (groupName || productCount)) {
- return (
-
-
-
- {badgeContent}
-
-
-
- {groupName && (
- {groupName}
- )}
- {name}
- {productCount !== undefined && (
-
- {productCount} produto{productCount !== 1 ? 's' : ''}
-
- )}
-
-
-
-
- );
- }
-
- return badgeContent;
-}
-
-// Variante compacta para listas
-export function CompactMaterialBadge({
- name,
- hexCode,
- isSelected,
- onClick,
-}: {
- name: string;
- hexCode?: string | null;
- isSelected?: boolean;
- onClick?: () => void;
-}) {
return (
-
+ onRemove={onRemove}
+ className={className}
+ showTooltip={showTooltip}
+ productCount={productCount}
+ />
);
}
diff --git a/src/components/ramo-atividade/RamoAtividadeBadge.tsx b/src/components/ramo-atividade/RamoAtividadeBadge.tsx
index 27e197134..cd3196899 100644
--- a/src/components/ramo-atividade/RamoAtividadeBadge.tsx
+++ b/src/components/ramo-atividade/RamoAtividadeBadge.tsx
@@ -1,13 +1,12 @@
-import React from "react";
-import { cn } from "@/lib/utils";
-import { X, Building2 } from "lucide-react";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-
+import { EntityBadge } from "@/components/common/EntityBadge";
+
+/**
+ * Badge for displaying a Ramo de Atividade (Hotel, Restaurante, Imobiliária, etc).
+ *
+ * Thin wrapper around `EntityBadge` — see common/EntityBadge for the actual
+ * implementation. API kept identical for backwards compatibility with all
+ * existing callers.
+ */
interface RamoAtividadeBadgeProps {
name: string;
ramoName?: string;
@@ -23,6 +22,12 @@ interface RamoAtividadeBadgeProps {
productCount?: number;
}
+const RAMO_MAX_WIDTH = {
+ sm: "max-w-[100px]",
+ md: "max-w-[140px]",
+ lg: "max-w-[180px]",
+};
+
export function RamoAtividadeBadge({
name,
ramoName,
@@ -37,143 +42,22 @@ export function RamoAtividadeBadge({
showTooltip = true,
productCount,
}: RamoAtividadeBadgeProps) {
- const sizeClasses = {
- sm: "text-[11px] px-2 py-0.5 gap-1",
- md: "text-xs px-2.5 py-1 gap-1.5",
- lg: "text-sm px-3 py-1.5 gap-2",
- };
-
- const colorDotSizes = {
- sm: "w-2 h-2",
- md: "w-2.5 h-2.5",
- lg: "w-3 h-3",
- };
-
- const variantClasses = {
- default: "bg-muted/60 text-muted-foreground hover:bg-muted",
- outline: "border border-border bg-background text-foreground hover:bg-muted/50",
- solid: "bg-primary/15 text-primary border border-primary/20 hover:bg-primary/25",
- ghost: "bg-transparent text-muted-foreground hover:bg-muted/50",
- };
-
- const displayText = showRamo && ramoName ? `${ramoName} → ${name}` : name;
-
- const badgeContent = (
-
-
- {/* Texto */}
-
- {displayText}
-
-
- {/* Contador de produtos */}
- {productCount !== undefined && productCount > 0 && (
-
- {productCount}
-
- )}
-
- {/* Botão remover */}
- {onRemove && (
-
- )}
-
- );
-
- // Com tooltip
- if (showTooltip && (ramoName || productCount)) {
- return (
-
-
-
- {badgeContent}
-
-
-
- {ramoName && (
- {ramoName}
- )}
- {name}
- {productCount !== undefined && (
-
- {productCount} produto{productCount !== 1 ? 's' : ''}
-
- )}
-
-
-
-
- );
- }
-
- return badgeContent;
-}
-
-// Variante compacta para listas
-export function CompactRamoAtividadeBadge({
- name,
- hexCode,
- isSelected,
- onClick,
-}: {
- name: string;
- hexCode?: string | null;
- isSelected?: boolean;
- onClick?: () => void;
-}) {
return (
-
+ onRemove={onRemove}
+ className={className}
+ showTooltip={showTooltip}
+ productCount={productCount}
+ truncateMaxWidth={RAMO_MAX_WIDTH}
+ />
);
}
diff --git a/src/data/pantone-coated.ts b/src/data/pantone-coated.ts
index 5bbe95fc5..13abe0ba3 100644
--- a/src/data/pantone-coated.ts
+++ b/src/data/pantone-coated.ts
@@ -681,11 +681,6 @@ export const PANTONE_CATALOG: PantoneColor[] = [
h("7547 C", "#131E29"),
];
-/** Get all Pantone codes for search/autocomplete */
-export function getAllPantoneCodes(): string[] {
- return PANTONE_CATALOG.map(p => p.code);
-}
-
/** Search Pantone catalog by code (partial match) */
export function searchPantone(query: string): PantoneColor[] {
const q = query.toLowerCase().trim();
diff --git a/src/hooks/useCommercialIntelligence.ts b/src/hooks/useCommercialIntelligence.ts
index 9c89f79a2..c9ee2614a 100644
--- a/src/hooks/useCommercialIntelligence.ts
+++ b/src/hooks/useCommercialIntelligence.ts
@@ -33,7 +33,6 @@ export interface OpportunityProduct {
quoteCount: number; orderCount: number; conversionRate: number; opportunityScore: number; reason: string;
}
-export interface RevenuePoint { date: string; revenue: number; orders: number; quotes: number; }
export interface CategoryRankingItem {
categoryId: string; categoryName: string; internalRevenue: number; internalQty: number;
@@ -252,53 +251,6 @@ export function useOpportunities(days = 30, categoryId?: string | null, supplier
});
}
-// ============================================
-// Revenue Trend
-// ============================================
-export function useRevenueTrend(days = 30, categoryId?: string | null, supplierId?: string | null, productId?: string | null) {
- const { user } = useAuth(); const orgId = useCurrentOrgId(); const scope = useSalesScope();
- const { data: productIds } = useFilteredProductIds(categoryId, supplierId, productId);
- const hasFilter = !!(categoryId || supplierId || productId);
-
- return useQuery({
- queryKey: ['commercial-revenue-trend', user?.id, orgId, scope, days, categoryId, supplierId],
- queryFn: async (): Promise => {
- const since = new Date(); since.setDate(since.getDate() - days); const sinceStr = since.toISOString();
- let orderData: Array<{ quantity?: number | null; unit_price?: number | null; created_at: string }> = [];
- let quoteData: Array<{ created_at: string }> = [];
-
- if (hasFilter && productIds) {
- const pids = Array.from(productIds).slice(0, 200);
- if (!pids.length) { const m = new Map(); for (let i = 0; i < days; i++) { const d = new Date(since); d.setDate(d.getDate() + i); const k = d.toISOString().split('T')[0]; m.set(k, { date: k, revenue: 0, orders: 0, quotes: 0 }); } return Array.from(m.values()); }
- const [{ data: oi }, { data: qi }] = await Promise.all([
- supabase.from('order_items').select('quantity, unit_price, created_at').gte('created_at', sinceStr).in('product_id', pids),
- supabase.from('quote_items').select('created_at').gte('created_at', sinceStr).in('product_id', pids),
- ]);
- orderData = oi || []; quoteData = qi || [];
- } else {
- // rls-allow: respeita can_view_all_sales server-side
- let oq = supabase.from('orders').select('total, created_at').gte('created_at', sinceStr).order('created_at');
- // rls-allow: respeita can_view_all_sales server-side
- let qq = supabase.from('quotes').select('total, created_at').gte('created_at', sinceStr).order('created_at');
- if (orgId) { oq = oq.eq('organization_id', orgId); qq = qq.eq('organization_id', orgId); }
- oq = applySellerScope(oq, { scope, userId: user?.id });
- qq = applySellerScope(qq, { scope, userId: user?.id });
- const [{ data: orders }, { data: quotes }] = await Promise.all([oq, qq]);
- orderData = (orders || []).map(o => ({ quantity: 1, unit_price: o.total, created_at: o.created_at }));
- quoteData = quotes || [];
- }
-
- const dateMap = new Map();
- for (let i = 0; i < days; i++) { const d = new Date(since); d.setDate(d.getDate() + i); const k = d.toISOString().split('T')[0]; dateMap.set(k, { date: k, revenue: 0, orders: 0, quotes: 0 }); }
- orderData.forEach(o => { const k = new Date(o.created_at).toISOString().split('T')[0]; const e = dateMap.get(k); if (e) { e.revenue += (o.quantity ?? 1) * (o.unit_price ?? 0); e.orders += 1; } });
- quoteData.forEach(q => { const k = new Date(q.created_at).toISOString().split('T')[0]; const e = dateMap.get(k); if (e) e.quotes += 1; });
- return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date));
- },
- staleTime: 1000 * 60 * 5,
- enabled: !!user && (!hasFilter || productIds !== undefined),
- });
-}
-
// ============================================
// Top Clients
// ============================================
diff --git a/src/hooks/useCrmCompanies.ts b/src/hooks/useCrmCompanies.ts
index da208e94d..d2b055578 100644
--- a/src/hooks/useCrmCompanies.ts
+++ b/src/hooks/useCrmCompanies.ts
@@ -5,7 +5,7 @@
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
import { selectCrm, selectCrmById, searchCrm, invokeCrmDb } from "@/lib/crm-db";
-import { type CrmCompany, type CrmCompanyFilters, type CrmCustomer, toLegacyClient, getCompanyDisplayName } from "@/types/crm";
+import { type CrmCompany, type CrmCompanyFilters, type CrmCustomer, getCompanyDisplayName } from "@/types/crm";
import { toast } from "sonner";
import { DEMO_CLIENT_ID, DEMO_COMPANY, isDemoClient } from "@/lib/bi/demoClient";
import { logger } from "@/lib/logger";
@@ -68,29 +68,6 @@ export function useCrmCompany(id: string | null | undefined) {
});
}
-/**
- * Hook de compatibilidade: retorna dados no formato legado (BitrixClient)
- */
-export function useCrmCompaniesLegacy(filters?: CrmCompanyFilters) {
- const query = useCrmCompanies(filters);
-
- return {
- ...query,
- data: query.data?.map(c => toLegacyClient(c)) || [],
- };
-}
-
-/**
- * Hook de compatibilidade: empresa individual no formato legado
- */
-export function useCrmCompanyLegacy(id: string | null | undefined) {
- const query = useCrmCompany(id);
-
- return {
- ...query,
- data: query.data ? toLegacyClient(query.data) : null,
- };
-}
/**
* Hook para busca infinita de empresas (dropdown/combobox)