diff --git a/package-lock.json b/package-lock.json index 01997f85a..0d2c56a99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,6 +109,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react-swc": "^4.3.0", "autoprefixer": "^10.5.0", + "fast-check": "^4.8.0", "jest-axe": "^10.0.0", "jsdom": "^29.1.1", "postcss": "^8.5.10", @@ -7149,6 +7150,29 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -11294,6 +11318,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qrcode.react": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-3.2.0.tgz", diff --git a/src/components/compare/SupplierComparisonModal.tsx b/src/components/compare/SupplierComparisonModal.tsx index 34e8d61b5..6695eb63d 100644 --- a/src/components/compare/SupplierComparisonModal.tsx +++ b/src/components/compare/SupplierComparisonModal.tsx @@ -1,5 +1,4 @@ -import { useMemo, useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useMemo, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -26,27 +25,17 @@ import { TrendingDown, TrendingUp, Package, - Crown, Minus, - ShieldCheck, - Clock, - Palette, Sparkles, LayoutGrid, List, - Info, AlertCircle, ExternalLink, ChevronRight, Filter, - ArrowRightLeft, } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { - useSupplierComparison, - type Product, - type SupplierComparisonSort, -} from '@/hooks/products'; +import { useSupplierComparison, type Product, type SupplierComparisonSort } from '@/hooks/products'; import { Skeleton } from '@/components/ui/skeleton'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { PriceSparkline } from './PriceSparkline'; @@ -61,6 +50,23 @@ interface SupplierComparisonModalProps { onOpenChange: (open: boolean) => void; } +/** Linha de comparação renderizada nas variações tabela/card (base + alternativas). */ +interface ComparisonRowData { + product: Product; + isBase: boolean; + isLowestPrice: boolean; + score: number; + scoreBreakdown: Record; + priceDiffPercent: number; + priceDiff: number; +} + +interface ComparisonItemProps { + row: ComparisonRowData; + formatCurrency: (value: number) => string; + formatPercent: (value: number) => string; +} + const SORT_LABEL: Record = { score: 'Score', price: 'Menor preço', @@ -92,7 +98,6 @@ export function SupplierComparisonModal({ open, onOpenChange, }: SupplierComparisonModalProps) { - const navigate = useNavigate(); const [onlyVerified, setOnlyVerified] = useState(false); const [sortBy, setSortBy] = useState('score'); const [viewMode, setViewMode] = useState<'table' | 'grid'>('table'); @@ -136,33 +141,36 @@ export function SupplierComparisonModal({ const filteredProducts = useMemo(() => { let filtered = allProducts; if (onlyVerified) { - filtered = filtered.filter(p => p.isVerified); + filtered = filtered.filter((p) => p.isVerified); } if (activeFilters.includes('moq10')) { - filtered = filtered.filter(p => (p.product.minQuantity ?? 1) <= 10); + filtered = filtered.filter((p) => (p.product.minQuantity ?? 1) <= 10); } if (activeFilters.includes('instock')) { - filtered = filtered.filter(p => p.product.stock > 0); + filtered = filtered.filter((p) => p.product.stock > 0); } return filtered; }, [allProducts, onlyVerified, activeFilters]); const winner = useMemo(() => { if (allProducts.length === 0) return null; - return allProducts.reduce((prev, current) => (prev.score > current.score) ? prev : current, allProducts[0]); + return allProducts.reduce( + (prev, current) => (prev.score > current.score ? prev : current), + allProducts[0], + ); }, [allProducts]); const winnerReason = useMemo(() => { if (!winner) return ''; const { scoreBreakdown } = winner; - const topFactor = Object.entries(scoreBreakdown).reduce((a, b) => a[1] > b[1] ? a : b); + const topFactor = Object.entries(scoreBreakdown).reduce((a, b) => (a[1] > b[1] ? a : b)); const factorLabels: Record = { price: 'melhor preço', stock: 'maior estoque', colors: 'maior variedade de cores', moq: 'baixo pedido mínimo', lead: 'entrega mais rápida', - verified: 'fornecedor ativo e confiável' + verified: 'fornecedor ativo e confiável', }; return `O ${winner.product.supplier.name} é o vencedor principalmente pelo ${factorLabels[topFactor[0]] || 'equilíbrio de fatores'}.`; }, [winner]); @@ -198,8 +206,12 @@ export function SupplierComparisonModal({

