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 && ( + + ); + + // 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)