diff --git a/src/components/admin/products/sections/ProductClassificationSection.tsx b/src/components/admin/products/sections/ProductClassificationSection.tsx index 67b5ec148..f25a610ce 100644 --- a/src/components/admin/products/sections/ProductClassificationSection.tsx +++ b/src/components/admin/products/sections/ProductClassificationSection.tsx @@ -22,7 +22,6 @@ import { Info, ChevronDown, ChevronRight, - Sparkles, } from 'lucide-react'; @@ -47,50 +46,58 @@ interface ClassificationCardProps { function DisabledPlaceholder() { return ( -
+
Disponível após salvar o produto
); } -function ClassificationCard({ title, subtitle, icon: Icon, iconColor, children, defaultOpen = false, disabled = false }: ClassificationCardProps) { +function ClassificationCard({ + title, + subtitle, + icon: iconComponent, + iconColor, + children, + defaultOpen = false, + disabled = false, +}: ClassificationCardProps) { const [isOpen, setIsOpen] = useState(defaultOpen); + const Icon = iconComponent; return ( - + - {isOpen && ( -
- {children} -
- )} + {isOpen &&
{children}
}
); } @@ -104,6 +111,7 @@ export default function ProductClassificationSection({ onGenderChange, }: Props) { const showFullContent = isEdit && productId; + const savedProductId = showFullContent ? productId : undefined; return (
@@ -113,13 +121,17 @@ export default function ProductClassificationSection({
-

Classificação & Vínculos

-

Configure variações, materiais, tags e vínculos comerciais

+

+ Classificação & Vínculos +

+

+ Configure variações, materiais, tags e vínculos comerciais +

{/* Grid de classificações */} -
+
{/* Eixos de Variação (inclui Gênero) */} - {showFullContent ? ( - + {savedProductId ? ( + ) : ( )} - {/* Materiais */} - {showFullContent ? ( - + {savedProductId ? ( + ) : ( )} @@ -175,8 +190,8 @@ export default function ProductClassificationSection({ iconColor="bg-orange/10 text-orange" disabled={!showFullContent} > - {showFullContent ? ( - + {savedProductId ? ( + ) : ( )} @@ -190,8 +205,8 @@ export default function ProductClassificationSection({ iconColor="bg-info/10 text-info" disabled={!showFullContent} > - {showFullContent ? ( - + {savedProductId ? ( + ) : ( )} @@ -205,8 +220,8 @@ export default function ProductClassificationSection({ iconColor="bg-destructive/10 text-destructive" disabled={!showFullContent} > - {showFullContent ? ( - + {savedProductId ? ( + ) : ( )} @@ -214,7 +229,7 @@ export default function ProductClassificationSection({
{!showFullContent && ( -
+
Salve o produto primeiro para editar as classificações acima.
diff --git a/src/components/admin/security/keys/UpdateMcpKeyDialog.tsx b/src/components/admin/security/keys/UpdateMcpKeyDialog.tsx index 6fe666298..2b92b9c72 100644 --- a/src/components/admin/security/keys/UpdateMcpKeyDialog.tsx +++ b/src/components/admin/security/keys/UpdateMcpKeyDialog.tsx @@ -12,18 +12,23 @@ * - Para edições que NÃO promovem a FULL, o step-up não é necessário e a * chamada vai direta. */ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from 'react'; import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { Badge } from "@/components/ui/badge"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Pencil, ShieldAlert } from "lucide-react"; -import { toast } from "sonner"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Pencil, ShieldAlert } from 'lucide-react'; +import { toast } from 'sonner'; import { KNOWN_SCOPES, FULL_SCOPE, @@ -33,14 +38,14 @@ import { FULL_SCOPE_MAX_TTL_DAYS, isFullAccess, type McpScope, -} from "@/lib/mcp/scopes"; -import { useCanGrantMcpFull } from "./useCanGrantMcpFull"; -import { sanitizeError } from "@/lib/security/sanitize-error"; -import { useDevChallenge } from "@/contexts/DevChallengeContext"; -import { invokeFullScopeFunction } from "@/lib/auth/invoke-full-scope"; -import { supabase } from "@/integrations/supabase/client"; -import { handleStepUpError } from "@/lib/auth/step-up-error"; -import type { McpKeyRow } from "./useMcpKeys"; +} from '@/lib/mcp/scopes'; +import { useCanGrantMcpFull } from './useCanGrantMcpFull'; +import { sanitizeError } from '@/lib/security/sanitize-error'; +import { useDevChallenge } from '@/contexts/DevChallengeContext'; +import { invokeFullScopeFunction } from '@/lib/auth/invoke-full-scope'; +import { supabase } from '@/integrations/supabase/client'; +import { handleStepUpError } from '@/lib/auth/step-up-error'; +import type { McpKeyRow } from './useMcpKeys'; interface Props { source: McpKeyRow | null; @@ -50,27 +55,27 @@ interface Props { } function isoToLocalInput(iso: string | null): string { - if (!iso) return ""; + if (!iso) return ''; const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ""; - const pad = (n: number) => n.toString().padStart(2, "0"); + if (Number.isNaN(d.getTime())) return ''; + const pad = (n: number) => n.toString().padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } function isoDaysFromNow(days: number): string { const d = new Date(); d.setDate(d.getDate() + days); - const pad = (n: number) => n.toString().padStart(2, "0"); + const pad = (n: number) => n.toString().padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Props) { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); const [scopes, setScopes] = useState([]); - const [expiresLocal, setExpiresLocal] = useState(""); - const [justification, setJustification] = useState(""); - const [confirmation, setConfirmation] = useState(""); + const [expiresLocal, setExpiresLocal] = useState(''); + const [justification, setJustification] = useState(''); + const [confirmation, setConfirmation] = useState(''); const [submitting, setSubmitting] = useState(false); const { canGrant: canGrantFull, loading: grantorLoading } = useCanGrantMcpFull(); @@ -80,11 +85,11 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr useEffect(() => { if (!open || !source) return; setName(source.name); - setDescription(source.description ?? ""); + setDescription(source.description ?? ''); setScopes((source.scopes ?? []) as McpScope[]); setExpiresLocal(isoToLocalInput(source.expires_at)); - setJustification(""); - setConfirmation(""); + setJustification(''); + setConfirmation(''); setSubmitting(false); }, [open, source]); @@ -105,12 +110,12 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr const validation = useMemo(() => { if (!source) return null; - if (name.trim().length < 3) return "Nome precisa ter ao menos 3 caracteres."; - if (scopes.length === 0) return "Selecione ao menos um escopo."; + if (name.trim().length < 3) return 'Nome precisa ter ao menos 3 caracteres.'; + if (scopes.length === 0) return 'Selecione ao menos um escopo.'; if (escalating) { - if (!expiresLocal) return "Chaves FULL exigem data de expiração."; + if (!expiresLocal) return 'Chaves FULL exigem data de expiração.'; const exp = new Date(expiresLocal).getTime(); - if (Number.isNaN(exp) || exp <= Date.now()) return "Expiração precisa ser no futuro."; + if (Number.isNaN(exp) || exp <= Date.now()) return 'Expiração precisa ser no futuro.'; const maxMs = FULL_SCOPE_MAX_TTL_DAYS * 24 * 60 * 60 * 1000; if (exp - Date.now() > maxMs) return `Janela máxima é ${FULL_SCOPE_MAX_TTL_DAYS} dias.`; if (justification.trim().length < FULL_SCOPE_MIN_JUSTIFICATION) { @@ -124,21 +129,23 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr }, [source, name, scopes, escalating, expiresLocal, justification, confirmation]); /** Body comum enviado para mcp-keys-update. */ - const buildBody = (): Record => ({ - key_id: source!.id, - name: name.trim() !== source!.name ? name.trim() : undefined, - description: description.trim() !== (source!.description ?? "") - ? (description.trim() || null) - : undefined, - scopes, - expires_at: expiresLocal ? new Date(expiresLocal).toISOString() : null, - justification: escalating ? justification.trim() : null, - confirmation_phrase: escalating ? confirmation : null, - }); + const buildBody = (): Record | null => { + if (!source) return null; + return { + key_id: source.id, + name: name.trim() !== source.name ? name.trim() : undefined, + description: + description.trim() !== (source.description ?? '') ? description.trim() || null : undefined, + scopes, + expires_at: expiresLocal ? new Date(expiresLocal).toISOString() : null, + justification: escalating ? justification.trim() : null, + confirmation_phrase: escalating ? confirmation : null, + }; + }; const handleSuccess = (data: { ok?: boolean; escalated_to_full?: boolean }) => { toast.success( - data?.escalated_to_full ? "Chave atualizada e escalada para FULL" : "Chave atualizada", + data?.escalated_to_full ? 'Chave atualizada e escalada para FULL' : 'Chave atualizada', ); onUpdated(); onOpenChange(false); @@ -150,6 +157,8 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr return; } if (!source) return; + const body = buildBody(); + if (!body) return; setSubmitting(true); try { if (escalating) { @@ -159,30 +168,37 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr { ok: boolean; escalated_to_full?: boolean } >({ challenge, - functionName: "mcp-keys-update", - action: "mcp_full_escalate", + functionName: 'mcp-keys-update', + action: 'mcp_full_escalate', actionLabel: `Escalar chave MCP "${source.name}" para FULL`, targetRef: source.id, - body: buildBody(), + body, }); - if (result.status === "cancelled" || result.status === "step_up_error") return; - if (result.status === "error") { - toast.error("Falha ao atualizar chave", { description: sanitizeError(result.error ?? result.data) }); + if (result.status === 'cancelled' || result.status === 'step_up_error') return; + if (result.status === 'error') { + toast.error('Falha ao atualizar chave', { + description: sanitizeError(result.error ?? result.data), + }); return; } handleSuccess(result.data); } else { // Edição comum: chamada direta (sem step-up). - const { data, error } = await supabase.functions.invoke("mcp-keys-update", { - body: { ...buildBody(), step_up_token: null }, + const { data, error } = await supabase.functions.invoke('mcp-keys-update', { + body: { ...body, step_up_token: null }, }); - if (handleStepUpError(data, error, () => { void handleSubmit(); })) return; + if ( + handleStepUpError(data, error, () => { + void handleSubmit(); + }) + ) + return; if (error) { - toast.error("Falha ao atualizar chave", { description: sanitizeError(error) }); + toast.error('Falha ao atualizar chave', { description: sanitizeError(error) }); return; } if (!data?.ok) { - toast.error("Não foi possível atualizar a chave", { description: sanitizeError(data) }); + toast.error('Não foi possível atualizar a chave', { description: sanitizeError(data) }); return; } handleSuccess(data); @@ -199,14 +215,14 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr return ( <> - + Editar chave MCP - {source.key_prefix}… ·{" "} - alterações ficam registradas no audit log. + {source.key_prefix}… · alterações ficam + registradas no audit log. @@ -234,7 +250,7 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr
- +
{KNOWN_SCOPES.map((s) => { const active = scopes.includes(s); @@ -247,25 +263,26 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr onClick={() => handleScopeToggle(s)} disabled={lock} className={[ - "px-2 py-1 rounded text-xs border font-mono transition", + 'rounded border px-2 py-1 font-mono text-xs transition', lock - ? "bg-muted text-muted-foreground border-border cursor-not-allowed opacity-60" + ? 'cursor-not-allowed border-border bg-muted text-muted-foreground opacity-60' : active ? isFull - ? "bg-destructive text-destructive-foreground border-destructive" - : "bg-primary text-primary-foreground border-primary" - : "bg-background border-border hover:border-primary/40", - ].join(" ")} + ? 'border-destructive bg-destructive text-destructive-foreground' + : 'border-primary bg-primary text-primary-foreground' + : 'border-border bg-background hover:border-primary/40', + ].join(' ')} > {s} - {lock && " 🔒"} + {lock && ' 🔒'} ); })}
{fullLockedForUser && (

- 🔒 Você não pode escalar esta chave para * (FULL). + 🔒 Você não pode escalar esta chave para *{' '} + (FULL).

)}
@@ -280,10 +297,10 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr value={expiresLocal} onChange={(e) => setExpiresLocal(e.target.value)} /> -

+

{willBeFull ? `Obrigatório para chave FULL. Máx ${FULL_SCOPE_MAX_TTL_DAYS} dias.` - : "Em branco = sem expiração."} + : 'Em branco = sem expiração.'}

@@ -294,9 +311,9 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr Escalando para acesso total * - Esta alteração concede acesso total ao MCP. - Exige justificativa, confirmação e verificação dupla{" "} - (senha + código por e-mail) antes de ser aplicada. + Esta alteração concede acesso total ao MCP. Exige justificativa, + confirmação e verificação dupla (senha + código por e-mail) antes + de ser aplicada. )} @@ -315,9 +332,9 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr maxLength={1000} placeholder="Por que esta chave precisa virar FULL agora?" /> -

- {justification.length}/{FULL_SCOPE_MIN_JUSTIFICATION} mínimo — - registrada no audit log. +

+ {justification.length}/{FULL_SCOPE_MIN_JUSTIFICATION} mínimo — registrada no + audit log.

@@ -345,10 +362,10 @@ export function UpdateMcpKeyDialog({ source, open, onOpenChange, onUpdated }: Pr disabled={submitting || !!validation || fullLockedForUser} > {submitting - ? "Salvando…" + ? 'Salvando…' : escalating - ? "Verificar e escalar para FULL" - : "Salvar alterações"} + ? 'Verificar e escalar para FULL' + : 'Salvar alterações'} diff --git a/src/components/admin/telemetry/BridgeCallDetailDrawer.tsx b/src/components/admin/telemetry/BridgeCallDetailDrawer.tsx index 8513d8539..0490142db 100644 --- a/src/components/admin/telemetry/BridgeCallDetailDrawer.tsx +++ b/src/components/admin/telemetry/BridgeCallDetailDrawer.tsx @@ -5,7 +5,13 @@ * Usa o request_id (correlation-id) propagado entre client e edge function * para permitir buscar logs do servidor com o mesmo identificador. */ -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from '@/components/ui/sheet'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Copy, Check, AlertCircle } from 'lucide-react'; @@ -46,13 +52,12 @@ export function BridgeCallDetailDrawer({ sample, open, onOpenChange }: Props) { if (!sample) return null; - const idsMatch = sample.requestId && sample.serverRequestId - ? sample.requestId === sample.serverRequestId - : null; + const idsMatch = + sample.requestId && sample.serverRequestId ? sample.requestId === sample.serverRequestId : null; return ( - + Detalhes da chamada @@ -68,18 +73,18 @@ export function BridgeCallDetailDrawer({ sample, open, onOpenChange }: Props) {
{/* Request ID — destaque */}
-

+

Request ID (client)

- + {sample.requestId ?? '—'} {sample.requestId && (
{sample.serverRequestId && ( <> -

+

Request ID (server eco)

- + {sample.serverRequestId} {idsMatch === false && ( - + divergente )} {idsMatch === true && ( - match ✓ + + match ✓ + )}
@@ -124,24 +131,30 @@ export function BridgeCallDetailDrawer({ sample, open, onOpenChange }: Props) { {sample.target && (

Alvo

-

{sample.target}

+

{sample.target}

)}
{/* Timing & sizes */}
-
+

Latência

-

{formatMs(sample.durationMs)}

+

+ {formatMs(sample.durationMs)} +

-
+

Enviado

-

{formatBytes(sample.reqBytes)}

+

+ {formatBytes(sample.reqBytes)} +

-
+

Recebido

-

{formatBytes(sample.respBytes)}

+

+ {formatBytes(sample.respBytes)} +

@@ -160,11 +173,11 @@ export function BridgeCallDetailDrawer({ sample, open, onOpenChange }: Props) { {/* Erro */} {sample.errorMessage && ( -
-

+

+

Erro

-

+

{sample.errorMessage}

@@ -172,9 +185,9 @@ export function BridgeCallDetailDrawer({ sample, open, onOpenChange }: Props) { {/* Hint p/ logs do servidor */} {sample.requestId && ( -
+
💡 Para encontrar este request nos logs do edge function, busque por: - + req_id={sample.requestId}
diff --git a/src/components/catalog/BulkVariantWizard.tsx b/src/components/catalog/BulkVariantWizard.tsx index d86fbbce9..193503454 100644 --- a/src/components/catalog/BulkVariantWizard.tsx +++ b/src/components/catalog/BulkVariantWizard.tsx @@ -8,9 +8,18 @@ import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; import { - Package, AlertTriangle, SkipForward, ShoppingBag, FileText, Heart, GitCompare, FolderPlus, FileDown, + Package, + AlertTriangle, + SkipForward, + ShoppingBag, + FileText, + Heart, + GitCompare, + FolderPlus, + FileDown, + type LucideIcon, } from 'lucide-react'; -import { useExternalVariantStock, type ExternalVariantStock, type Product } from "@/hooks/products"; +import { useExternalVariantStock, type ExternalVariantStock, type Product } from '@/hooks/products'; import { motion, AnimatePresence } from 'framer-motion'; export interface BulkVariantSelection { @@ -94,19 +103,22 @@ function ProductVariantStep({ className="space-y-3" > {/* Product info card */} -
+
{product.images?.[0] && ( {product.name} )} -
-

{product.name}

-

{product.sku}

+
+

{product.name}

+

{product.sku}

- + {fmt(totalStock)} @@ -115,15 +127,15 @@ function ProductVariantStep({ {/* Skip / add without color */} {/* Variant grid */} -
+
{sortedVariants.map((variant) => { const stock = variant.stock_quantity ?? 0; const isOutOfStock = stock === 0; @@ -134,10 +146,10 @@ function ProductVariantStep({ key={variant.id} onClick={() => onSelect(variant)} className={cn( - 'relative flex items-center gap-2.5 p-2.5 rounded-xl border transition-all text-left group', + 'group relative flex items-center gap-2.5 rounded-xl border p-2.5 text-left transition-all', 'hover:border-primary/50 hover:bg-accent/60 hover:shadow-sm', isOutOfStock - ? 'opacity-50 border-border/40 bg-muted/20' + ? 'border-border/40 bg-muted/20 opacity-50' : 'border-border/60 bg-card', )} > @@ -145,11 +157,11 @@ function ProductVariantStep({ {variant.color_name { const t = e.currentTarget; if (t.src.includes('/thumbnail')) { - t.src = variant.selected_thumbnail!; + t.src = variant.selected_thumbnail ?? ''; } else { t.style.display = 'none'; } @@ -157,25 +169,30 @@ function ProductVariantStep({ /> ) : (
)} -
-

+

+

{variant.color_name || 'Sem nome'} {variant.size_code && ( - — {variant.size_code} + — {variant.size_code} )}

-
+
{isOutOfStock ? ( - + Sem estoque ) : ( - + {fmt(stock)} un @@ -200,17 +217,17 @@ function ProductHeader({ total: number; }) { return ( -
+
{product.images?.[0] && ( {product.name} )} -
-

{product.name}

-

{product.sku}

+
+

{product.name}

+

{product.sku}

{step + 1}/{total} @@ -223,9 +240,9 @@ function ProductHeader({ function ProgressBar({ current, total }: { current: number; total: number }) { const pct = total > 0 ? (current / total) * 100 : 0; return ( -
+
([]); @@ -270,28 +293,63 @@ export function BulkVariantWizard({ open, onOpenChange, products, mode, onComple const currentProduct = products[currentIndex]; if (!currentProduct) return null; - const modeConfig: Record = { - cart: { icon: ShoppingBag, title: 'Adicionar ao Carrinho', colorClass: 'text-primary', bgClass: 'bg-primary/15' }, - quote: { icon: FileText, title: 'Enviar para Orçamento', colorClass: 'text-success', bgClass: 'bg-success/15' }, - favorite: { icon: Heart, title: 'Favoritar com Cor', colorClass: 'text-destructive', bgClass: 'bg-destructive/15' }, - compare: { icon: GitCompare, title: 'Comparar com Cor', colorClass: 'text-primary', bgClass: 'bg-primary/15' }, - collection: { icon: FolderPlus, title: 'Coleção com Cor', colorClass: 'text-info', bgClass: 'bg-info/15' }, - pdf: { icon: FileDown, title: 'Gerar Catálogo PDF', colorClass: 'text-orange-500', bgClass: 'bg-orange-500/15' }, + const modeConfig: Record< + BulkWizardMode, + { icon: LucideIcon; title: string; colorClass: string; bgClass: string } + > = { + cart: { + icon: ShoppingBag, + title: 'Adicionar ao Carrinho', + colorClass: 'text-primary', + bgClass: 'bg-primary/15', + }, + quote: { + icon: FileText, + title: 'Enviar para Orçamento', + colorClass: 'text-success', + bgClass: 'bg-success/15', + }, + favorite: { + icon: Heart, + title: 'Favoritar com Cor', + colorClass: 'text-destructive', + bgClass: 'bg-destructive/15', + }, + compare: { + icon: GitCompare, + title: 'Comparar com Cor', + colorClass: 'text-primary', + bgClass: 'bg-primary/15', + }, + collection: { + icon: FolderPlus, + title: 'Coleção com Cor', + colorClass: 'text-info', + bgClass: 'bg-info/15', + }, + pdf: { + icon: FileDown, + title: 'Gerar Catálogo PDF', + colorClass: 'text-orange-500', + bgClass: 'bg-orange-500/15', + }, }; const { icon: Icon, title, colorClass } = modeConfig[mode]; const bgClass = modeConfig[mode].bgClass; return ( - + {/* Header */} -
+
- -
+ +
{title} @@ -303,7 +361,7 @@ export function BulkVariantWizard({ open, onOpenChange, products, mode, onComple -

+

Escolha a cor/variação de cada produto. Clique em "Sem cor específica" para pular.

@@ -323,19 +381,22 @@ export function BulkVariantWizard({ open, onOpenChange, products, mode, onComple
{/* Bottom step indicator */} -
+
- Produto {currentIndex + 1} de {products.length} + Produto {currentIndex + 1} de{' '} + {products.length}
{products.map((_, i) => (
))} diff --git a/src/components/expert/ProductLinkRenderer.tsx b/src/components/expert/ProductLinkRenderer.tsx index 1e5602fee..a8a560805 100644 --- a/src/components/expert/ProductLinkRenderer.tsx +++ b/src/components/expert/ProductLinkRenderer.tsx @@ -3,38 +3,39 @@ * Converts product link syntax to markdown links before ReactMarkdown processes them, * then uses a custom component to render them as styled product cards with images. */ -import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Package, ChevronRight } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { getCdnUrl } from "@/utils/image-utils"; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Package, ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getCdnUrl } from '@/utils/image-utils'; // Matches [[PRODUTO:id:name]] and [[PRODUTO:id:name:imageUrl]] const PRODUCT_LINK_REGEX = /\[\[PRODUTO:([^:\]]+):([^:\]]+)(?::([^\]]+))?\]\]/g; -const PRODUCT_HREF_PREFIX = "produto://"; +const PRODUCT_HREF_PREFIX = 'produto://'; /** - * Pre-process markdown content: convert [[PRODUTO:id:nome:image?]] to + * Pre-process markdown content: convert [[PRODUTO:id:nome:image?]] to * standard markdown links with a special protocol encoding image data. */ export function preprocessProductLinks(content: string): string { - return content.replace( - PRODUCT_LINK_REGEX, - (_, id, name, imageUrl) => { - if (imageUrl) { - // Encode image URL in a fragment so it survives markdown parsing - return `[🔗 ${name}](${PRODUCT_HREF_PREFIX}${id}#img=${encodeURIComponent(imageUrl)})`; - } - return `[🔗 ${name}](${PRODUCT_HREF_PREFIX}${id})`; + return content.replace(PRODUCT_LINK_REGEX, (_, id, name, imageUrl) => { + if (imageUrl) { + // Encode image URL in a fragment so it survives markdown parsing + return `[🔗 ${name}](${PRODUCT_HREF_PREFIX}${id}#img=${encodeURIComponent(imageUrl)})`; } - ); + return `[🔗 ${name}](${PRODUCT_HREF_PREFIX}${id})`; + }); } /** * Custom component for ReactMarkdown that renders product links * as styled cards with images and navigation, while passing through regular links. */ -export function ProductAwareLink({ href, children, ...props }: React.AnchorHTMLAttributes & { children?: React.ReactNode }) { +export function ProductAwareLink({ + href, + children, + ...props +}: React.AnchorHTMLAttributes & { children?: React.ReactNode }) { const navigate = useNavigate(); const [imgError, setImgError] = useState(false); @@ -46,23 +47,30 @@ export function ProductAwareLink({ href, children, ...props }: React.AnchorHTMLA let productId: string; let imageUrl: string | null = null; - if (isProductProtocol) { - const urlPart = href!.slice(PRODUCT_HREF_PREFIX.length); - const [id, fragment] = urlPart.split("#"); + if (isProductProtocol && href) { + const urlPart = href.slice(PRODUCT_HREF_PREFIX.length); + const [id, fragment] = urlPart.split('#'); productId = id; - if (fragment?.startsWith("img=")) { - try { imageUrl = decodeURIComponent(fragment.slice(4)); } catch { /* ignore */ } + if (fragment?.startsWith('img=')) { + try { + imageUrl = decodeURIComponent(fragment.slice(4)); + } catch { + /* ignore */ + } } + } else if (isProductPath?.[1]) { + productId = isProductPath[1]; } else { - productId = isProductPath![1]; + productId = ''; } // Extract name from children (strip the 🔗 emoji) - const name = typeof children === "string" - ? children.replace(/^🔗\s*/, "") - : String(children || "").replace(/^🔗\s*/, ""); + const name = + typeof children === 'string' + ? children.replace(/^🔗\s*/, '') + : String(children || '').replace(/^🔗\s*/, ''); - const proxiedImage = imageUrl && !imgError ? getCdnUrl(imageUrl, "card") : null; + const proxiedImage = imageUrl && !imgError ? getCdnUrl(imageUrl, 'card') : null; return ( ); } // Regular link — render as normal anchor, but internal links stay in same tab - const isInternal = href?.startsWith("/"); + const isInternal = href?.startsWith('/'); if (isInternal) { return ( { e.preventDefault(); - navigate(href!); + if (href) navigate(href); }} {...props} > diff --git a/src/components/inventory/risk/ProductRiskDetail.tsx b/src/components/inventory/risk/ProductRiskDetail.tsx index c416e3573..b0f0e7542 100644 --- a/src/components/inventory/risk/ProductRiskDetail.tsx +++ b/src/components/inventory/risk/ProductRiskDetail.tsx @@ -2,7 +2,7 @@ * Detail panel for a single product's supplier risk analysis. * Extracted from SupplierRiskPanel for SRP compliance. */ -import { useMemo, useState } from "react"; +import { useMemo, useState } from 'react'; import { Maximize2, Minimize2, @@ -16,8 +16,8 @@ import { ExternalLink, AlertCircle, RefreshCw, -} from "lucide-react"; -import { useNavigate } from "react-router-dom"; +} from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import { ResponsiveContainer, Area, @@ -28,11 +28,11 @@ import { Bar, ComposedChart, Legend, -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { cn } from "@/lib/utils"; +} from 'recharts'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; import { useStockDailySummary, useStockVelocity, @@ -40,7 +40,7 @@ import { aggregateDailySummaryByDate, getActiveFlags, type IntelligenceFlag, -} from "@/hooks/intelligence"; +} from '@/hooks/intelligence'; import { safeVelocityTrend, safeNumber, @@ -53,17 +53,16 @@ import { OPERATIONAL_FLAG_CONFIG, safePriceChanges, type MockIntelligenceData, -} from "@/lib/stock-chart-utils"; -import { RiskKpi } from "./RiskKpi"; -import { RiskTooltip } from "./RiskTooltip"; +} from '@/lib/stock-chart-utils'; +import { RiskKpi } from './RiskKpi'; +import { RiskTooltip } from './RiskTooltip'; interface ProductRiskDetailProps { productId: string; productName?: string; - productSku?: string; } -export function ProductRiskDetail({ productId, productName, productSku }: ProductRiskDetailProps) { +export function ProductRiskDetail({ productId, productName }: ProductRiskDetailProps) { const navigate = useNavigate(); const [period, setPeriod] = useState('30'); const [chartExpanded, setChartExpanded] = useState(false); @@ -103,12 +102,23 @@ export function ProductRiskDetail({ productId, productName, productSku }: Produc // #10 fix: correct type for reduce — use union type instead of intersection const chartData = useMemo(() => { if (!hasData) return mockChartData; - const aggregated = aggregateDailySummaryByDate(summaries!); + const aggregated = aggregateDailySummaryByDate(summaries ?? []); const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); return aggregated - .filter(d => new Date(d.date) >= cutoff) - .reduce>((acc, d) => { + .filter((d) => new Date(d.date) >= cutoff) + .reduce< + Array<{ + date: string; + stockClose: number; + depleted: number; + restocked: number; + restockDetected: boolean; + costPriceClose: number | null; + dateFormatted: string; + fullDate: string; + }> + >((acc, d) => { const parsed = safeParseDateForChart(d.date); if (parsed) acc.push({ ...d, ...parsed }); return acc; @@ -119,9 +129,13 @@ export function ProductRiskDetail({ productId, productName, productSku }: Produc const effectiveIntelligence = intelligence ?? (isDemo ? mockIntel : null); const bestVelocity = velocity?.length - ? velocity.reduce((best, v) => - (v.avg_daily_depletion_7d > (best?.avg_daily_depletion_7d ?? 0)) ? v : best, velocity[0]) - : (isDemo ? mockVelocity : null); + ? velocity.reduce( + (best, v) => (v.avg_daily_depletion_7d > (best?.avg_daily_depletion_7d ?? 0) ? v : best), + velocity[0], + ) + : isDemo + ? mockVelocity + : null; // #9 fix: derive flags from mock data too (was returning [] for non-real) const flags = useMemo(() => { @@ -143,7 +157,11 @@ export function ProductRiskDetail({ productId, productName, productSku }: Produc if (isLoading) { return ( -
+
); @@ -151,13 +169,13 @@ export function ProductRiskDetail({ productId, productName, productSku }: Produc if (hasError && !hasData) { return ( -
+

Erro ao carregar dados

-

+

Não foi possível buscar o histórico deste produto. Tente novamente em alguns instantes.

- @@ -167,7 +185,8 @@ export function ProductRiskDetail({ productId, productName, productSku }: Produc const daysToStockout = bestVelocity?.days_to_stockout; const isUrgent = daysToStockout !== null && Number.isFinite(daysToStockout) && daysToStockout < 7; - const isWarning = daysToStockout !== null && Number.isFinite(daysToStockout) && daysToStockout < 15; + const isWarning = + daysToStockout !== null && Number.isFinite(daysToStockout) && daysToStockout < 15; const trend = safeVelocityTrend(bestVelocity?.velocity_trend); const trendDisplay = formatVelocityTrendOperational(trend); @@ -178,19 +197,32 @@ export function ProductRiskDetail({ productId, productName, productSku }: Produc return (
{/* Header */} -
-
-

{productName || productId}

- {isDemo && demo} - {hasError && hasData && parcial} +
+
+

{productName || productId}

+ {isDemo && ( + + demo + + )} + {hasError && hasData && ( + + parcial + + )} {effectiveIntelligence?.abc_classification && ( Classe {effectiveIntelligence.abc_classification} @@ -200,7 +232,7 @@ export function ProductRiskDetail({ productId, productName, productSku }: Produc
-
+
- - + + } /> - {value}} /> - - - + ( + {value} + )} + /> + + +
diff --git a/src/components/simulator/NicheRecommendationBadge.tsx b/src/components/simulator/NicheRecommendationBadge.tsx index ac7404e1f..4ec57dc95 100644 --- a/src/components/simulator/NicheRecommendationBadge.tsx +++ b/src/components/simulator/NicheRecommendationBadge.tsx @@ -1,70 +1,70 @@ // src/components/simulator/NicheRecommendationBadge.tsx // Melhoria #3: Recomendação inteligente por nicho do cliente -import { useMemo } from "react"; -import { Badge } from "@/components/ui/badge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { Sparkles, Target, TrendingUp } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { useMemo } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Sparkles, Target } from 'lucide-react'; +import { cn } from '@/lib/utils'; // Mapeamento de nichos/ramos para técnicas recomendadas const NICHE_TECHNIQUE_MAP: Record = { // Tecnologia - 'tecnologia': { techniques: ['LASER', 'UV', 'DTF'], reason: 'Acabamento premium e moderno' }, - 'tech': { techniques: ['LASER', 'UV', 'DTF'], reason: 'Acabamento premium e moderno' }, - 'software': { techniques: ['LASER', 'UV'], reason: 'Elegância e durabilidade' }, - 'ti': { techniques: ['LASER', 'UV', 'DTF'], reason: 'Visual tecnológico' }, - + tecnologia: { techniques: ['LASER', 'UV', 'DTF'], reason: 'Acabamento premium e moderno' }, + tech: { techniques: ['LASER', 'UV', 'DTF'], reason: 'Acabamento premium e moderno' }, + software: { techniques: ['LASER', 'UV'], reason: 'Elegância e durabilidade' }, + ti: { techniques: ['LASER', 'UV', 'DTF'], reason: 'Visual tecnológico' }, + // Saúde - 'saude': { techniques: ['BORD', 'SUB'], reason: 'Durável e higiênico' }, - 'saúde': { techniques: ['BORD', 'SUB'], reason: 'Durável e higiênico' }, - 'hospital': { techniques: ['BORD', 'SUB'], reason: 'Resistente a lavagens' }, - 'clinica': { techniques: ['BORD', 'SUB', 'LASER'], reason: 'Profissional e durável' }, - 'clínica': { techniques: ['BORD', 'SUB', 'LASER'], reason: 'Profissional e durável' }, - + saude: { techniques: ['BORD', 'SUB'], reason: 'Durável e higiênico' }, + saúde: { techniques: ['BORD', 'SUB'], reason: 'Durável e higiênico' }, + hospital: { techniques: ['BORD', 'SUB'], reason: 'Resistente a lavagens' }, + clinica: { techniques: ['BORD', 'SUB', 'LASER'], reason: 'Profissional e durável' }, + clínica: { techniques: ['BORD', 'SUB', 'LASER'], reason: 'Profissional e durável' }, + // Educação - 'educacao': { techniques: ['SILK', 'SERIGRAFIA', 'DTF'], reason: 'Custo-benefício em volume' }, - 'educação': { techniques: ['SILK', 'SERIGRAFIA', 'DTF'], reason: 'Custo-benefício em volume' }, - 'escola': { techniques: ['SILK', 'SERIGRAFIA'], reason: 'Econômico para grandes quantidades' }, - 'universidade': { techniques: ['SILK', 'BORD', 'DTF'], reason: 'Institucional e durável' }, - + educacao: { techniques: ['SILK', 'SERIGRAFIA', 'DTF'], reason: 'Custo-benefício em volume' }, + educação: { techniques: ['SILK', 'SERIGRAFIA', 'DTF'], reason: 'Custo-benefício em volume' }, + escola: { techniques: ['SILK', 'SERIGRAFIA'], reason: 'Econômico para grandes quantidades' }, + universidade: { techniques: ['SILK', 'BORD', 'DTF'], reason: 'Institucional e durável' }, + // Alimentício - 'alimenticio': { techniques: ['UV', 'LASER', 'SUB'], reason: 'Seguro para alimentos' }, - 'alimentício': { techniques: ['UV', 'LASER', 'SUB'], reason: 'Seguro para alimentos' }, - 'restaurante': { techniques: ['BORD', 'SILK'], reason: 'Resistente a lavagens frequentes' }, - 'gastronomia': { techniques: ['LASER', 'BORD'], reason: 'Elegante e profissional' }, - + alimenticio: { techniques: ['UV', 'LASER', 'SUB'], reason: 'Seguro para alimentos' }, + alimentício: { techniques: ['UV', 'LASER', 'SUB'], reason: 'Seguro para alimentos' }, + restaurante: { techniques: ['BORD', 'SILK'], reason: 'Resistente a lavagens frequentes' }, + gastronomia: { techniques: ['LASER', 'BORD'], reason: 'Elegante e profissional' }, + // Construção - 'construcao': { techniques: ['SILK', 'TRANSFER'], reason: 'Resistente e visível' }, - 'construção': { techniques: ['SILK', 'TRANSFER'], reason: 'Resistente e visível' }, - 'imobiliaria': { techniques: ['LASER', 'UV', 'BORD'], reason: 'Sofisticado e premium' }, - 'imobiliária': { techniques: ['LASER', 'UV', 'BORD'], reason: 'Sofisticado e premium' }, - + construcao: { techniques: ['SILK', 'TRANSFER'], reason: 'Resistente e visível' }, + construção: { techniques: ['SILK', 'TRANSFER'], reason: 'Resistente e visível' }, + imobiliaria: { techniques: ['LASER', 'UV', 'BORD'], reason: 'Sofisticado e premium' }, + imobiliária: { techniques: ['LASER', 'UV', 'BORD'], reason: 'Sofisticado e premium' }, + // Varejo - 'varejo': { techniques: ['SILK', 'DTF', 'TRANSFER'], reason: 'Volume e versatilidade' }, - 'comercio': { techniques: ['SILK', 'DTF'], reason: 'Custo-benefício' }, - 'comércio': { techniques: ['SILK', 'DTF'], reason: 'Custo-benefício' }, - 'loja': { techniques: ['SILK', 'DTF', 'SUB'], reason: 'Visual atrativo' }, - + varejo: { techniques: ['SILK', 'DTF', 'TRANSFER'], reason: 'Volume e versatilidade' }, + comercio: { techniques: ['SILK', 'DTF'], reason: 'Custo-benefício' }, + comércio: { techniques: ['SILK', 'DTF'], reason: 'Custo-benefício' }, + loja: { techniques: ['SILK', 'DTF', 'SUB'], reason: 'Visual atrativo' }, + // Esportivo - 'esporte': { techniques: ['SUB', 'DTF', 'TRANSFER'], reason: 'Cores vibrantes e flexível' }, - 'academia': { techniques: ['SUB', 'DTF'], reason: 'Resistente ao suor' }, - 'fitness': { techniques: ['SUB', 'DTF', 'TRANSFER'], reason: 'Secagem rápida' }, - + esporte: { techniques: ['SUB', 'DTF', 'TRANSFER'], reason: 'Cores vibrantes e flexível' }, + academia: { techniques: ['SUB', 'DTF'], reason: 'Resistente ao suor' }, + fitness: { techniques: ['SUB', 'DTF', 'TRANSFER'], reason: 'Secagem rápida' }, + // Automotivo - 'automotivo': { techniques: ['LASER', 'UV', 'GRAVACAO'], reason: 'Resistente e duradouro' }, - 'automoveis': { techniques: ['LASER', 'UV'], reason: 'Acabamento premium' }, - + automotivo: { techniques: ['LASER', 'UV', 'GRAVACAO'], reason: 'Resistente e duradouro' }, + automoveis: { techniques: ['LASER', 'UV'], reason: 'Acabamento premium' }, + // Eventos - 'eventos': { techniques: ['DTF', 'SUB', 'SILK'], reason: 'Entrega rápida e cores vivas' }, - 'marketing': { techniques: ['DTF', 'SUB', 'SILK'], reason: 'Impactante e versátil' }, - 'publicidade': { techniques: ['DTF', 'SUB', 'UV'], reason: 'Cores vibrantes' }, - + eventos: { techniques: ['DTF', 'SUB', 'SILK'], reason: 'Entrega rápida e cores vivas' }, + marketing: { techniques: ['DTF', 'SUB', 'SILK'], reason: 'Impactante e versátil' }, + publicidade: { techniques: ['DTF', 'SUB', 'UV'], reason: 'Cores vibrantes' }, + // Corporativo - 'corporativo': { techniques: ['BORD', 'LASER', 'UV'], reason: 'Elegante e profissional' }, - 'empresarial': { techniques: ['BORD', 'LASER'], reason: 'Institucional e durável' }, - 'escritorio': { techniques: ['LASER', 'UV', 'TAMPOGRAFIA'], reason: 'Sofisticado' }, - 'escritório': { techniques: ['LASER', 'UV', 'TAMPOGRAFIA'], reason: 'Sofisticado' }, + corporativo: { techniques: ['BORD', 'LASER', 'UV'], reason: 'Elegante e profissional' }, + empresarial: { techniques: ['BORD', 'LASER'], reason: 'Institucional e durável' }, + escritorio: { techniques: ['LASER', 'UV', 'TAMPOGRAFIA'], reason: 'Sofisticado' }, + escritório: { techniques: ['LASER', 'UV', 'TAMPOGRAFIA'], reason: 'Sofisticado' }, }; interface NicheRecommendationBadgeProps { @@ -82,16 +82,16 @@ export function NicheRecommendationBadge({ }: NicheRecommendationBadgeProps) { const recommendation = useMemo(() => { if (!clientRamo && !clientNicho) return null; - + const searchTerms = [clientRamo, clientNicho] - .filter(Boolean) - .map(t => t!.toLowerCase()); - + .filter((t): t is string => Boolean(t)) + .map((t) => t.toLowerCase()); + for (const term of searchTerms) { for (const [key, value] of Object.entries(NICHE_TECHNIQUE_MAP)) { if (term.includes(key) || key.includes(term)) { - const isRecommended = value.techniques.some( - t => techniqueCode.toUpperCase().includes(t) + const isRecommended = value.techniques.some((t) => + techniqueCode.toUpperCase().includes(t), ); if (isRecommended) { return { @@ -103,7 +103,7 @@ export function NicheRecommendationBadge({ } } } - + return null; }, [techniqueCode, clientRamo, clientNicho]); @@ -113,11 +113,11 @@ export function NicheRecommendationBadge({ - @@ -126,13 +126,11 @@ export function NicheRecommendationBadge({
-

+

Ideal para o nicho do cliente

-

- {recommendation.reason} -

+

{recommendation.reason}

@@ -143,7 +141,7 @@ export function NicheRecommendationBadge({ // Hook para obter recomendações por nicho export function useNicheRecommendations( clientRamo: string | null | undefined, - clientNicho: string | null | undefined + clientNicho: string | null | undefined, ) { return useMemo(() => { if (!clientRamo && !clientNicho) { @@ -153,14 +151,14 @@ export function useNicheRecommendations( reason: null as string | null, }; } - + const searchTerms = [clientRamo, clientNicho] - .filter(Boolean) - .map(t => t!.toLowerCase()); - + .filter((t): t is string => Boolean(t)) + .map((t) => t.toLowerCase()); + const allRecommendedCodes: string[] = []; let reason: string | null = null; - + for (const term of searchTerms) { for (const [key, value] of Object.entries(NICHE_TECHNIQUE_MAP)) { if (term.includes(key) || key.includes(term)) { @@ -169,7 +167,7 @@ export function useNicheRecommendations( } } } - + return { hasRecommendations: allRecommendedCodes.length > 0, recommendedCodes: [...new Set(allRecommendedCodes)], diff --git a/src/hooks/quotes/useProdutoPersonalizacao.ts b/src/hooks/quotes/useProdutoPersonalizacao.ts index 9062a2a7b..64e1ccf0d 100644 --- a/src/hooks/quotes/useProdutoPersonalizacao.ts +++ b/src/hooks/quotes/useProdutoPersonalizacao.ts @@ -2,8 +2,8 @@ * useProdutoPersonalizacao — busca regras de personalização de um produto local * (componentes + locations já modeladas em product_components / product_component_locations). */ -import { useQuery } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; export interface ComponentLocation { id: string; @@ -27,26 +27,27 @@ export interface ComponentWithLocations { export function useProdutoPersonalizacao(productId: string | null | undefined) { return useQuery({ - queryKey: ["produto-personalizacao", productId], + queryKey: ['produto-personalizacao', productId], enabled: !!productId, queryFn: async (): Promise => { + if (!productId) return []; const { data: components, error: cErr } = await supabase - .from("product_components") - .select("id, component_code, component_name, is_personalizable, sort_order") - .eq("product_id", productId!) - .eq("is_active", true) - .order("sort_order", { ascending: true }); + .from('product_components') + .select('id, component_code, component_name, is_personalizable, sort_order') + .eq('product_id', productId) + .eq('is_active', true) + .order('sort_order', { ascending: true }); if (cErr) throw cErr; if (!components?.length) return []; const componentIds = components.map((c) => c.id); const { data: locations, error: lErr } = await supabase - .from("product_component_locations") - .select("*") - .in("component_id", componentIds) - .eq("is_active", true) - .order("sort_order", { ascending: true }); + .from('product_component_locations') + .select('*') + .in('component_id', componentIds) + .eq('is_active', true) + .order('sort_order', { ascending: true }); if (lErr) throw lErr; diff --git a/src/pages/admin/AdminProductFormPage.tsx b/src/pages/admin/AdminProductFormPage.tsx index e0611cf11..4d678bfa1 100644 --- a/src/pages/admin/AdminProductFormPage.tsx +++ b/src/pages/admin/AdminProductFormPage.tsx @@ -67,10 +67,10 @@ export default function AdminProductFormPage() { if (!isEdit) return; const loadProduct = async () => { + if (!id) return; setIsLoading(true); try { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const fullProduct = await fetchPromobrindProductById(id!); + const fullProduct = await fetchPromobrindProductById(id); if (fullProduct) { setProduct(fullProduct); } else { @@ -378,163 +378,163 @@ export default function AdminProductFormPage() { if (isLoading) { return ( - <> - -
-
- -

Carregando produto...

-
-
- - ); - } - - return ( <> -
-

- {isEdit ? 'Editar Produto' : 'Novo Produto'} -

- {/* Breadcrumbs are rendered by MainLayout's PersistentBreadcrumbs */} +
+
+ +

Carregando produto...

+
+
+ + ); + } - {/* Header */} - {isEdit && ( -
-
- - {isEdit && product && ( -
-

- {product.sku} — {product.name} -

-
- )} -
+ return ( + <> + +
+

+ {isEdit ? 'Editar Produto' : 'Novo Produto'} +

+ {/* Breadcrumbs are rendered by MainLayout's PersistentBreadcrumbs */} -
- {isEdit && product && ( - <> - - - - )} + {/* Header */} + {isEdit && ( +
+
+ + {isEdit && product && ( +
+

+ {product.sku} — {product.name} +

+
+ )} +
- {isEdit && ( - setActiveTab(v as 'form' | 'history')} +
+ {isEdit && product && ( + <> + +
+ + Duplicar + + + )} + + {isEdit && ( + setActiveTab(v as 'form' | 'history')} + > + + + + Editar + + + + Histórico + + + + )}
- )} +
+ )} - {/* Content */} - - -
- } - > - {activeTab === 'form' ? ( - navigate('/admin/cadastros')} - isSaving={isSaving} - isEdit={isEdit} + {/* Content */} + + +
+ } + > + {activeTab === 'form' ? ( + navigate('/admin/cadastros')} + isSaving={isSaving} + isEdit={isEdit} + /> + ) : ( + isEdit && + id && ( + - ) : ( - isEdit && - id && ( - - ) - )} - -
- + ) + )} + +
+ ); } diff --git a/src/pages/admin/RlsDenialsAdminPage.tsx b/src/pages/admin/RlsDenialsAdminPage.tsx index f4dec52f4..3299fcbf1 100644 --- a/src/pages/admin/RlsDenialsAdminPage.tsx +++ b/src/pages/admin/RlsDenialsAdminPage.tsx @@ -5,23 +5,25 @@ * resumo (top vendedores, top tabelas) e alerta visual quando o volume excede * threshold (>= 10 negações nas últimas 24h). */ -import { useMemo, useState } from "react"; -import { Link } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; -import { PageSEO } from "@/components/seo/PageSEO"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { PageSEO } from '@/components/seo/PageSEO'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select"; -import { - ShieldAlert, ArrowLeft, AlertTriangle, RefreshCw, Filter, -} from "lucide-react"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ShieldAlert, ArrowLeft, AlertTriangle, RefreshCw, Filter } from 'lucide-react'; const ALERT_THRESHOLD_24H = 10; @@ -31,7 +33,7 @@ interface DenialRow { user_email: string | null; user_role: string | null; table_name: string; - operation: "SELECT" | "INSERT" | "UPDATE" | "DELETE"; + operation: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE'; endpoint: string | null; query_summary: string | null; target_id: string | null; @@ -43,24 +45,24 @@ interface DenialRow { } export default function RlsDenialsAdminPage() { - const [tableFilter, setTableFilter] = useState("all"); - const [opFilter, setOpFilter] = useState("all"); - const [emailFilter, setEmailFilter] = useState(""); - const [windowHours, setWindowHours] = useState("168"); // 7 dias + const [tableFilter, setTableFilter] = useState('all'); + const [opFilter, setOpFilter] = useState('all'); + const [emailFilter, setEmailFilter] = useState(''); + const [windowHours, setWindowHours] = useState('168'); // 7 dias const { data, isLoading, refetch, isFetching } = useQuery({ - queryKey: ["rls-denials", tableFilter, opFilter, emailFilter, windowHours], + queryKey: ['rls-denials', tableFilter, opFilter, emailFilter, windowHours], queryFn: async (): Promise => { const since = new Date(Date.now() - Number(windowHours) * 3600 * 1000).toISOString(); let q = supabase - .from("rls_denial_log") - .select("*") - .gte("created_at", since) - .order("created_at", { ascending: false }) + .from('rls_denial_log') + .select('*') + .gte('created_at', since) + .order('created_at', { ascending: false }) .limit(500); - if (tableFilter !== "all") q = q.eq("table_name", tableFilter); - if (opFilter !== "all") q = q.eq("operation", opFilter); - if (emailFilter.trim()) q = q.ilike("user_email", `%${emailFilter.trim()}%`); + if (tableFilter !== 'all') q = q.eq('table_name', tableFilter); + if (opFilter !== 'all') q = q.eq('operation', opFilter); + if (emailFilter.trim()) q = q.ilike('user_email', `%${emailFilter.trim()}%`); const { data, error } = await q; if (error) throw error; return (data ?? []) as DenialRow[]; @@ -70,16 +72,15 @@ export default function RlsDenialsAdminPage() { const stats = useMemo(() => { const rows = data ?? []; - const last24h = rows.filter( - (r) => Date.parse(r.created_at) > Date.now() - 24 * 3600 * 1000 - ); + const last24h = rows.filter((r) => Date.parse(r.created_at) > Date.now() - 24 * 3600 * 1000); const byTable = new Map(); const byUser = new Map(); const byPolicy = new Map(); rows.forEach((r) => { byTable.set(r.table_name, (byTable.get(r.table_name) ?? 0) + 1); const u = byUser.get(r.user_id) ?? { email: r.user_email, count: 0 }; - u.count++; u.email = r.user_email ?? u.email; + u.count++; + u.email = r.user_email ?? u.email; byUser.set(r.user_id, u); if (r.policy_hint) byPolicy.set(r.policy_hint, (byPolicy.get(r.policy_hint) ?? 0) + 1); }); @@ -101,245 +102,311 @@ export default function RlsDenialsAdminPage() { const alertActive = stats.last24h >= ALERT_THRESHOLD_24H; return ( - <> - -
-
-
-
- -
-
-

Acessos negados (RLS)

-

- Toda vez que uma política bloqueia uma operação, o evento é registrado aqui. -

-
+ <> + +
+
+
+
+
-
- - +
+

+ Acessos negados (RLS) +

+

+ Toda vez que uma política bloqueia uma operação, o evento é registrado aqui. +

+
+ + +
+
- {alertActive && ( - - - Volume anormal de negações nas últimas 24h - - {stats.last24h} eventos registrados (limiar: {ALERT_THRESHOLD_24H}). Investigue os usuários e tabelas - listados abaixo — pode indicar bug de UI, escalonamento de privilégio ou tentativa maliciosa. - - - )} + {alertActive && ( + + + Volume anormal de negações nas últimas 24h + + {stats.last24h} eventos registrados (limiar: {ALERT_THRESHOLD_24H}). Investigue os + usuários e tabelas listados abaixo — pode indicar bug de UI, escalonamento de + privilégio ou tentativa maliciosa. + + + )} - {/* KPIs */} -
- + {/* KPIs */} +
+ +

Total na janela

{stats.total}

-
- + + + +

Últimas 24h

-

{stats.last24h}

-
- +

+ {stats.last24h} +

+
+
+ +

Tabelas distintas

{stats.topTables.length}

-
- + + + +

Usuários distintos

{stats.topUsers.length}

-
-
+
+
+
+ + {/* Filtros */} + + + + Filtros + + + +
+ + +
+
+ + +
+
+ + +
+
+ + setEmailFilter(e.target.value)} + /> +
+
+
- {/* Filtros */} +
+ {/* Top users */} - - - Filtros - + + Top vendedores negados - -
- - -
-
- - -
-
- - -
-
- - setEmailFilter(e.target.value)} - /> -
+ + {stats.topUsers.length === 0 ? ( +

Sem eventos.

+ ) : ( +
    + {stats.topUsers.map(([uid, v]) => ( +
  • + + {v.email ?? uid.slice(0, 8) + '…'} + + {v.count} +
  • + ))} +
+ )}
-
- {/* Top users */} - - Top vendedores negados - - {stats.topUsers.length === 0 ? ( -

Sem eventos.

- ) : ( -
    - {stats.topUsers.map(([uid, v]) => ( -
  • - {v.email ?? uid.slice(0, 8) + "…"} - {v.count} -
  • - ))} -
- )} -
-
- - {/* Top tables */} - - Top tabelas - - {stats.topTables.length === 0 ? ( -

Sem eventos.

- ) : ( -
    - {stats.topTables.map(([t, n]) => ( -
  • - {t}{n} -
  • - ))} -
- )} -
-
- - {/* Top policies */} - - Políticas mais acionadas - - {stats.topPolicies.length === 0 ? ( -

Sem dica de política capturada.

- ) : ( -
    - {stats.topPolicies.map(([p, n]) => ( -
  • - {p}{n} -
  • - ))} -
- )} -
-
-
+ {/* Top tables */} + + + Top tabelas + + + {stats.topTables.length === 0 ? ( +

Sem eventos.

+ ) : ( +
    + {stats.topTables.map(([t, n]) => ( +
  • + {t} + {n} +
  • + ))} +
+ )} +
+
- {/* Lista detalhada */} + {/* Top policies */} - Eventos ({data?.length ?? 0}) - Ordenados do mais recente para o mais antigo. Limite de 500. + Políticas mais acionadas - {isLoading ? ( -
{[0, 1, 2, 3].map((i) => )}
- ) : (data?.length ?? 0) === 0 ? ( -

- Nenhuma negação registrada nesta janela. ✅ -

+ {stats.topPolicies.length === 0 ? ( +

Sem dica de política capturada.

) : ( -
- - - - - - - - - - - - - - {data!.map((r) => ( - - - - - - - - - - ))} - -
QuandoUsuárioTabelaOpEndpointPolíticaDetalhe
- {new Date(r.created_at).toLocaleString()} - -
- {r.user_email ?? r.user_id.slice(0, 8)} -
- {r.user_role && {r.user_role}} -
{r.table_name} - {r.operation} - - {r.endpoint ?? "—"} - {r.policy_hint ?? "—"} - {r.query_summary &&
{r.query_summary}
} - {r.target_id && ( -
alvo: {r.target_id.slice(0, 8)}…
- )} - {r.error_message && ( -
- {r.error_message} -
- )} -
-
+
    + {stats.topPolicies.map(([p, n]) => ( +
  • + {p} + {n} +
  • + ))} +
)}
- + + {/* Lista detalhada */} + + + Eventos ({data?.length ?? 0}) + + Ordenados do mais recente para o mais antigo. Limite de 500. + + + + {isLoading ? ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ) : (data?.length ?? 0) === 0 ? ( +

+ Nenhuma negação registrada nesta janela. ✅ +

+ ) : ( +
+ + + + + + + + + + + + + + {(data ?? []).map((r) => ( + + + + + + + + + + ))} + +
QuandoUsuárioTabelaOpEndpointPolíticaDetalhe
+ {new Date(r.created_at).toLocaleString()} + +
+ {r.user_email ?? r.user_id.slice(0, 8)} +
+ {r.user_role && ( + + {r.user_role} + + )} +
+ {r.table_name} + + + {r.operation} + + + {r.endpoint ?? '—'} + + {r.policy_hint ?? '—'} + + {r.query_summary && ( +
+ {r.query_summary} +
+ )} + {r.target_id && ( +
+ alvo: {r.target_id.slice(0, 8)}… +
+ )} + {r.error_message && ( +
+ {r.error_message} +
+ )} +
+
+ )} +
+
+
+ ); } diff --git a/src/pages/admin/SellerDiscountLimitsAdminPage.tsx b/src/pages/admin/SellerDiscountLimitsAdminPage.tsx index f35aaa7d3..7609192bc 100644 --- a/src/pages/admin/SellerDiscountLimitsAdminPage.tsx +++ b/src/pages/admin/SellerDiscountLimitsAdminPage.tsx @@ -7,22 +7,30 @@ * • Tabela de vendedores: limite editável, notas, métricas de impacto (pendentes/aprovadas/negadas) * • Painel de requisições recentes que excederam o limite */ -import { useMemo, useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Link } from "react-router-dom"; -import { PageSEO } from "@/components/seo/PageSEO"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { useMemo, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { PageSEO } from '@/components/seo/PageSEO'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { - Percent, Save, ShieldAlert, Info, ArrowLeft, TrendingUp, Clock, CheckCircle2, XCircle, -} from "lucide-react"; -import { supabase } from "@/integrations/supabase/client"; -import { toast } from "sonner"; + Percent, + Save, + ShieldAlert, + Info, + ArrowLeft, + TrendingUp, + Clock, + CheckCircle2, + XCircle, +} from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; +import { toast } from 'sonner'; const DEFAULT_LIMIT = 5; @@ -60,29 +68,29 @@ export default function SellerDiscountLimitsAdminPage() { // ---------- Vendedores + limites ---------- const { data: sellers, isLoading: loadingSellers } = useQuery({ - queryKey: ["admin-seller-discount-limits"], + queryKey: ['admin-seller-discount-limits'], queryFn: async (): Promise => { const { data: profiles, error: pErr } = await supabase - .from("profiles") - .select("user_id, full_name, email, role, is_active") - .eq("role", "vendedor") - .eq("is_active", true) - .order("full_name"); + .from('profiles') + .select('user_id, full_name, email, role, is_active') + .eq('role', 'vendedor') + .eq('is_active', true) + .order('full_name'); if (pErr) throw pErr; const ids = (profiles ?? []).map((p) => p.user_id); if (!ids.length) return []; const { data: limits } = await supabase - .from("seller_discount_limits") - .select("user_id, max_discount_percent, notes") - .in("user_id", ids); + .from('seller_discount_limits') + .select('user_id, max_discount_percent, notes') + .in('user_id', ids); const byId = new Map( (limits ?? []).map((l) => [ l.user_id, { pct: Number(l.max_discount_percent), notes: l.notes ?? null }, - ]) + ]), ); return (profiles ?? []).map((p) => { @@ -101,23 +109,27 @@ export default function SellerDiscountLimitsAdminPage() { // ---------- Impacto: agregados de discount_approval_requests ---------- const { data: impact } = useQuery({ - queryKey: ["admin-discount-impact"], + queryKey: ['admin-discount-impact'], queryFn: async (): Promise> => { const { data, error } = await supabase // rls-allow: admin-only; RLS filtra - .from("discount_approval_requests") - .select("seller_id, status, requested_discount_percent"); + .from('discount_approval_requests') + .select('seller_id, status, requested_discount_percent'); if (error) throw error; const map = new Map(); for (const r of data ?? []) { const cur = map.get(r.seller_id) ?? { - seller_id: r.seller_id, pending: 0, approved: 0, rejected: 0, - avg_requested: 0, max_requested: 0, + seller_id: r.seller_id, + pending: 0, + approved: 0, + rejected: 0, + avg_requested: 0, + max_requested: 0, }; - if (r.status === "pending") cur.pending++; - else if (r.status === "approved") cur.approved++; - else if (r.status === "rejected") cur.rejected++; + if (r.status === 'pending') cur.pending++; + else if (r.status === 'approved') cur.approved++; + else if (r.status === 'rejected') cur.rejected++; const pct = Number(r.requested_discount_percent); cur.avg_requested += pct; cur.max_requested = Math.max(cur.max_requested, pct); @@ -134,292 +146,375 @@ export default function SellerDiscountLimitsAdminPage() { // ---------- Requisições recentes que excederam limite ---------- const { data: exceeded } = useQuery({ - queryKey: ["admin-discount-exceeded"], + queryKey: ['admin-discount-exceeded'], queryFn: async (): Promise => { const { data, error } = await supabase // rls-allow: admin-only; RLS filtra - .from("discount_approval_requests") - .select("id, seller_id, requested_discount_percent, max_allowed_percent, status, created_at, quote_id") - .order("created_at", { ascending: false }) + .from('discount_approval_requests') + .select( + 'id, seller_id, requested_discount_percent, max_allowed_percent, status, created_at, quote_id', + ) + .order('created_at', { ascending: false }) .limit(20); if (error) throw error; return (data ?? []).filter( - (r) => Number(r.requested_discount_percent) > Number(r.max_allowed_percent) + (r) => Number(r.requested_discount_percent) > Number(r.max_allowed_percent), ); }, }); // ---------- Mutation: salvar limite + notas ---------- const save = useMutation({ - mutationFn: async ({ userId, percent, notes }: { userId: string; percent: number; notes: string }) => { + mutationFn: async ({ + userId, + percent, + notes, + }: { + userId: string; + percent: number; + notes: string; + }) => { const { data: u } = await supabase.auth.getUser(); - if (!u.user) throw new Error("Não autenticado"); + if (!u.user) throw new Error('Não autenticado'); const { error } = await supabase - .from("seller_discount_limits") + .from('seller_discount_limits') .upsert( - { user_id: userId, max_discount_percent: percent, notes: notes || null, set_by: u.user.id }, - { onConflict: "user_id" } + { + user_id: userId, + max_discount_percent: percent, + notes: notes || null, + set_by: u.user.id, + }, + { onConflict: 'user_id' }, ); if (error) throw error; }, onSuccess: (_d, vars) => { - toast.success("Limite atualizado"); + toast.success('Limite atualizado'); setEdits((prev) => { const { [vars.userId]: _, ...rest } = prev; return rest; }); - qc.invalidateQueries({ queryKey: ["admin-seller-discount-limits"] }); - qc.invalidateQueries({ queryKey: ["admin-discount-impact"] }); + qc.invalidateQueries({ queryKey: ['admin-seller-discount-limits'] }); + qc.invalidateQueries({ queryKey: ['admin-discount-impact'] }); }, onError: (e: Error) => toast.error(e.message), }); const sellerNameById = useMemo(() => { const m = new Map(); - (sellers ?? []).forEach((s) => m.set(s.user_id, s.full_name ?? s.email ?? s.user_id.slice(0, 8))); + (sellers ?? []).forEach((s) => + m.set(s.user_id, s.full_name ?? s.email ?? s.user_id.slice(0, 8)), + ); return m; }, [sellers]); const totals = useMemo(() => { - let pending = 0, approved = 0, rejected = 0; - impact?.forEach((v) => { pending += v.pending; approved += v.approved; rejected += v.rejected; }); + let pending = 0, + approved = 0, + rejected = 0; + impact?.forEach((v) => { + pending += v.pending; + approved += v.approved; + rejected += v.rejected; + }); return { pending, approved, rejected, total: pending + approved + rejected }; }, [impact]); return ( - <> - -
- {/* Header */} -
-
-
- -
-
-

Limites de desconto

-

- Defina o teto de desconto por vendedor — solicitações acima do limite exigem aprovação. -

-
+ <> + +
+ {/* Header */} +
+
+
+ +
+
+

+ Limites de desconto +

+

+ Defina o teto de desconto por vendedor — solicitações acima do limite exigem + aprovação. +

-
+ +
- {/* Regras gerais */} - - - Como o limite afeta as requisições - -

Desconto ≤ limite: aplicado direto no orçamento, sem aprovação.

-

Desconto > limite: cria uma discount_approval_request pendente que precisa ser aprovada por supervisor/admin.

-

Sem limite definido: o vendedor herda o padrão global de {DEFAULT_LIMIT}%.

-

• Alterações de limite valem apenas para novas solicitações; pendentes existentes mantêm o teto registrado no momento da criação.

-
-
+ {/* Regras gerais */} + + + Como o limite afeta as requisições + +

+ • Desconto ≤ limite: aplicado direto no orçamento, sem aprovação. +

+

+ • Desconto > limite: cria uma discount_approval_request{' '} + pendente que precisa ser aprovada por supervisor/admin. +

+

+ • Sem limite definido: o vendedor herda o padrão global de{' '} + {DEFAULT_LIMIT}%. +

+

+ • Alterações de limite valem apenas para novas solicitações; pendentes + existentes mantêm o teto registrado no momento da criação. +

+
+
- {/* KPIs */} -
- } label="Pendentes" value={totals.pending} tone="warning" /> - } label="Aprovadas" value={totals.approved} tone="success" /> - } label="Recusadas" value={totals.rejected} tone="danger" /> - } label="Total histórico" value={totals.total} /> -
+ {/* KPIs */} +
+ } + label="Pendentes" + value={totals.pending} + tone="warning" + /> + } + label="Aprovadas" + value={totals.approved} + tone="success" + /> + } + label="Recusadas" + value={totals.rejected} + tone="danger" + /> + } + label="Total histórico" + value={totals.total} + /> +
-
- {/* Tabela de vendedores */} - - - Limites por vendedor - - Edite o percentual e adicione observações para justificar exceções. Métricas mostram o impacto histórico. - - - - {loadingSellers ? ( -
{[0, 1, 2, 3].map((i) => )}
- ) : (sellers?.length ?? 0) === 0 ? ( -

Nenhum vendedor ativo encontrado.

- ) : ( -
- {sellers!.map((row) => { - const edit = edits[row.user_id] ?? {}; - const currentPct = edit.percent ?? row.max_discount_percent; - const currentNotes = edit.notes ?? row.notes ?? ""; - const dirty = - currentPct !== row.max_discount_percent || - currentNotes !== (row.notes ?? ""); - const imp = impact?.get(row.user_id); - const stress = - imp && imp.max_requested > row.max_discount_percent; +
+ {/* Tabela de vendedores */} + + + Limites por vendedor + + Edite o percentual e adicione observações para justificar exceções. Métricas mostram + o impacto histórico. + + + + {loadingSellers ? ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ) : (sellers?.length ?? 0) === 0 ? ( +

Nenhum vendedor ativo encontrado.

+ ) : ( +
+ {(sellers ?? []).map((row) => { + const edit = edits[row.user_id] ?? {}; + const currentPct = edit.percent ?? row.max_discount_percent; + const currentNotes = edit.notes ?? row.notes ?? ''; + const dirty = + currentPct !== row.max_discount_percent || currentNotes !== (row.notes ?? ''); + const imp = impact?.get(row.user_id); + const stress = imp && imp.max_requested > row.max_discount_percent; - return ( -
-
-
-

- {row.full_name || "Sem nome"} - {!row.hasCustomLimit && ( - padrão - )} -

-

{row.email}

-
-
- - setEdits((p) => ({ - ...p, - [row.user_id]: { ...p[row.user_id], percent: +e.target.value }, - })) - } - className="w-24" - aria-label="Limite máximo de desconto em porcentagem" - /> - % - -
+ return ( +
+
+
+

+ {row.full_name || 'Sem nome'} + {!row.hasCustomLimit && ( + + padrão + + )} +

+

{row.email}

- -