Nenhuma alternativa encontrada

-

Tente buscar por outra categoria ou produto.

- +

+ Tente buscar por outra categoria ou produto. +

+
@@ -210,20 +222,20 @@ export function SupplierComparisonModal({ return ( - -
-
+ +
+
-
+
- Comparador de Fornecedores -
-

- {displayProduct.name} -

- + + Comparador de Fornecedores + +
+

{displayProduct.name}

+ {allProducts.length} fornecedores
@@ -232,10 +244,16 @@ export function SupplierComparisonModal({
setViewMode(v as 'table' | 'grid')}> - + Tabela - + Cards @@ -264,26 +282,38 @@ export function SupplierComparisonModal({ {Object.entries(SORT_LABEL).map(([val, label]) => ( - {label} + + {label} + ))}
- setActiveFilters(prev => prev.includes('moq10') ? prev.filter(f => f !== 'moq10') : [...prev, 'moq10'])} + + setActiveFilters((prev) => + prev.includes('moq10') ? prev.filter((f) => f !== 'moq10') : [...prev, 'moq10'], + ) + } /> - setActiveFilters(prev => prev.includes('instock') ? prev.filter(f => f !== 'instock') : [...prev, 'instock'])} + + setActiveFilters((prev) => + prev.includes('instock') + ? prev.filter((f) => f !== 'instock') + : [...prev, 'instock'], + ) + } /> -
+
-
@@ -302,81 +335,152 @@ export function SupplierComparisonModal({
-
+
- + - {maxEconomiaPorMOQ > 0 && } - {fastestLeadTimeDays != null && fastestLeadTimeDays > 0 && } + {maxEconomiaPorMOQ > 0 && ( + + )} + {fastestLeadTimeDays !== null && fastestLeadTimeDays > 0 && ( + + )}
-
-
+
+
-

Por que este vencedor?

-

- {winnerReason} -

+

Por que este vencedor?

+

{winnerReason}

{viewMode === 'table' ? ( -
+
- Prod - Fornecedor - Preço / Ticket - Histórico & Δ - Estoque - Lead - Score + + Prod + + + Fornecedor + + + Preço / Ticket + + + Histórico & Δ + + + Estoque + + + Lead + + + Score + {filteredProducts.map((row) => ( - + ))}
) : ( -
+
{filteredProducts.map((row) => ( - + ))}
)} {/* Sticky Decision Footer */} -
+
{filteredProducts.slice(0, 3).map((p) => ( -
- +
+
))}
-

Resumo da Decisão

-

+

Resumo da Decisão

+

{filteredProducts.length} fornecedores filtrados de {allProducts.length} totais

-
- Produto Selecionado +
+ + Produto Selecionado + {displayProduct.supplier.name}
- - +
@@ -386,15 +490,26 @@ export function SupplierComparisonModal({ ); } -function ToggleFilter({ label, active, onToggle }: { id: string, label: string, active: boolean, onToggle: () => void }) { +function ToggleFilter({ + label, + active, + onToggle, +}: { + id: string; + label: string; + active: boolean; + onToggle: () => void; +}) { return ( -
@@ -582,53 +796,68 @@ function ComparisonCard({ row, formatCurrency, formatPercent }: any) { ); } -function ScoreBreakdown({ score, breakdown, label }: { score: number; breakdown: Record; label?: string }) { +function ScoreBreakdown({ + score, + breakdown, + label, +}: { + score: number; + breakdown: Record; + label?: string; +}) { return ( - - +
-
+
-
+
-

Análise IA

+

Análise IA

- {score} + + {score} +
{Object.entries(breakdown).map(([key, val]) => (
{BREAKDOWN_LABELS[key] || key} - {val.toFixed(1)} / {WEIGHT_LIMITS[key]} + + {val.toFixed(1)} / {WEIGHT_LIMITS[key]} +
-
+
))}
-
-

- "Esta pontuação é dinâmica e reflete o equilíbrio entre custo operacional, disponibilidade imediata e confiabilidade do fornecedor." +

+

+ "Esta pontuação é dinâmica e reflete o equilíbrio entre custo operacional, + disponibilidade imediata e confiabilidade do fornecedor."

diff --git a/src/components/products/PriceFreshnessBadge.test.tsx b/src/components/products/PriceFreshnessBadge.test.tsx index 727afe678..bf3ad9845 100644 --- a/src/components/products/PriceFreshnessBadge.test.tsx +++ b/src/components/products/PriceFreshnessBadge.test.tsx @@ -17,11 +17,11 @@ describe('PriceFreshnessBadge Component', () => { return render({ui}); }; - it("renders 'Atualizado (há 0 dias)' for fresh updates in inline variant", () => { + it("renders 'Atualizado hoje' for fresh updates in inline variant", () => { const today = new Date('2026-05-03T09:00:00Z').toISOString(); renderWithProvider(); - expect(screen.getByText(/Atualizado \(há 0 dias\)/i)).toBeInTheDocument(); + expect(screen.getByText(/Atualizado hoje/i)).toBeInTheDocument(); }); it('renders nothing for fresh updates in compact variant (unless alwaysShow is true)', () => { @@ -42,19 +42,19 @@ describe('PriceFreshnessBadge Component', () => { expect(screen.getByText(/há 4m/i)).toBeInTheDocument(); }); - it("renders 'Próximo do limite (há 45 dias)' for aging updates in inline variant", () => { + it("renders 'Atualizado há 45 dias' for aging updates in inline variant", () => { // 2026-05-03 - 45 days ago const fortyFiveDaysAgo = new Date('2026-03-19T12:00:00Z').toISOString(); renderWithProvider(); - expect(screen.getByText(/Próximo do limite \(há 45 dias\)/i)).toBeInTheDocument(); + expect(screen.getByText(/Atualizado há 45 dias/i)).toBeInTheDocument(); }); it('renders PDP variant with warning box for stale updates', () => { const monthsAgo = new Date('2026-01-03T12:00:00Z').toISOString(); renderWithProvider(); - expect(screen.getByText(/Possivelmente defasado/i)).toBeInTheDocument(); + expect(screen.getByText(/Preço pode estar defasado/i)).toBeInTheDocument(); expect(screen.getByText(/\(há 120 dias\)/i)).toBeInTheDocument(); expect(screen.getByText(/Confirme com o fornecedor/i)).toBeInTheDocument(); }); diff --git a/src/components/products/PriceFreshnessBadge.tsx b/src/components/products/PriceFreshnessBadge.tsx index 32185504d..5f48403b1 100644 --- a/src/components/products/PriceFreshnessBadge.tsx +++ b/src/components/products/PriceFreshnessBadge.tsx @@ -119,7 +119,6 @@ function FreshnessTooltipBody({ freshness, priceUpdatedAt }: FreshnessTooltipPro // Padrão único pt-BR: "em DD/MM/AAAA". A hora local e a forma por extenso // ficam como detalhamento auxiliar, sem repetir a data curta. const shortDate = isValidDate ? formatPriceDateShort(dateValue) : null; - const longDate = isValidDate ? formatPriceDateLong(dateValue) : null; const _exactDateTime = isValidDate ? formatExactDateTime(dateValue) : null; const statusLabel = STATUS_LABELS[freshness.status]; const rule = buildClassificationRule(freshness.thresholdDays); @@ -128,12 +127,15 @@ function FreshnessTooltipBody({ freshness, priceUpdatedAt }: FreshnessTooltipPro
{statusLabel} - {freshness.status !== 'unknown' && (() => { - const stripped = freshness.label.match(/\(([^)]+)\)/)?.[1] || freshness.label.replace(/^(Atualizado|Próximo do limite|Possivelmente defasado)\s+/i, '').trim(); - return ( - ({stripped}) - ); - })()} + {freshness.status !== 'unknown' && + (() => { + const stripped = + freshness.label.match(/\(([^)]+)\)/)?.[1] || + freshness.label + .replace(/^(Atualizado|Próximo do limite|Possivelmente defasado)\s+/i, '') + .trim(); + return ({stripped}); + })()}
{shortDate && (
@@ -395,7 +397,7 @@ export function PriceFreshnessBadge({ >