diff --git a/src/components/cart/BundleSuggestionCard.tsx b/src/components/cart/BundleSuggestionCard.tsx index 7e5c7395e..4d9552f29 100644 --- a/src/components/cart/BundleSuggestionCard.tsx +++ b/src/components/cart/BundleSuggestionCard.tsx @@ -2,13 +2,13 @@ * BundleSuggestionCard — sugere produtos comumente orçados juntos com o produto-âncora. * Consulta histórico de quote_items via RPC `get_bundle_suggestions(_product_id)`. */ -import { useQuery } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Sparkles, Plus } from "lucide-react"; -import { motion } from "framer-motion"; +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Sparkles, Plus } from 'lucide-react'; +import { motion } from 'framer-motion'; interface BundleSuggestion { product_id: string; @@ -26,15 +26,15 @@ interface BundleSuggestionCardProps { export function BundleSuggestionCard({ productId, onAdd, className }: BundleSuggestionCardProps) { const { data, isLoading } = useQuery({ - queryKey: ["bundle-suggestions", productId], + queryKey: ['bundle-suggestions', productId], enabled: !!productId, queryFn: async (): Promise => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data, error } = await (supabase.rpc as any)("get_bundle_suggestions", { + const { data, error } = await (supabase.rpc as any)('get_bundle_suggestions', { _product_id: productId, }); if (error) { - console.warn("get_bundle_suggestions error:", error); + console.warn('get_bundle_suggestions error:', error); return []; } return (data ?? []) as BundleSuggestion[]; @@ -43,12 +43,15 @@ export function BundleSuggestionCard({ productId, onAdd, className }: BundleSugg }); if (!isLoading && !data?.length) return null; + const suggestions = data ?? []; return ( - + - -
+ +
Frequentemente orçado em conjunto @@ -57,12 +60,12 @@ export function BundleSuggestionCard({ productId, onAdd, className }: BundleSugg Vendedores que orçaram este produto também incluíram: - + {isLoading ? ( -
+
{[...Array(3)].map((_, i) => ( -
- +
+
@@ -72,26 +75,26 @@ export function BundleSuggestionCard({ productId, onAdd, className }: BundleSugg ))}
) : ( - data!.map(item => ( + suggestions.map((item) => ( {item.product_image_url ? ( {item.product_name} ) : ( -
+
)} -
-

{item.product_name}

+
+

{item.product_name}

{item.frequency_percent}% das vezes · {item.cooccurrence_count}x

@@ -100,7 +103,7 @@ export function BundleSuggestionCard({ productId, onAdd, className }: BundleSugg @@ -189,39 +206,37 @@ export function MyClientsWidget() { /> -
+
{filtered.length === 0 ? ( -

+

Nenhum cliente encontrado com os filtros atuais.

) : ( filtered.map((c) => (
-
+
-
-

- {c.company || c.name} -

-
+
+

{c.company || c.name}

+
{c.company && c.name && c.name !== c.company && ( - {c.name} + {c.name} )} - + {c.quotes} - + {c.orders}
{c.total > 0 && ( -

- {c.total.toLocaleString("pt-BR", { style: "currency", currency: "BRL" })} +

+ {c.total.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}

)}
@@ -237,8 +252,8 @@ export function MyClientsWidget() {
)}
-

- {filtered.length} cliente(s) · {clients.length} carregado(s){hasNextPage ? "+" : ""}. +

+ {filtered.length} cliente(s) · {clients.length} carregado(s){hasNextPage ? '+' : ''}.

diff --git a/src/components/dashboard/MyDiscountRequestsWidget.tsx b/src/components/dashboard/MyDiscountRequestsWidget.tsx index dc09a7d24..ee0145b5f 100644 --- a/src/components/dashboard/MyDiscountRequestsWidget.tsx +++ b/src/components/dashboard/MyDiscountRequestsWidget.tsx @@ -2,72 +2,68 @@ * MyDiscountRequestsWidget — solicitações de desconto do usuário com busca, * filtros e infinite scroll. Filtro explícito por seller_id = auth.uid(). */ -import { useMemo, useState, useCallback } from "react"; -import { useInfiniteQuery } from "@tanstack/react-query"; -import { useNavigate } from "react-router-dom"; -import { Percent, ArrowRight, Clock, Loader2 } from "lucide-react"; -import { supabase } from "@/integrations/supabase/client"; -import { useAuth } from "@/contexts/AuthContext"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { formatDistanceToNow } from "date-fns"; -import { ptBR } from "date-fns/locale"; +import { useMemo, useState, useCallback } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { Percent, ArrowRight, Clock, Loader2 } from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; +import { useAuth } from '@/contexts/AuthContext'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { formatDistanceToNow } from 'date-fns'; +import { ptBR } from 'date-fns/locale'; import { WidgetFiltersBar, EMPTY_FILTERS, matchesSearch, withinDateRange, type WidgetFiltersValue, -} from "./widget-filters/WidgetFiltersBar"; -import { useInfiniteScroll } from "./widget-filters/useInfiniteScroll"; +} from './widget-filters/WidgetFiltersBar'; +import { useInfiniteScroll } from './widget-filters/useInfiniteScroll'; const PAGE_SIZE = 20; -const STATUS_VARIANT: Record = { - pending: "secondary", - approved: "default", - rejected: "destructive", +const STATUS_VARIANT: Record = { + pending: 'secondary', + approved: 'default', + rejected: 'destructive', }; const STATUS_LABELS: Record = { - pending: "Pendente", - approved: "Aprovado", - rejected: "Rejeitado", + pending: 'Pendente', + approved: 'Aprovado', + rejected: 'Rejeitado', }; const STATUS_OPTIONS = Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })); export function MyDiscountRequestsWidget() { const { user } = useAuth(); + const userId = user?.id; const navigate = useNavigate(); const [filters, setFilters] = useState(EMPTY_FILTERS); - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - } = useInfiniteQuery({ - queryKey: ["my-discount-requests-widget", user?.id], - enabled: !!user?.id, + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ + queryKey: ['my-discount-requests-widget', userId], + enabled: !!userId, staleTime: 30_000, initialPageParam: null as string | null, queryFn: async ({ pageParam }) => { + if (!userId) return []; let q = supabase - .from("discount_approval_requests") - .select("id, status, requested_discount_percent, quote_id, created_at") - .eq("seller_id", user!.id) - .order("created_at", { ascending: false }) + .from('discount_approval_requests') + .select('id, status, requested_discount_percent, quote_id, created_at') + .eq('seller_id', userId) + .order('created_at', { ascending: false }) .limit(PAGE_SIZE); - if (pageParam) q = q.lt("created_at", pageParam); + if (pageParam) q = q.lt('created_at', pageParam); const { data, error } = await q; if (error) throw error; return data ?? []; }, getNextPageParam: (last) => - last.length < PAGE_SIZE ? undefined : last[last.length - 1]?.created_at ?? undefined, + last.length < PAGE_SIZE ? undefined : (last[last.length - 1]?.created_at ?? undefined), }); const all = useMemo(() => data?.pages.flat() ?? [], [data]); @@ -75,13 +71,10 @@ export function MyDiscountRequestsWidget() { const filtered = useMemo(() => { return all.filter( (r) => - (filters.status === "all" || r.status === filters.status) && + (filters.status === 'all' || r.status === filters.status) && withinDateRange(r.created_at, filters.dateRange) && matchesSearch( - [ - r.quote_id, - String(Number(r.requested_discount_percent ?? 0).toFixed(1)), - ], + [r.quote_id, String(Number(r.requested_discount_percent ?? 0).toFixed(1))], filters.search, ), ); @@ -99,13 +92,18 @@ export function MyDiscountRequestsWidget() { return ( - +
- + Minhas Solicitações de Desconto -
@@ -117,31 +115,37 @@ export function MyDiscountRequestsWidget() { />
-
+
{filtered.length === 0 ? ( -

+

Nenhuma solicitação encontrada com os filtros atuais.

) : ( filtered.map((r) => (
-
+
-
-

+

+

Desconto solicitado: {Number(r.requested_discount_percent ?? 0).toFixed(1)}%

- + {STATUS_LABELS[r.status] ?? r.status} - {formatDistanceToNow(new Date(r.created_at), { addSuffix: true, locale: ptBR })} + {formatDistanceToNow(new Date(r.created_at), { + addSuffix: true, + locale: ptBR, + })}
@@ -158,8 +162,8 @@ export function MyDiscountRequestsWidget() {
)}
-

- Exibindo {filtered.length} de {all.length} carregado(s){hasNextPage ? "+" : ""}. +

+ Exibindo {filtered.length} de {all.length} carregado(s){hasNextPage ? '+' : ''}.

diff --git a/src/components/dashboard/MyRecentQuotesWidget.tsx b/src/components/dashboard/MyRecentQuotesWidget.tsx index d3563327e..45688d3b7 100644 --- a/src/components/dashboard/MyRecentQuotesWidget.tsx +++ b/src/components/dashboard/MyRecentQuotesWidget.tsx @@ -4,70 +4,66 @@ * Filtro explícito por seller_id = auth.uid() (defesa em profundidade * sobre a RLS já existente). */ -import { useMemo, useState, useCallback } from "react"; -import { useInfiniteQuery } from "@tanstack/react-query"; -import { useNavigate } from "react-router-dom"; -import { FileText, ArrowRight, Clock, Loader2 } from "lucide-react"; -import { supabase } from "@/integrations/supabase/client"; -import { useAuth } from "@/contexts/AuthContext"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { formatDistanceToNow } from "date-fns"; -import { ptBR } from "date-fns/locale"; +import { useMemo, useState, useCallback } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { FileText, ArrowRight, Clock, Loader2 } from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; +import { useAuth } from '@/contexts/AuthContext'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { formatDistanceToNow } from 'date-fns'; +import { ptBR } from 'date-fns/locale'; import { WidgetFiltersBar, EMPTY_FILTERS, matchesSearch, withinDateRange, type WidgetFiltersValue, -} from "./widget-filters/WidgetFiltersBar"; -import { useInfiniteScroll } from "./widget-filters/useInfiniteScroll"; +} from './widget-filters/WidgetFiltersBar'; +import { useInfiniteScroll } from './widget-filters/useInfiniteScroll'; const PAGE_SIZE = 20; const STATUS_LABELS: Record = { - draft: "Rascunho", - sent: "Enviado", - approved: "Aprovado", - rejected: "Recusado", - expired: "Expirado", - pending_approval: "Aguardando aprovação", - converted: "Convertido", + draft: 'Rascunho', + sent: 'Enviado', + approved: 'Aprovado', + rejected: 'Recusado', + expired: 'Expirado', + pending_approval: 'Aguardando aprovação', + converted: 'Convertido', }; const STATUS_OPTIONS = Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })); export function MyRecentQuotesWidget() { const { user } = useAuth(); + const userId = user?.id; const navigate = useNavigate(); const [filters, setFilters] = useState(EMPTY_FILTERS); - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - } = useInfiniteQuery({ - queryKey: ["my-recent-quotes-widget", user?.id], - enabled: !!user?.id, + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ + queryKey: ['my-recent-quotes-widget', userId], + enabled: !!userId, staleTime: 30_000, initialPageParam: null as string | null, queryFn: async ({ pageParam }) => { + if (!userId) return []; let q = supabase - .from("quotes") - .select("id, quote_number, status, total, client_name, client_company, updated_at") - .eq("seller_id", user!.id) - .order("updated_at", { ascending: false }) + .from('quotes') + .select('id, quote_number, status, total, client_name, client_company, updated_at') + .eq('seller_id', userId) + .order('updated_at', { ascending: false }) .limit(PAGE_SIZE); - if (pageParam) q = q.lt("updated_at", pageParam); + if (pageParam) q = q.lt('updated_at', pageParam); const { data, error } = await q; if (error) throw error; return data ?? []; }, getNextPageParam: (last) => - last.length < PAGE_SIZE ? undefined : last[last.length - 1]?.updated_at ?? undefined, + last.length < PAGE_SIZE ? undefined : (last[last.length - 1]?.updated_at ?? undefined), }); const all = useMemo(() => data?.pages.flat() ?? [], [data]); @@ -75,7 +71,7 @@ export function MyRecentQuotesWidget() { const filtered = useMemo(() => { return all.filter( (q) => - (filters.status === "all" || q.status === filters.status) && + (filters.status === 'all' || q.status === filters.status) && withinDateRange(q.updated_at, filters.dateRange) && matchesSearch([q.quote_number, q.client_name, q.client_company], filters.search), ); @@ -93,13 +89,18 @@ export function MyRecentQuotesWidget() { return ( - +
- + Minhas Propostas Recentes -
@@ -111,9 +112,9 @@ export function MyRecentQuotesWidget() { />
-
+
{filtered.length === 0 ? ( -

+

Nenhuma proposta encontrada com os filtros atuais.

) : ( @@ -121,28 +122,37 @@ export function MyRecentQuotesWidget() { @@ -158,8 +168,8 @@ export function MyRecentQuotesWidget() {
)}
-

- Exibindo {filtered.length} de {all.length} carregado(s){hasNextPage ? "+" : ""}. +

+ Exibindo {filtered.length} de {all.length} carregado(s){hasNextPage ? '+' : ''}.

diff --git a/src/components/intelligence/ProductRankingSearch.tsx b/src/components/intelligence/ProductRankingSearch.tsx index 9177a8a5c..cfb6fb382 100644 --- a/src/components/intelligence/ProductRankingSearch.tsx +++ b/src/components/intelligence/ProductRankingSearch.tsx @@ -1,20 +1,20 @@ -import { useState, useMemo, useCallback } from "react"; -import { Search, Trophy, DollarSign, ShoppingBag, BarChart3, Medal } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useTrendingProducts } from "@/hooks/intelligence"; -import { useCategories, useSuppliers } from "@/hooks/products"; -import { useNavigate } from "react-router-dom"; -import { RankingFilterToolbar, type RankingFilters } from "./RankingFilterToolbar"; -import { RankingResultRow } from "./RankingResultRow"; +import { useState, useMemo, useCallback } from 'react'; +import { Search, Trophy, DollarSign, ShoppingBag, BarChart3, Medal } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useTrendingProducts } from '@/hooks/intelligence'; +import { useCategories, useSuppliers } from '@/hooks/products'; +import { useNavigate } from 'react-router-dom'; +import { RankingFilterToolbar, type RankingFilters } from './RankingFilterToolbar'; +import { RankingResultRow } from './RankingResultRow'; export function ProductRankingSearch() { const navigate = useNavigate(); const { suppliers } = useSuppliers(); const { data: categories = [] } = useCategories(); - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); const [days, setDays] = useState(90); const [limit, setLimit] = useState(10); const [supplierId, setSupplierId] = useState(null); @@ -23,24 +23,36 @@ export function ProductRankingSearch() { const [categoryName, setCategoryName] = useState(null); const [debounceTimer, setDebounceTimer] = useState | null>(null); - const handleSearch = useCallback((value: string) => { - setSearchTerm(value); - setDebouncedSearch(""); // clear immediately for UX - if (debounceTimer) clearTimeout(debounceTimer); - const timer = setTimeout(() => setDebouncedSearch(value), 400); - setDebounceTimer(timer); - }, [debounceTimer]); + const handleSearch = useCallback( + (value: string) => { + setSearchTerm(value); + setDebouncedSearch(''); // clear immediately for UX + if (debounceTimer) clearTimeout(debounceTimer); + const timer = setTimeout(() => setDebouncedSearch(value), 400); + setDebounceTimer(timer); + }, + [debounceTimer], + ); const { data: products, isLoading } = useTrendingProducts( - days, categoryId, supplierId, null, limit, - debouncedSearch.trim() || null + days, + categoryId, + supplierId, + null, + limit, + debouncedSearch.trim() || null, ); - const hasResults = !!(products?.length); + const hasResults = !!products?.length; + const rankedProducts = products ?? []; const hasActiveFilters = !!(supplierId || categoryId || debouncedSearch); const formatCurrency = (v: number) => - new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(v); + new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + maximumFractionDigits: 0, + }).format(v); const summary = useMemo(() => { if (!products?.length) return null; @@ -54,14 +66,24 @@ export function ProductRankingSearch() { const topRevenue = products?.[0]?.totalRevenue || 0; const clearAllFilters = () => { - setSupplierId(null); setSupplierName(null); - setCategoryId(null); setCategoryName(null); - setSearchTerm(""); setDebouncedSearch(""); + setSupplierId(null); + setSupplierName(null); + setCategoryId(null); + setCategoryName(null); + setSearchTerm(''); + setDebouncedSearch(''); }; const filters: RankingFilters = { - searchTerm, debouncedSearch, days, limit, - supplierId, supplierName, categoryId, categoryName, hasActiveFilters, + searchTerm, + debouncedSearch, + days, + limit, + supplierId, + supplierName, + categoryId, + categoryName, + hasActiveFilters, }; return ( @@ -69,14 +91,15 @@ export function ProductRankingSearch() {
- -
+ +
🏆 Ranking de Produtos Mais Vendidos
- - Pesquise por tipo de produto, filtre por fornecedor/categoria e veja o ranking dos mais vendidos + + Pesquise por tipo de produto, filtre por fornecedor/categoria e veja o ranking dos + mais vendidos
@@ -90,32 +113,54 @@ export function ProductRankingSearch() { onSearchChange={handleSearch} onDaysChange={setDays} onLimitChange={setLimit} - onSupplierChange={(id, name) => { setSupplierId(id); setSupplierName(name); }} - onCategoryChange={(id, name) => { setCategoryId(id); setCategoryName(name); }} + onSupplierChange={(id, name) => { + setSupplierId(id); + setSupplierName(name); + }} + onCategoryChange={(id, name) => { + setCategoryId(id); + setCategoryName(name); + }} onClearAll={clearAllFilters} /> {/* Summary KPIs */} {summary && ( -
-
-
-

{formatCurrency(summary.totalRev)}

+
+
+
+ +
+

+ {formatCurrency(summary.totalRev)} +

Faturamento

-
-
-

{summary.totalQty.toLocaleString('pt-BR')}

+
+
+ +
+

+ {summary.totalQty.toLocaleString('pt-BR')} +

Unidades

-
-
-

{summary.totalOrders}

+
+
+ +
+

+ {summary.totalOrders} +

Pedidos

-
-
-

{formatCurrency(summary.avgTicket)}

+
+
+ +
+

+ {formatCurrency(summary.avgTicket)} +

Ticket Médio

@@ -123,19 +168,23 @@ export function ProductRankingSearch() { {isLoading && (
- {[...Array(5)].map((_, i) => )} + {[...Array(5)].map((_, i) => ( + + ))}
)} {!isLoading && !hasResults && (
- +

Nenhum produto encontrado

-

- {debouncedSearch ? `Nenhum resultado para "${debouncedSearch}"` : 'Sem dados de vendas para os filtros selecionados'} +

+ {debouncedSearch + ? `Nenhum resultado para "${debouncedSearch}"` + : 'Sem dados de vendas para os filtros selecionados'}

{debouncedSearch && ( -

+

Dica: tente termos mais genéricos como "garrafa", "caneta" ou "mochila"

)} @@ -143,8 +192,8 @@ export function ProductRankingSearch() { )} {!isLoading && hasResults && ( -
-
+
+
# Produto @@ -153,7 +202,7 @@ export function ProductRankingSearch() { P.Médio Conv.
- {products!.map((product, index) => ( + {rankedProducts.map((product, index) => ( - Top {products!.length} {debouncedSearch ? `para "${debouncedSearch}"` : 'produtos'} +

+ Top {rankedProducts.length} {debouncedSearch ? `para "${debouncedSearch}"` : 'produtos'} {categoryName ? ` · ${categoryName}` : ''} {supplierName ? ` · ${supplierName}` : ''} · {days} dias · ordenado por faturamento

diff --git a/src/components/intelligence/SupplierSales.tsx b/src/components/intelligence/SupplierSales.tsx index 971554027..b252cb96c 100644 --- a/src/components/intelligence/SupplierSales.tsx +++ b/src/components/intelligence/SupplierSales.tsx @@ -1,33 +1,53 @@ -import { Truck } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useSupplierSales } from "@/hooks/intelligence"; -import { cn } from "@/lib/utils"; -import { IntelligenceEmptyState } from "./IntelligenceEmptyState"; +import { Truck } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useSupplierSales } from '@/hooks/intelligence'; +import { cn } from '@/lib/utils'; +import { IntelligenceEmptyState } from './IntelligenceEmptyState'; -export function SupplierSales({ days = 30, categoryId, supplierId, productId, categoryName }: { days?: number; categoryId?: string | null; supplierId?: string | null; productId?: string | null; categoryName?: string | null }) { +export function SupplierSales({ + days = 30, + categoryId, + supplierId, + productId, + categoryName, +}: { + days?: number; + categoryId?: string | null; + supplierId?: string | null; + productId?: string | null; + categoryName?: string | null; +}) { const { data: suppliers, isLoading } = useSupplierSales(days, categoryId, supplierId, productId); - const hasData = !!(suppliers?.length); + const hasData = !!suppliers?.length; + const supplierRows = suppliers ?? []; const formatCurrency = (v: number) => - new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(v); - + new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + maximumFractionDigits: 0, + }).format(v); if (isLoading) { return ( - + + + - {[...Array(5)].map((_, i) => )} + {[...Array(5)].map((_, i) => ( + + ))} ); } - const maxRevenue = hasData ? Math.max(...suppliers!.map(s => s.revenue)) : 0; - const totalRevenue = hasData ? suppliers!.reduce((s, su) => s + su.revenue, 0) : 0; + const maxRevenue = hasData ? Math.max(...supplierRows.map((s) => s.revenue)) : 0; + const totalRevenue = hasData ? supplierRows.reduce((s, su) => s + su.revenue, 0) : 0; // Use opacity-based approach so bars follow the skin const getBarOpacity = (i: number) => Math.max(1 - i * 0.07, 0.4); @@ -37,14 +57,15 @@ export function SupplierSales({ days = 30, categoryId, supplierId, productId, ca
- -
+ +
📦 Vendas por Fornecedor
- - {categoryName ? `Fornecedores de "${categoryName}"` : 'Faturamento por fornecedor'} · {days} dias + + {categoryName ? `Fornecedores de "${categoryName}"` : 'Faturamento por fornecedor'} ·{' '} + {days} dias
@@ -53,33 +74,42 @@ export function SupplierSales({ days = 30, categoryId, supplierId, productId, ca {!hasData ? ( ) : ( - suppliers!.map((supplier, i) => { + supplierRows.map((supplier, i) => { const pct = maxRevenue > 0 ? (supplier.revenue / maxRevenue) * 100 : 0; - const share = totalRevenue > 0 ? ((supplier.revenue / totalRevenue) * 100).toFixed(1) : '0'; + const share = + totalRevenue > 0 ? ((supplier.revenue / totalRevenue) * 100).toFixed(1) : '0'; return (
-
- +
+ {i + 1} - {supplier.supplierName} + {supplier.supplierName}
-
- +
+ {share}% - {formatCurrency(supplier.revenue)} + + {formatCurrency(supplier.revenue)} +
-
+
- + {supplier.productCount} prod. · {supplier.orderCount} it.
diff --git a/src/components/intelligence/TrendingProducts.tsx b/src/components/intelligence/TrendingProducts.tsx index 325c20e3c..d1b8dd792 100644 --- a/src/components/intelligence/TrendingProducts.tsx +++ b/src/components/intelligence/TrendingProducts.tsx @@ -1,17 +1,39 @@ -import { TrendingUp, TrendingDown, Minus, Package } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useTrendingProducts } from "@/hooks/intelligence"; -import { useNavigate } from "react-router-dom"; -import { cn } from "@/lib/utils"; -import { IntelligenceEmptyState } from "./IntelligenceEmptyState"; +import { TrendingUp, TrendingDown, Minus, Package } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useTrendingProducts } from '@/hooks/intelligence'; +import { useNavigate } from 'react-router-dom'; +import { cn } from '@/lib/utils'; +import { IntelligenceEmptyState } from './IntelligenceEmptyState'; -export function TrendingProducts({ days = 30, categoryId, supplierId, productId, categoryName }: { days?: number; categoryId?: string | null; supplierId?: string | null; productId?: string | null; categoryName?: string | null }) { - const { data: products, isLoading } = useTrendingProducts(days, categoryId, supplierId, productId, 7); +export function TrendingProducts({ + days = 30, + categoryId, + supplierId, + productId, + categoryName, +}: { + days?: number; + categoryId?: string | null; + supplierId?: string | null; + productId?: string | null; + categoryName?: string | null; +}) { + const { data: products, isLoading } = useTrendingProducts( + days, + categoryId, + supplierId, + productId, + 7, + ); const navigate = useNavigate(); const formatCurrency = (v: number) => - new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(v); + new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + maximumFractionDigits: 0, + }).format(v); const trendIcon = { up: , @@ -19,15 +41,19 @@ export function TrendingProducts({ days = 30, categoryId, supplierId, productId, stable: , }; - const hasData = !!(products?.length); - + const hasData = !!products?.length; + const visibleProducts = products ?? []; if (isLoading) { return ( - + + + - {[...Array(5)].map((_, i) => )} + {[...Array(5)].map((_, i) => ( + + ))} ); @@ -38,13 +64,13 @@ export function TrendingProducts({ days = 30, categoryId, supplierId, productId,
- -
+ +
🔥 Produtos em Alta
- + {categoryName ? `Top em "${categoryName}"` : 'Top 7 por faturamento'} · {days} dias
@@ -54,42 +80,52 @@ export function TrendingProducts({ days = 30, categoryId, supplierId, productId, {!hasData ? ( ) : (
- {products!.map((product, index) => ( + {visibleProducts.map((product, index) => (
product.productId && navigate(`/produto/${product.productId}`)} > {/* Rank */} - 2 && "bg-muted text-muted-foreground", - )}> - {index < 3 ? ['🥇','🥈','🥉'][index] : index + 1} + 2 && 'bg-muted text-muted-foreground', + )} + > + {index < 3 ? ['🥇', '🥈', '🥉'][index] : index + 1} {/* Image */} -
+
{product.productImage ? ( - -Imagem do produto + Imagem do produto ) : ( -
+
)}
{/* Info */} -
-

{product.productName}

+
+

{product.productName}

{product.totalQuantity.toLocaleString('pt-BR')} un. · @@ -98,11 +134,13 @@ export function TrendingProducts({ days = 30, categoryId, supplierId, productId,
{/* Revenue + Trend */} -
+

{formatCurrency(product.totalRevenue)}

-
+
{trendIcon[product.trend]} - {product.conversionRate}% + + {product.conversionRate}% +
diff --git a/src/components/products/SalesHistoryChart.tsx b/src/components/products/SalesHistoryChart.tsx index b2152bb75..b9f7bef9d 100644 --- a/src/components/products/SalesHistoryChart.tsx +++ b/src/components/products/SalesHistoryChart.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState } from 'react'; import { ResponsiveContainer, Area, @@ -9,12 +9,12 @@ import { Bar, ComposedChart, Legend, -} from "recharts"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +} from 'recharts'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { ShoppingCart, FileText, @@ -25,14 +25,13 @@ import { Crown, RefreshCw, Package, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { formatCurrency } from "@/lib/format"; -import { useSalesHistory, type SellerRanking } from "@/hooks/intelligence"; -import { safeParseDateForChart } from "@/lib/stock-chart-utils"; -import { KpiCard } from "@/components/ui/kpi-card"; -import { useProductInsights } from "@/hooks/products"; - +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatCurrency } from '@/lib/format'; +import { useSalesHistory, type SellerRanking } from '@/hooks/intelligence'; +import { safeParseDateForChart } from '@/lib/stock-chart-utils'; +import { KpiCard } from '@/components/ui/kpi-card'; +import { useProductInsights } from '@/hooks/products'; interface SalesHistoryChartProps { productId: string; @@ -42,7 +41,7 @@ interface SalesHistoryChartProps { // ---------- Main Component ---------- -export function SalesHistoryChart({ productId, productSku, productName }: SalesHistoryChartProps) { +export function SalesHistoryChart({ productId, productSku }: SalesHistoryChartProps) { const [period, setPeriod] = useState('30'); const days = Number(period); @@ -53,16 +52,41 @@ export function SalesHistoryChart({ productId, productSku, productName }: SalesH const chartData = useMemo(() => { if (!hasData) return []; - return data!.daily.reduce>((acc, d) => { - const parsed = safeParseDateForChart(d.date); - if (parsed) acc.push({ ...d, ...parsed }); - return acc; - }, []); + const daily = data?.daily ?? []; + return daily.reduce>( + (acc, d) => { + const parsed = safeParseDateForChart(d.date); + if (parsed) acc.push({ ...d, ...parsed }); + return acc; + }, + [], + ); }, [data, hasData]); const kpis = useMemo(() => { - if (!hasData) return { totalQuotedQty: 0, totalOrderedQty: 0, totalQuotedValue: 0, totalOrderedValue: 0, conversionRate: 0, uniqueSellers: 0, avgOrderValue: 0, topSellers: [] }; - return data!.kpis; + if (!hasData) + return { + totalQuotedQty: 0, + totalOrderedQty: 0, + totalQuotedValue: 0, + totalOrderedValue: 0, + conversionRate: 0, + uniqueSellers: 0, + avgOrderValue: 0, + topSellers: [], + }; + return ( + data?.kpis ?? { + totalQuotedQty: 0, + totalOrderedQty: 0, + totalQuotedValue: 0, + totalOrderedValue: 0, + conversionRate: 0, + uniqueSellers: 0, + avgOrderValue: 0, + topSellers: [], + } + ); }, [data, hasData]); // Loading @@ -81,17 +105,17 @@ export function SalesHistoryChart({ productId, productSku, productName }: SalesH return ( - + Vendas Internas -
+

Erro ao carregar dados de vendas

Tente novamente em alguns instantes

- @@ -106,14 +130,15 @@ export function SalesHistoryChart({ productId, productSku, productName }: SalesH return ( - + Vendas Internas -

- Nenhum dado de vendas disponível ainda. Os dados serão exibidos quando houver orçamentos e pedidos. +

+ Nenhum dado de vendas disponível ainda. Os dados serão exibidos quando houver orçamentos + e pedidos.

@@ -126,28 +151,28 @@ export function SalesHistoryChart({ productId, productSku, productName }: SalesH return ( -
+
- + Vendas Internas - - Orçamentos vs Pedidos · {days} dias - + Orçamentos vs Pedidos · {days} dias
{kpis.conversionRate > 0 && ( = 40 ? 'bg-primary/15 text-primary border-primary/30' : - kpis.conversionRate >= 20 ? 'bg-warning/15 text-warning border-warning/30' : - 'bg-destructive/15 text-destructive border-destructive/30' + 'text-xs font-bold', + kpis.conversionRate >= 40 + ? 'border-primary/30 bg-primary/15 text-primary' + : kpis.conversionRate >= 20 + ? 'border-warning/30 bg-warning/15 text-warning' + : 'border-destructive/30 bg-destructive/15 text-destructive', )} > - + {kpis.conversionRate.toFixed(1)}% conversão )} @@ -157,7 +182,11 @@ export function SalesHistoryChart({ productId, productSku, productName }: SalesH {/* KPI cards — 6 metrics in unified grid */} -
+
s.segment).join(', ') - : 'Nenhum ainda'} + sub={ + insights?.topSegments?.length + ? insights.topSegments + .slice(0, 2) + .map((s) => s.segment) + .join(', ') + : 'Nenhum ainda' + } />
{/* Period selector */} - {['15','30','60','90','120','180','360'].map(p => ( - {p}d + {['15', '30', '60', '90', '120', '180', '360'].map((p) => ( + + {p}d + ))} @@ -237,7 +273,9 @@ export function SalesHistoryChart({ productId, productSku, productName }: SalesH {value}} + formatter={(value: string) => ( + {value} + )} /> 0 && (
-

+

Top Vendedores

@@ -292,33 +330,38 @@ export function SalesHistoryChart({ productId, productSku, productName }: SalesH // #1 fix: safe initials extraction — guards against empty sellerName function SellerRow({ seller, rank }: { seller: SellerRanking; rank: number }) { const name = seller.sellerName || 'Vendedor'; - const initials = name - .split(' ') - .filter(w => w.length > 0) - .map(w => w[0]) - .slice(0, 2) - .join('') - .toUpperCase() || '??'; + const initials = + name + .split(' ') + .filter((w) => w.length > 0) + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase() || '??'; return ( -
- +
+ {rank} - {initials} + + {initials} + - {name} + {name} {seller.totalQty} un {formatCurrency(seller.totalValue)}
- + {seller.quoteCount} orç - + {seller.orderCount} ped
@@ -327,30 +370,41 @@ function SellerRow({ seller, rank }: { seller: SellerRanking; rank: number }) { } // #2 fix: SalesTooltip shows fallback when all values are zero -function SalesTooltip({ active, payload }: { active?: boolean; payload?: { payload: Record }[] }) { +function SalesTooltip({ + active, + payload, +}: { + active?: boolean; + payload?: { payload: Record }[]; +}) { if (!active || !payload?.length) return null; const data = payload[0]?.payload; if (!data) return null; - const hasAnyActivity = (data.quotedQty > 0) || (data.orderedQty > 0) || (data.quoteCount > 0) || (data.orderCount > 0); + const hasAnyActivity = + data.quotedQty > 0 || data.orderedQty > 0 || data.quoteCount > 0 || data.orderCount > 0; return ( -
+

{data.fullDate}

{!hasAnyActivity && ( -

Sem movimentação neste dia

+

Sem movimentação neste dia

)} {data.quotedQty > 0 && (
Orçado: - {data.quotedQty} un · {formatCurrency(data.quotedValue)} + + {data.quotedQty} un · {formatCurrency(data.quotedValue)} +
)} {data.orderedQty > 0 && (
Vendido: - {data.orderedQty} un · {formatCurrency(data.orderedValue)} + + {data.orderedQty} un · {formatCurrency(data.orderedValue)} +
)} {data.quoteCount > 0 && ( diff --git a/src/components/quotes/MarginInsightBadge.tsx b/src/components/quotes/MarginInsightBadge.tsx index ac178f880..d1c7bdcfc 100644 --- a/src/components/quotes/MarginInsightBadge.tsx +++ b/src/components/quotes/MarginInsightBadge.tsx @@ -2,12 +2,12 @@ * MarginInsightBadge — badge inline com a margem do orçamento atual vs mediana histórica do vendedor. * Verde se acima da mediana; âmbar se abaixo. Usa quote_items históricos do vendedor. */ -import { useQuery } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; -import { Badge } from "@/components/ui/badge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { TrendingUp, TrendingDown, Info } from "lucide-react"; -import { useAuth } from "@/contexts/AuthContext"; +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { Badge } from '@/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { TrendingUp, TrendingDown, Info } from 'lucide-react'; +import { useAuth } from '@/contexts/AuthContext'; interface MarginInsightBadgeProps { /** Margem (ou desconto) aparente do orçamento atual em %. */ @@ -26,29 +26,33 @@ export function MarginInsightBadge({ className, }: MarginInsightBadgeProps) { const { user } = useAuth(); - const dualMode = (markupPercent ?? 0) > 0 && realMarginPercent !== null; + const userId = user?.id; + const effectiveMarkupPercent = markupPercent ?? 0; + const effectiveRealMarginPercent = realMarginPercent ?? 0; + const dualMode = effectiveMarkupPercent > 0 && realMarginPercent !== undefined; const { data: median } = useQuery({ - queryKey: ["seller-margin-median", user?.id], - enabled: !!user?.id && !dualMode, + queryKey: ['seller-margin-median', userId], + enabled: !!userId && !dualMode, queryFn: async (): Promise => { + if (!userId) return null; // Aproximação: usa subtotal vs total de orçamentos convertidos do vendedor (proxy de margem) const { data, error } = await supabase - .from("quotes") - .select("subtotal, total, discount_amount") - .eq("seller_id", user!.id) - .in("status", ["converted", "approved"]) - .order("created_at", { ascending: false }) + .from('quotes') + .select('subtotal, total, discount_amount') + .eq('seller_id', userId) + .in('status', ['converted', 'approved']) + .order('created_at', { ascending: false }) .limit(50); if (error || !data?.length) return null; const margins = data - .map(q => { + .map((q) => { const sub = Number(q.subtotal ?? 0); const tot = Number(q.total ?? 0); if (sub <= 0) return null; // Margem aparente = (total - desconto) / subtotal — proxy para comparação relativa - return ((tot / sub) - 1) * 100 + 100; // normaliza próximo a 100 + return (tot / sub - 1) * 100 + 100; // normaliza próximo a 100 }) .filter((v): v is number => v !== null) .sort((a, b) => a - b); @@ -66,22 +70,30 @@ export function MarginInsightBadge({ - Aparente {currentMarginPercent.toFixed(1)}% · Real {realMarginPercent!.toFixed(1)}% + Aparente {currentMarginPercent.toFixed(1)}% · Real{' '} + {effectiveRealMarginPercent.toFixed(1)}% - -

Margem de negociação ativa

+ +

Margem de negociação ativa

- Markup interno: +{markupPercent!.toFixed(1)}% + Markup interno: +{effectiveMarkupPercent.toFixed(1)}%
Cliente vê desconto de {currentMarginPercent.toFixed(1)}%
- Desconto real (alçada): {realMarginPercent!.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}% + Desconto real (alçada):{' '} + + {effectiveRealMarginPercent.toLocaleString('pt-BR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + % +

@@ -103,19 +115,20 @@ export function MarginInsightBadge({ variant="outline" className={`gap-1.5 ${ isAbove - ? "bg-success/10 text-success border-success/30" - : "bg-warning/10 text-warning border-warning/30" - } ${className ?? ""}`} + ? 'border-success/30 bg-success/10 text-success' + : 'border-warning/30 bg-warning/10 text-warning' + } ${className ?? ''}`} > - {isAbove ? "+" : ""}{delta.toFixed(1)}pp vs mediana + {isAbove ? '+' : ''} + {delta.toFixed(1)}pp vs mediana - -

Insight de margem

+ +

Insight de margem

Sua mediana histórica: {median.toFixed(1)}%
diff --git a/src/hooks/kit-builder/useKitBuilderQueries.ts b/src/hooks/kit-builder/useKitBuilderQueries.ts index 9da797e16..7df62255b 100644 --- a/src/hooks/kit-builder/useKitBuilderQueries.ts +++ b/src/hooks/kit-builder/useKitBuilderQueries.ts @@ -17,7 +17,10 @@ import { import { MOCK_BOXES, MOCK_ITEMS } from '@/lib/kit-builder/mock-data'; // Import transformers from the main hook file -import { transformToKitBox, transformToKitItem } from "@/hooks/kit-builder/useKitBuilderTransformers"; +import { + transformToKitBox, + transformToKitItem, +} from '@/hooks/kit-builder/useKitBuilderTransformers'; import { logger } from '@/lib/logger'; function filterBoxes( @@ -32,12 +35,18 @@ function filterBoxes( (b) => b.name.toLowerCase().includes(q) || b.sku.toLowerCase().includes(q), ); } - if (dimFilters?.minWidth) - filtered = filtered.filter((b) => b.internalWidth >= dimFilters.minWidth!); - if (dimFilters?.minHeight) - filtered = filtered.filter((b) => b.internalHeight >= dimFilters.minHeight!); - if (dimFilters?.minDepth) - filtered = filtered.filter((b) => b.internalDepth >= dimFilters.minDepth!); + if (dimFilters?.minWidth) { + const minWidth = dimFilters.minWidth; + filtered = filtered.filter((b) => b.internalWidth >= minWidth); + } + if (dimFilters?.minHeight) { + const minHeight = dimFilters.minHeight; + filtered = filtered.filter((b) => b.internalHeight >= minHeight); + } + if (dimFilters?.minDepth) { + const minDepth = dimFilters.minDepth; + filtered = filtered.filter((b) => b.internalDepth >= minDepth); + } if (dimFilters?.material) filtered = filtered.filter((b) => b.material === dimFilters.material); return filtered; } diff --git a/src/hooks/products/useStockAlerts.integration.test.tsx b/src/hooks/products/useStockAlerts.integration.test.tsx index 8d7bfe9a1..915827c0f 100644 --- a/src/hooks/products/useStockAlerts.integration.test.tsx +++ b/src/hooks/products/useStockAlerts.integration.test.tsx @@ -22,9 +22,7 @@ const queryClient = new QueryClient({ }); const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + {children} ); describe('useStockAlerts integration', () => { @@ -44,46 +42,50 @@ describe('useStockAlerts integration', () => { brand: 'Brand X', primary_image_url: 'http://img.com/1.jpg', images: ['http://img.com/1.jpg'], - } + }, ]; - (bridge.invokeExternalDb as any).mockResolvedValue({ + const invokeExternalDbMock = vi.mocked(bridge.invokeExternalDb); + invokeExternalDbMock.mockResolvedValue({ records: mockRecords, - count: 1 + count: 1, }); const { result } = renderHook(() => useStockAlerts(50, 10), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - const callArgs = (bridge.invokeExternalDb as any).mock.calls[0][0]; + const callArgs = invokeExternalDbMock.mock.calls[0][0]; const selectStr = callArgs.select; const fields = selectStr.split(',').map((f: string) => f.trim()); expect(fields).not.toContain('supplier_name'); expect(fields).not.toContain('image_url'); - + // Verify it contains the required fields expect(selectStr).toContain('brand'); expect(selectStr).toContain('primary_image_url'); }); it('should map brand to supplier field correctly', async () => { - (bridge.invokeExternalDb as any).mockResolvedValue({ - records: [{ - id: 'p1', - name: 'Mapped Product', - sku: 'SKU-M', - stock_quantity: 2, - brand: 'External Supplier', - }], - count: 1 + const invokeExternalDbMock = vi.mocked(bridge.invokeExternalDb); + invokeExternalDbMock.mockResolvedValue({ + records: [ + { + id: 'p1', + name: 'Mapped Product', + sku: 'SKU-M', + stock_quantity: 2, + brand: 'External Supplier', + }, + ], + count: 1, }); const { result } = renderHook(() => useStockAlerts(), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - + expect(result.current.data?.[0].supplier).toBe('External Supplier'); }); }); diff --git a/src/pages/mockups/MockupHistoryPage.tsx b/src/pages/mockups/MockupHistoryPage.tsx index fa8eb0a81..784b9cfed 100644 --- a/src/pages/mockups/MockupHistoryPage.tsx +++ b/src/pages/mockups/MockupHistoryPage.tsx @@ -40,18 +40,20 @@ interface GeneratedMockup { export default function MockupHistoryPage() { const { user } = useAuth(); + const userId = user?.id; const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const debouncedSearch = useDebounce(search, 400); const pageSize = 20; const { data, isLoading, refetch } = useQuery({ - queryKey: ['mockup-history', page, debouncedSearch], + queryKey: ['mockup-history', userId, page, debouncedSearch], queryFn: async () => { + if (!userId) return { mockups: [], totalCount: 0 }; let query = supabase .from('generated_mockups') .select('*', { count: 'exact' }) - .eq('seller_id', user!.id) + .eq('seller_id', userId) .order('created_at', { ascending: false }) .range((page - 1) * pageSize, page * pageSize - 1); @@ -65,7 +67,7 @@ export default function MockupHistoryPage() { if (error) throw error; return { mockups: data as GeneratedMockup[], totalCount: count || 0 }; }, - enabled: !!user, + enabled: !!userId, staleTime: 1000 * 60 * 5, // 5 minutos }); @@ -223,7 +225,7 @@ export default function MockupHistoryPage() { variant="ghost" size="icon" aria-label="Download" - onClick={() => window.open(m.mockup_url!, '_blank')} + onClick={() => m.mockup_url && window.open(m.mockup_url, '_blank')} data-testid="mockup-history-download-btn" >