diff --git a/src/components/admin/products/ProductFormFullscreen.tsx b/src/components/admin/products/ProductFormFullscreen.tsx index b1906193e..82b4d61b6 100644 --- a/src/components/admin/products/ProductFormFullscreen.tsx +++ b/src/components/admin/products/ProductFormFullscreen.tsx @@ -1,6 +1,9 @@ /** * ProductFormFullscreen — Stepper horizontal com preview lateral * Refatorado: conteúdo das etapas em ProductFormStepContent.tsx + * + * Sprint 3 (26/05/2026): + * BUG-03: engravingFlushRef prop passed down through to ProductFormStepContent */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -14,32 +17,14 @@ import { useProductFormDraft } from './hooks/useProductFormDraft'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { - Loader2, - Package, - Tag, - ImageIcon, - Layers, - Megaphone, - Paintbrush, - AlertCircle, - FileText, - Save, - X, - PanelRightClose, - PanelRightOpen, - ChevronLeft, - ChevronRight, - Info, - Boxes, + Loader2, Package, Tag, ImageIcon, Layers, Megaphone, Paintbrush, + AlertCircle, FileText, Save, X, PanelRightClose, PanelRightOpen, + ChevronLeft, ChevronRight, Info, Boxes, } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { useSkuValidation } from './hooks/useSkuValidation'; import { useProductSeoAI } from '@/hooks/products'; -// ============================================ -// TYPES & STEPS -// ============================================ - interface ProductFormFullscreenProps { initialData?: Partial; productImages?: string[]; @@ -48,89 +33,22 @@ interface ProductFormFullscreenProps { onCancel: () => void; isSaving: boolean; isEdit: boolean; + /** BUG-03: ref populated by ProductEngravingSection with flushLocalAreas */ + engravingFlushRef?: React.MutableRefObject<((id: string) => Promise) | null>; } const STEPS: StepDef[] = [ - { - id: 'essentials', - label: 'Identificação', - description: 'Fornecedor e dados', - icon: Info, - requiredFields: ['supplier_id', 'sku', 'name'], - fieldLabels: { supplier_id: 'Fornecedor', sku: 'SKU Interno', name: 'Nome do Produto' }, - }, - { - id: 'fiscal', - label: 'Financeiro e Fiscal', - description: 'Preços, estoque e tributos', - icon: FileText, - requiredFields: ['sale_price'], - fieldLabels: { sale_price: 'Preço de Venda' }, - }, - { - id: 'classification', - label: 'Classificação', - description: 'Gênero, cores e vínculos', - icon: Layers, - requiredFields: [], - fieldLabels: {}, - }, - { - id: 'commercial', - label: 'Categorias e Dimensões', - description: 'Categoria, dimensões e flags', - icon: Tag, - requiredFields: [], - fieldLabels: {}, - }, - { - id: 'engraving', - label: 'Gravação', - description: 'Áreas de personalização', - icon: Paintbrush, - requiredFields: [], - fieldLabels: {}, - }, - { - id: 'packaging', - label: 'Embalagem', - description: 'Dados da embalagem', - icon: Package, - requiredFields: [], - fieldLabels: {}, - }, - { - // 'kits' is a live wizard step but is missing from StepDef['id'] (StepId); - // widen via unknown until StepId is extended to include it. - id: 'kits', - label: 'Kits', - description: 'Gestão de kits nativos', - icon: Boxes, - requiredFields: [], - fieldLabels: {}, - } as unknown as StepDef, - { - id: 'media', - label: 'Mídia', - description: 'Imagens e vídeos', - icon: ImageIcon, - requiredFields: [], - fieldLabels: {}, - }, - { - id: 'content', - label: 'SEO', - description: 'Meta tags e marketing', - icon: Megaphone, - requiredFields: [], - fieldLabels: {}, - }, + { id: 'essentials', label: 'Identificação', description: 'Fornecedor e dados', icon: Info, requiredFields: ['supplier_id', 'sku', 'name'], fieldLabels: { supplier_id: 'Fornecedor', sku: 'SKU Interno', name: 'Nome do Produto' } }, + { id: 'fiscal', label: 'Financeiro e Fiscal', description: 'Preços, estoque e tributos', icon: FileText, requiredFields: ['sale_price'], fieldLabels: { sale_price: 'Preço de Venda' } }, + { id: 'classification', label: 'Classificação', description: 'Gênero, cores e vínculos', icon: Layers, requiredFields: [], fieldLabels: {} }, + { id: 'commercial', label: 'Categorias e Dimensões', description: 'Categoria, dimensões e flags', icon: Tag, requiredFields: [], fieldLabels: {} }, + { id: 'engraving', label: 'Gravação', description: 'Áreas de personalização', icon: Paintbrush, requiredFields: [], fieldLabels: {} }, + { id: 'packaging', label: 'Embalagem', description: 'Dados da embalagem', icon: Package, requiredFields: [], fieldLabels: {} }, + { id: 'kits', label: 'Kits', description: 'Gestão de kits nativos', icon: Boxes, requiredFields: [], fieldLabels: {} } as unknown as StepDef, + { id: 'media', label: 'Mídia', description: 'Imagens e vídeos', icon: ImageIcon, requiredFields: [], fieldLabels: {} }, + { id: 'content', label: 'SEO', description: 'Meta tags e marketing', icon: Megaphone, requiredFields: [], fieldLabels: {} }, ]; -// ============================================ -// MAIN -// ============================================ - export function ProductFormFullscreen({ initialData, productImages: initialImages = [], @@ -139,6 +57,7 @@ export function ProductFormFullscreen({ onCancel, isSaving, isEdit, + engravingFlushRef, }: ProductFormFullscreenProps) { const [images, setImages] = useState(initialImages); const [skuManuallyEdited, setSkuManuallyEdited] = useState(isEdit); @@ -152,18 +71,11 @@ export function ProductFormFullscreen({ return stored !== null ? stored === 'true' : true; }); - const { - register, - handleSubmit, - setValue, - watch, - trigger, - getValues, - formState: { errors }, - } = useForm({ - resolver: zodResolver(productFormSchema), - defaultValues: { ...defaultFormValues, ...initialData }, - }); + const { register, handleSubmit, setValue, watch, trigger, getValues, formState: { errors } } = + useForm({ + resolver: zodResolver(productFormSchema), + defaultValues: { ...defaultFormValues, ...initialData }, + }); const formValues = watch(); const supplierId = formValues.supplier_id || ''; @@ -178,21 +90,14 @@ export function ProductFormFullscreen({ const supplierRefValue = formValues.supplier_reference || ''; const flags: Record = { - is_active: formValues.is_active, - is_featured: formValues.is_featured, - is_bestseller: formValues.is_bestseller, - is_new: formValues.is_new, - is_on_sale: formValues.is_on_sale, - is_kit: formValues.is_kit, - is_imported: formValues.is_imported, - is_textil: formValues.is_textil, - is_thermal: formValues.is_thermal, - allows_personalization: formValues.allows_personalization, - has_gift_box: formValues.has_gift_box, - has_optional_packaging: formValues.has_optional_packaging, + is_active: formValues.is_active, is_featured: formValues.is_featured, + is_bestseller: formValues.is_bestseller, is_new: formValues.is_new, + is_on_sale: formValues.is_on_sale, is_kit: formValues.is_kit, + is_imported: formValues.is_imported, is_textil: formValues.is_textil, + is_thermal: formValues.is_thermal, allows_personalization: formValues.allows_personalization, + has_gift_box: formValues.has_gift_box, has_optional_packaging: formValues.has_optional_packaging, has_commercial_packaging: formValues.has_commercial_packaging, }; - const expirations: Record = { is_featured_expires_at: formValues.is_featured_expires_at ?? null, is_bestseller_expires_at: formValues.is_bestseller_expires_at ?? null, @@ -201,21 +106,11 @@ export function ProductFormFullscreen({ }; const { status: skuStatus, duplicateName } = useSkuValidation(skuValue, isEdit, initialData?.sku); - const { clearDraft } = useProductFormDraft( - productId, - setValue, - formValues, - images, - stepIndex, - setImages, - setStepIndex, - ); + const { clearDraft } = useProductFormDraft(productId, setValue, formValues, images, stepIndex, setImages, setStepIndex); - // Effects useEffect(() => { - if (!skuManuallyEdited && !isEdit && supplierRefValue) { + if (!skuManuallyEdited && !isEdit && supplierRefValue) setValue('sku', supplierRefValue, { shouldValidate: true }); - } }, [supplierRefValue, skuManuallyEdited, isEdit, setValue]); useEffect(() => { @@ -232,10 +127,7 @@ export function ProductFormFullscreen({ if (!supplierMarkup || !costPriceValue || costPriceValue <= 0) return; const calc = Math.round(costPriceValue * (1 + supplierMarkup / 100) * 100) / 100; setValue('suggested_price', calc); - if (!priceManuallyEdited) { - setValue('sale_price', calc); - setSalePriceDisplay(calc.toFixed(2)); - } + if (!priceManuallyEdited) { setValue('sale_price', calc); setSalePriceDisplay(calc.toFixed(2)); } }, [costPriceValue, supplierMarkup, priceManuallyEdited, setValue]); const numericProps = (name: keyof ProductFormData) => ({ @@ -245,10 +137,7 @@ export function ProductFormFullscreen({ }); const formProps = { register, setValue, watch, errors, numericProps }; - const { generate: generateSeoAI, isGenerating: isSeoGenerating } = useProductSeoAI( - getValues, - setValue, - ); + const { generate: generateSeoAI, isGenerating: isSeoGenerating } = useProductSeoAI(getValues, setValue); const [showValidation, setShowValidation] = useState(false); @@ -280,35 +169,22 @@ export function ProductFormFullscreen({ const stepErrors = useMemo(() => { const errs = Object.keys(errors); - return STEPS.map((step) => - step.requiredFields.reduce((c, f) => c + (errs.includes(f) ? 1 : 0), 0), - ); + return STEPS.map((step) => step.requiredFields.reduce((c, f) => c + (errs.includes(f) ? 1 : 0), 0)); }, [errors]); const [direction, setDirection] = useState(0); - - const goStep = useCallback( - (i: number) => { - setDirection(i > stepIndex ? 1 : -1); - setStepIndex(i); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }, - [stepIndex], - ); + const goStep = useCallback((i: number) => { + setDirection(i > stepIndex ? 1 : -1); + setStepIndex(i); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, [stepIndex]); useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey) { - if (e.key === 's') { - e.preventDefault(); - document.querySelector('form')?.requestSubmit(); - } else if (e.key === 'ArrowRight' && stepIndex < STEPS.length - 1) { - e.preventDefault(); - goStep(stepIndex + 1); - } else if (e.key === 'ArrowLeft' && stepIndex > 0) { - e.preventDefault(); - goStep(stepIndex - 1); - } + if (e.key === 's') { e.preventDefault(); document.querySelector('form')?.requestSubmit(); } + else if (e.key === 'ArrowRight' && stepIndex < STEPS.length - 1) { e.preventDefault(); goStep(stepIndex + 1); } + else if (e.key === 'ArrowLeft' && stepIndex > 0) { e.preventDefault(); goStep(stepIndex - 1); } } }; window.addEventListener('keydown', handler); @@ -319,14 +195,12 @@ export function ProductFormFullscreen({ e.preventDefault(); const isValid = await trigger(); const totalMissing = missingFields.reduce((sum, arr) => sum + arr.length, 0); - if (!isValid || totalMissing > 0) { setShowValidation(true); const firstBadStep = missingFields.findIndex((arr) => arr.length > 0); if (firstBadStep >= 0 && firstBadStep !== stepIndex) goStep(firstBadStep); return; } - clearDraft(); handleSubmit(async (data) => { if (skuStatus === 'duplicate') return; @@ -341,45 +215,25 @@ export function ProductFormFullscreen({ return (
- {/* STEPPER BAR */}
- +
{Object.keys(errors).length > 0 && ( - - {Object.keys(errors).length} + {Object.keys(errors).length} )} -
- {/* CONTENT + PREVIEW */}
{skuStatus === 'duplicate' && ( @@ -388,25 +242,20 @@ export function ProductFormFullscreen({

SKU duplicado

-

- Este SKU já está em uso{duplicateName ? ` no produto "${duplicateName}"` : ''}. - Ajuste antes de salvar. -

+

Este SKU já está em uso{duplicateName ? ` no produto "${duplicateName}"` : ''}. Ajuste antes de salvar.

)} - 0 ? 60 : -60 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: direction > 0 ? -60 : 60 }} transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }} > + {/* BUG-03: engravingFlushRef is threaded down to ProductFormStepContent → ProductEngravingSection */} {showValidation && missingFields[stepIndex].length > 0 && ( - +

- {missingFields[stepIndex].length} campo - {missingFields[stepIndex].length > 1 ? 's' : ''} obrigatório - {missingFields[stepIndex].length > 1 ? 's' : ''} nesta etapa + {missingFields[stepIndex].length} campo{missingFields[stepIndex].length > 1 ? 's' : ''} obrigatório{missingFields[stepIndex].length > 1 ? 's' : ''} nesta etapa

    {missingFields[stepIndex].map((label) => ( -
  • - - {label} +
  • + {label}
  • ))}
@@ -471,151 +311,73 @@ export function ProductFormFullscreen({ )} - {/* Navigation footer */}
{hasPrev && ( - )} - - Ctrl+←/→ navegar · Ctrl+S salvar - + Ctrl+←/→ navegar · Ctrl+S salvar
{hasNext && ( - )} {isLast && ( - )} - +
- {/* Preview sidebar */}
-
{showPreview && (
- +
)}
- {/* Mobile bottom bar */}
{hasPrev && ( - )} - - {stepIndex + 1}/{STEPS.length} - + {stepIndex + 1}/{STEPS.length}
{hasNext ? ( - ) : ( - )} - +
diff --git a/src/components/admin/products/ProductFormStepContent.tsx b/src/components/admin/products/ProductFormStepContent.tsx index 1568305c4..c9e60ccce 100644 --- a/src/components/admin/products/ProductFormStepContent.tsx +++ b/src/components/admin/products/ProductFormStepContent.tsx @@ -1,5 +1,8 @@ /** * ProductFormStepContent — Renderiza o conteúdo de cada etapa do formulário + * + * Sprint 3 (26/05/2026): + * BUG-03: pass engravingFlushRef down to ProductEngravingSection */ import React, { Suspense } from 'react'; import { Card } from '@/components/ui/card'; @@ -81,6 +84,8 @@ interface StepContentProps { expirations: Record; generateSeoAI: () => void; isSeoGenerating: boolean; + /** BUG-03: ref populated by ProductEngravingSection with flushLocalAreas */ + engravingFlushRef?: React.MutableRefObject<((id: string) => Promise) | null>; } export function ProductFormStepContent({ @@ -108,6 +113,7 @@ export function ProductFormStepContent({ expirations, generateSeoAI, isSeoGenerating, + engravingFlushRef, }: StepContentProps) { const { register, setValue, errors } = formProps; @@ -201,7 +207,12 @@ export function ProductFormStepContent({ case 'engraving': return ( }> - + {/* BUG-03: pass engravingFlushRef so AdminProductFormPage can flush local areas after creation */} + ); case 'classification': diff --git a/src/components/admin/products/sections/ProductEngravingSection.tsx b/src/components/admin/products/sections/ProductEngravingSection.tsx index 67243149d..13b8bc537 100644 --- a/src/components/admin/products/sections/ProductEngravingSection.tsx +++ b/src/components/admin/products/sections/ProductEngravingSection.tsx @@ -1,8 +1,14 @@ /** * ProductEngravingSection — Aba de Gravação com Wizard guiado * Refactored: 960 → ~280 lines (orchestrator + steps inline) + * + * Sprint 3 fixes (26/05/2026): + * BUG-03: engravingFlushRef prop — populated with flushLocalAreas so AdminProductFormPage + * can call it before navigate() to persist local areas for new products. + * BUG-05 (UI completion): AlertDialog for delete area confirmation (state was already + * exposed by useEngravingWizard since Sprint 2, UI rendering added here). */ -import React from 'react'; +import React, { useEffect } from 'react'; import { SectionCard } from '../ProductFormHelpers'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,6 +16,16 @@ import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Skeleton } from '@/components/ui/skeleton'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Paintbrush, MapPin, @@ -41,11 +57,26 @@ import { EngravingAreaCard } from './engraving/EngravingAreaCard'; interface Props { productId?: string; isEdit: boolean; + /** BUG-03: ref that will be populated with flushLocalAreas so the parent page can call it + * after product creation and before navigating to edit mode. */ + engravingFlushRef?: React.MutableRefObject<((id: string) => Promise) | null>; } -export default function ProductEngravingSection({ productId, isEdit }: Props) { +export default function ProductEngravingSection({ productId, isEdit, engravingFlushRef }: Props) { const w = useEngravingWizard(productId, isEdit); + // BUG-03 FIX: register flushLocalAreas with the ref so AdminProductFormPage can call it + useEffect(() => { + if (engravingFlushRef) { + engravingFlushRef.current = w.flushLocalAreas; + } + return () => { + if (engravingFlushRef) { + engravingFlushRef.current = null; + } + }; + }, [engravingFlushRef, w.flushLocalAreas]); + const renderWizardStepper = () => { if (w.wizardStep === 'list') return null; return ( @@ -429,111 +460,140 @@ export default function ProductEngravingSection({ productId, isEdit }: Props) { }; return ( - - {w.isLoading ? ( -
- - -
- ) : ( - - {w.wizardStep === 'list' ? ( - -
-
- - {w.displayAreas.length} {w.displayAreas.length === 1 ? 'área' : 'áreas'}{' '} - configurada{w.displayAreas.length !== 1 ? 's' : ''} - - {w.displayAreas.filter((a) => a.is_active).length < w.displayAreas.length && ( - - {w.displayAreas.filter((a) => a.is_active).length} ativas - - )} - {!isEdit && w.localAreas.length > 0 && ( - - Serão salvas ao criar o produto - - )} -
- -
- {w.displayAreas.length === 0 && ( -
-
- + <> + + {w.isLoading ? ( +
+ + +
+ ) : ( + + {w.wizardStep === 'list' ? ( + +
+
+ + {w.displayAreas.length} {w.displayAreas.length === 1 ? 'área' : 'áreas'}{' '} + configurada{w.displayAreas.length !== 1 ? 's' : ''} + + {w.displayAreas.filter((a) => a.is_active).length < w.displayAreas.length && ( + + {w.displayAreas.filter((a) => a.is_active).length} ativas + + )} + {!isEdit && w.localAreas.length > 0 && ( + + Serão salvas ao criar o produto + + )}
-

Nenhuma personalização configurada

-

- Use o assistente para definir componentes, locais e técnicas de gravação do - produto. -

-
- )} - {w.displayAreas.length > 0 && ( -
- {w.displayAreas.map((area) => ( - - w.setExpandedId(w.expandedId === area.id ? null : area.id) - } - onToggleActive={() => w.handleToggleActive(area)} - onDelete={() => w.handleDeleteArea(area)} - /> - ))} -
- )} -
- ) : ( - - +
+ )} + {w.displayAreas.length > 0 && ( +
+ {w.displayAreas.map((area) => ( + + w.setExpandedId(w.expandedId === area.id ? null : area.id) + } + onToggleActive={() => w.handleToggleActive(area)} + onDelete={() => w.handleDeleteArea(area)} + /> + ))} +
+ )} + + ) : ( + - Voltar à lista - - {renderWizardStepper()} - {w.wizardStep === 'component' && renderComponentStep()} - {w.wizardStep === 'location' && renderLocationStep()} - {w.wizardStep === 'technique' && renderTechniqueStep()} - {w.wizardStep === 'details' && renderDetailsStep()} - - )} - - )} - + + {renderWizardStepper()} + {w.wizardStep === 'component' && renderComponentStep()} + {w.wizardStep === 'location' && renderLocationStep()} + {w.wizardStep === 'technique' && renderTechniqueStep()} + {w.wizardStep === 'details' && renderDetailsStep()} + + )} + + )} + + + {/* BUG-05 FIX: AlertDialog for area delete confirmation (no more confirm()) */} + { if (!open) w.cancelDeleteArea(); }} + > + + + Remover área de personalização + + Deseja remover a área{' '} + "{w.deleteAreaConfirm?.location_name}" com a técnica{' '} + "{w.deleteAreaConfirm?.technique_name}"? + {!isEdit && ' (A área só existe localmente e não foi salva no banco ainda.)'} + + + + Cancelar + + Remover + + + + + ); } diff --git a/src/components/admin/products/sections/engraving/useEngravingWizard.ts b/src/components/admin/products/sections/engraving/useEngravingWizard.ts index 8b5438bbb..ff652beed 100644 --- a/src/components/admin/products/sections/engraving/useEngravingWizard.ts +++ b/src/components/admin/products/sections/engraving/useEngravingWizard.ts @@ -1,14 +1,16 @@ /** * useEngravingWizard — Business logic for the engraving wizard * - * Fixes applied (audit 26/05/2026): - * BUG-02: table name corrected to 'tecnicas_gravacao' (was 'tecnica_gravacao' singular) - * BUG-05: handleDeleteArea uses state instead of confirm() — exposes deleteAreaConfirm/confirmDeleteArea/cancelDeleteArea - * BUG-03 NOTE: localAreas are not persisted when creating a new product. This is a known - * limitation — fix requires AdminProductFormPage to call flushLocalAreas(productId) after - * successful creation. Deferred to Sprint 3. A warning badge is shown in the UI. + * Sprint 2 fixes (audit 26/05/2026): + * BUG-02: table name corrected to 'tecnicas_gravacao' (plural) + * BUG-05: handleDeleteArea uses state — exposes deleteAreaConfirm/confirmDeleteArea/cancelDeleteArea + * + * Sprint 3 fixes (26/05/2026): + * BUG-03: flushLocalAreas(newProductId) — persists localAreas to DB after product creation. + * AdminProductFormPage calls this before navigating to edit mode. + * BUG-28: console.warn when technique not found in cache (likely deleted from DB) */ -import { useState, useCallback, useMemo, useRef } from 'react'; +import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; @@ -27,12 +29,8 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea const [wizardStep, setWizardStep] = useState('list'); const [expandedId, setExpandedId] = useState(null); - const [selectedComponent, setSelectedComponent] = useState<{ code: string; name: string } | null>( - null, - ); - const [selectedLocation, setSelectedLocation] = useState<{ code: string; name: string } | null>( - null, - ); + const [selectedComponent, setSelectedComponent] = useState<{ code: string; name: string } | null>(null); + const [selectedLocation, setSelectedLocation] = useState<{ code: string; name: string } | null>(null); const [selectedTechnique, setSelectedTechnique] = useState(null); const [customComponent, setCustomComponent] = useState(''); const [customLocation, setCustomLocation] = useState(''); @@ -42,14 +40,16 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea (PrintAreaTechnique & { _techData?: ExternalTechnique })[] >([]); - // BUG-05 FIX: state-based delete confirmation for areas — no more confirm() + // BUG-05: state-based delete confirmation — no more confirm() const [deleteAreaConfirm, setDeleteAreaConfirm] = useState(null); - // BUG-03 NOTE: exposed via ref so AdminProductFormPage can call flushLocalAreas(id) after creation + // BUG-03: ref always holds the latest localAreas so flushLocalAreas (stable useCallback) can read it const localAreasRef = useRef(localAreas); - localAreasRef.current = localAreas; + useEffect(() => { + localAreasRef.current = localAreas; + }, [localAreas]); - // BUG-02 FIX: correct table name is 'tecnicas_gravacao' (plural with 's') + // BUG-02 FIX: correct table name 'tecnicas_gravacao' (plural) const { data: techniques = [], isLoading: loadingTechs } = useQuery({ queryKey: ['external-techniques-catalog'], queryFn: async (): Promise => { @@ -74,7 +74,6 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea return map; }, [techniques]); - // Fetch saved areas const { data: savedAreas = [], isLoading: loadingAreas } = useQuery({ queryKey: ['print-area-techniques', productId], queryFn: async (): Promise => { @@ -102,7 +101,6 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea const displayAreas: EnrichedArea[] = isEdit && productId ? savedAreas : enrichedLocalAreas; - // Mutations const invalidate = () => queryClient.invalidateQueries({ queryKey: ['print-area-techniques', productId] }); @@ -114,10 +112,7 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea if (error) throw new Error(error.message); if (!data?.success) throw new Error(data?.error || 'Erro ao criar área'); }, - onSuccess: () => { - invalidate(); - toast.success('Área de personalização adicionada'); - }, + onSuccess: () => { invalidate(); toast.success('Área de personalização adicionada'); }, onError: (e: unknown) => toast.error(sanitizeError(e)), }); @@ -129,10 +124,7 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea if (error) throw new Error(error.message); if (!data?.success) throw new Error(data?.error || 'Erro ao atualizar área'); }, - onSuccess: () => { - invalidate(); - toast.success('Área atualizada'); - }, + onSuccess: () => { invalidate(); toast.success('Área atualizada'); }, onError: (e: unknown) => toast.error(sanitizeError(e)), }); @@ -144,14 +136,10 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea if (error) throw new Error(error.message); if (!data?.success) throw new Error(data?.error || 'Erro ao excluir área'); }, - onSuccess: () => { - invalidate(); - toast.success('Área removida'); - }, + onSuccess: () => { invalidate(); toast.success('Área removida'); }, onError: (e: unknown) => toast.error(sanitizeError(e)), }); - // Wizard actions const resetWizard = useCallback(() => { setWizardStep('list'); setSelectedComponent(null); @@ -163,24 +151,15 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea setDetailForm(DEFAULT_DETAIL_FORM); }, []); - const startWizard = useCallback(() => { - resetWizard(); - setWizardStep('component'); - }, [resetWizard]); - + const startWizard = useCallback(() => { resetWizard(); setWizardStep('component'); }, [resetWizard]); const handleSelectComponent = useCallback((comp: { code: string; name: string }) => { - setSelectedComponent(comp); - setWizardStep('location'); + setSelectedComponent(comp); setWizardStep('location'); }, []); - const handleSelectLocation = useCallback((loc: { code: string; name: string }) => { - setSelectedLocation(loc); - setWizardStep('technique'); + setSelectedLocation(loc); setWizardStep('technique'); }, []); - const handleSelectTechnique = useCallback((tech: ExternalTechnique) => { - setSelectedTechnique(tech); - setWizardStep('details'); + setSelectedTechnique(tech); setWizardStep('details'); }, []); const handleSaveArea = useCallback(() => { @@ -205,38 +184,51 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea if (isEdit && productId) { createMutation.mutate(newArea); } else { - // BUG-03 NOTE: area stored locally with product_id='pending'. - // AdminProductFormPage must call flushLocalAreas(productId) after successful creation. + // BUG-03: stored locally with product_id='pending'. + // AdminProductFormPage calls flushLocalAreas(newId) before navigating to edit mode. setLocalAreas((prev) => [ ...prev, - { - ...newArea, - id: `local-${Date.now()}`, - _techData: selectedTechnique, - } as PrintAreaTechnique & { _techData?: ExternalTechnique }, + { ...newArea, id: `local-${Date.now()}`, _techData: selectedTechnique } as PrintAreaTechnique & { _techData?: ExternalTechnique }, ]); toast.success('Área adicionada (será salva junto ao produto)'); } resetWizard(); - }, [ - selectedComponent, - selectedLocation, - selectedTechnique, - detailForm, - productId, - isEdit, - displayAreas.length, - createMutation, - resetWizard, - ]); + }, [selectedComponent, selectedLocation, selectedTechnique, detailForm, productId, isEdit, displayAreas.length, createMutation, resetWizard]); - // BUG-05 FIX: requestDeleteArea sets state; confirmDeleteArea performs the delete - const handleDeleteArea = useCallback( - (area: EnrichedArea) => { - setDeleteAreaConfirm(area); - }, - [], - ); + // BUG-03 FIX: flush all pending localAreas to DB using the newly created product's ID. + // Called by AdminProductFormPage BEFORE navigate() so areas are available in edit mode. + // Uses localAreasRef (always current) so useCallback can have empty deps (stable ref). + const flushLocalAreas = useCallback(async (newProductId: string): Promise => { + const areas = localAreasRef.current.filter((a) => a.id.startsWith('local-')); + if (areas.length === 0) return; + const results = await Promise.allSettled( + areas.map(async (area) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, _techData: _td, ...areaData } = area as PrintAreaTechnique & { _techData?: ExternalTechnique }; + const { data, error } = await supabase.functions.invoke('external-db-bridge', { + body: { + table: 'print_area_techniques', + operation: 'insert', + data: { ...areaData, product_id: newProductId }, + }, + }); + if (error) throw new Error(error.message); + if (!data?.success) throw new Error(data?.error || 'Erro ao salvar área de gravação'); + }), + ); + const failed = results.filter((r) => r.status === 'rejected').length; + if (failed > 0) { + toast.warning(`${areas.length - failed}/${areas.length} áreas de gravação salvas — ${failed} falha(s).`); + } else { + toast.success(`${areas.length} área(s) de gravação salvas com o produto`); + } + setLocalAreas([]); // clear after flush + }, []); // stable — reads localAreasRef.current which is always up-to-date + + // BUG-05: state-based delete confirmation + const handleDeleteArea = useCallback((area: EnrichedArea) => { + setDeleteAreaConfirm(area); + }, []); const confirmDeleteArea = useCallback(() => { if (!deleteAreaConfirm) return; @@ -252,20 +244,14 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea const cancelDeleteArea = useCallback(() => setDeleteAreaConfirm(null), []); - const handleToggleActive = useCallback( - (area: EnrichedArea) => { - if (isEdit && area.id && !area.id.startsWith('local-')) { - updateMutation.mutate({ id: area.id, is_active: !area.is_active }); - } else { - setLocalAreas((prev) => - prev.map((a) => (a.id === area.id ? { ...a, is_active: !a.is_active } : a)), - ); - } - }, - [isEdit, updateMutation], - ); + const handleToggleActive = useCallback((area: EnrichedArea) => { + if (isEdit && area.id && !area.id.startsWith('local-')) { + updateMutation.mutate({ id: area.id, is_active: !area.is_active }); + } else { + setLocalAreas((prev) => prev.map((a) => (a.id === area.id ? { ...a, is_active: !a.is_active } : a))); + } + }, [isEdit, updateMutation]); - // Filtered techniques const filteredTechniques = useMemo(() => { if (!techSearch) return techniques.filter((t) => t.ativo !== false); const s = techSearch.toLowerCase(); @@ -294,42 +280,25 @@ export function useEngravingWizard(productId: string | undefined, isEdit: boolea const isLoading = loadingTechs || (isEdit && loadingAreas); return { - wizardStep, - setWizardStep, - expandedId, - setExpandedId, - selectedComponent, - selectedLocation, - selectedTechnique, - customComponent, - setCustomComponent, - customLocation, - setCustomLocation, - techSearch, - setTechSearch, - detailForm, - setDetailForm, - localAreas, - localAreasRef, + wizardStep, setWizardStep, + expandedId, setExpandedId, + selectedComponent, selectedLocation, selectedTechnique, + customComponent, setCustomComponent, + customLocation, setCustomLocation, + techSearch, setTechSearch, + detailForm, setDetailForm, + localAreas, localAreasRef, displayAreas, - filteredTechniques, - groupedTechniques, - wizardStepIndex, - isBusy, - isLoading, - loadingTechs, - // Actions - resetWizard, - startWizard, - handleSelectComponent, - handleSelectLocation, - handleSelectTechnique, + filteredTechniques, groupedTechniques, + wizardStepIndex, isBusy, isLoading, loadingTechs, + resetWizard, startWizard, + handleSelectComponent, handleSelectLocation, handleSelectTechnique, handleSaveArea, handleDeleteArea, - // BUG-05: new delete confirmation actions - deleteAreaConfirm, - confirmDeleteArea, - cancelDeleteArea, + // BUG-03: flush pending local areas to DB after product creation + flushLocalAreas, + // BUG-05: state-based delete confirmation + deleteAreaConfirm, confirmDeleteArea, cancelDeleteArea, handleToggleActive, }; } @@ -342,6 +311,13 @@ function enrichArea( override?: ExternalTechnique, ): EnrichedArea { const tech = override || techById.get(area.tabela_preco_id); + // BUG-28 FIX: warn when technique not found — likely deleted from DB; area shows '—' + if (!tech && !override && area.id && !area.id.startsWith('local-')) { + console.warn( + `[EngravingWizard] Technique id="${area.tabela_preco_id}" not found in cache — ` + + 'it may have been deleted from the DB. Area will display "—" as technique name.', + ); + } return { ...area, technique_name: tech?.nome || '—', @@ -349,9 +325,7 @@ function enrichArea( technique_group: tech?.grupo_tecnica || '', max_colors: tech !== null && tech !== undefined && tech.max_cores !== null && tech.max_cores !== undefined - ? typeof tech.max_cores === 'string' - ? parseInt(tech.max_cores, 10) - : tech.max_cores + ? typeof tech.max_cores === 'string' ? parseInt(tech.max_cores, 10) : tech.max_cores : null, setup_cost: tech?.custo_setup ?? null, charges_per_color: tech?.cobra_por_cor ?? false, diff --git a/src/components/admin/products/useProductsManager.ts b/src/components/admin/products/useProductsManager.ts index ffa9e444f..566ac832e 100644 --- a/src/components/admin/products/useProductsManager.ts +++ b/src/components/admin/products/useProductsManager.ts @@ -2,13 +2,18 @@ * useProductsManager — Business logic hook for ProductsManager. * Manages fetching, pagination, filtering, bulk selection, and CRUD operations. * - * Fixes applied (audit 26/05/2026): + * Sprint 2 fixes (audit 26/05/2026): * BUG-08: galeria completa preservada; imageUrl apenas como fallback * BUG-09: Promise.allSettled com reporte granular de falhas * BUG-10: handleFiltersChange inclui fetchProducts nas deps * BUG-11: useEffect de searchTerm inclui fetchProducts nas deps (com nota) * BUG-15: video_url extração segura — suporta string | {url:string} * BUG-18: stats.isPageLevel sinaliza que os números são da página atual + * + * Sprint 3 fixes (26/05/2026): + * BUG-26: selectedOnPageLabel exposto no return para UX comunicar seleção por página + * BUG-27: handlePageChange passa advancedFilters explicitamente (não depende de closure) + * BUG-29: comentário no useEffect inicial com [] documenta a intencionalidade */ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -302,8 +307,9 @@ export function useProductsManager() { [currentPage, pageSize, advancedFilters], ); - // BUG-29 note: empty deps intentional for single mount fetch; pageSize/searchTerm are - // initial values (50/'') so stale-closure risk is negligible here. + // BUG-29: empty deps intentional — this runs once on mount to load the initial page. + // pageSize=50 and searchTerm='' are initial values; stale-closure risk is negligible + // since any subsequent user interaction will call fetchProducts with explicit args. useEffect(() => { fetchProducts(1, pageSize, searchTerm); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -344,11 +350,14 @@ export function useProductsManager() { return filtered; }, [products, advancedFilters.price_min, advancedFilters.price_max, advancedFilters.is_kit]); + // BUG-27 FIX: pass advancedFilters explicitly so active filters persist when changing pages. + // Previously relied on closure which could be stale if filters had just been set. const handlePageChange = (page: number) => { setCurrentPage(page); setSelectedIds(new Set()); - fetchProducts(page, pageSize, searchTerm); + fetchProducts(page, pageSize, searchTerm, advancedFilters); }; + const handlePageSizeChange = (newSize: string) => { const size = parseInt(newSize, 10); setPageSize(size); @@ -397,8 +406,7 @@ export function useProductsManager() { }, []); const toggleSelectAll = useCallback(() => { - // BUG-26 note: selects only the current page. UI should display a label - // like "N selected on this page" to avoid confusion with full-catalog selection. + // BUG-26: selects only the current page — selectedOnPageLabel in return communicates this to UI setSelectedIds((prev) => prev.size === displayedProducts.length ? new Set() @@ -483,6 +491,11 @@ export function useProductsManager() { setIsImportOpen, selectedProduct, stats, + // BUG-26 FIX: label for UX — surface to the UI that selection is page-scoped only + selectedOnPageLabel: + selectedIds.size > 0 + ? `${selectedIds.size} de ${displayedProducts.length} selecionado(s) nesta página` + : null, openCreateForm, openEditForm, openDeleteDialog, diff --git a/src/pages/admin/AdminProductFormPage.tsx b/src/pages/admin/AdminProductFormPage.tsx index 36e4fa0f2..e4635cff0 100644 --- a/src/pages/admin/AdminProductFormPage.tsx +++ b/src/pages/admin/AdminProductFormPage.tsx @@ -1,9 +1,15 @@ /** * AdminProductFormPage — Página full-screen para criar/editar produtos * Substitui o Dialog modal por uma experiência imersiva com sidebar de navegação + * + * Sprint 3 (26/05/2026): + * BUG-03: engravingFlushRef criado aqui e passado para ProductFormFullscreen. + * Após criação bem-sucedida do produto, chama flushLocalAreas(newProduct.id) + * ANTES de navigate() para que as áreas de gravação configuradas no wizard + * sejam persistidas e apareçam em edit mode. */ -import { useState, useEffect, useCallback, Suspense } from 'react'; +import { useState, useEffect, useCallback, useRef, Suspense } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { invokeExternalDbSingle, @@ -22,7 +28,6 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { lazyWithRetry } from '@/lib/lazyWithRetry'; import { PageSEO } from '@/components/seo/PageSEO'; -// Lazy load heavy sub-components const ProductFormFullscreen = lazyWithRetry(() => import('@/components/admin/products/ProductFormFullscreen').then((m) => ({ default: m.ProductFormFullscreen, @@ -43,7 +48,11 @@ export default function AdminProductFormPage() { const [isSaving, setIsSaving] = useState(false); const [activeTab, setActiveTab] = useState<'form' | 'history'>('form'); - // Load duplicate data from sessionStorage for new products + // BUG-03 FIX: ref populated by ProductEngravingSection with flushLocalAreas. + // After product creation, we call this before navigate() so local engraving areas + // are persisted and immediately visible when the page re-renders in edit mode. + const engravingFlushRef = useRef<((id: string) => Promise) | null>(null); + useEffect(() => { if (!isEdit) { const stored = sessionStorage.getItem('duplicate_product'); @@ -52,9 +61,7 @@ export default function AdminProductFormPage() { const parsed = JSON.parse(stored); setDuplicateProduct(parsed); toast.info(`Duplicando produto: ${parsed.name}. Altere o SKU antes de salvar.`); - } catch { - /* ignore */ - } + } catch { /* ignore */ } sessionStorage.removeItem('duplicate_product'); } } @@ -62,30 +69,21 @@ export default function AdminProductFormPage() { const { logAction, getChangedFields } = useAuditLog(); - // Load product data for edit mode useEffect(() => { if (!isEdit) return; - const loadProduct = async () => { if (!id) return; setIsLoading(true); try { const fullProduct = await fetchPromobrindProductById(id); - if (fullProduct) { - setProduct(fullProduct); - } else { - toast.error('Produto não encontrado'); - navigate('/admin/cadastros'); - } + if (fullProduct) { setProduct(fullProduct); } + else { toast.error('Produto não encontrado'); navigate('/admin/cadastros'); } } catch (err) { console.error('Error loading product:', err); toast.error('Erro ao carregar produto'); navigate('/admin/cadastros'); - } finally { - setIsLoading(false); - } + } finally { setIsLoading(false); } }; - loadProduct(); }, [id, isEdit, navigate]); @@ -108,83 +106,50 @@ export default function AdminProductFormPage() { product_type: p.product_type ?? (p.is_kit ? 'kit' : 'product'), min_quantity: p.min_quantity ?? 1, min_order_quantity: p.min_order_quantity ?? null, - height_cm: p.height_cm ?? null, - width_cm: p.width_cm ?? null, - length_cm: p.length_cm ?? null, - diameter_cm: p.diameter_cm ?? null, - weight_g: p.weight_g ?? null, - capacity_ml: p.capacity_ml ?? null, - internal_height_cm: p.internal_height_cm ?? null, - internal_width_cm: p.internal_width_cm ?? null, - internal_length_cm: p.internal_length_cm ?? null, - internal_diameter_cm: p.internal_diameter_cm ?? null, + height_cm: p.height_cm ?? null, width_cm: p.width_cm ?? null, + length_cm: p.length_cm ?? null, diameter_cm: p.diameter_cm ?? null, + weight_g: p.weight_g ?? null, capacity_ml: p.capacity_ml ?? null, + internal_height_cm: p.internal_height_cm ?? null, internal_width_cm: p.internal_width_cm ?? null, + internal_length_cm: p.internal_length_cm ?? null, internal_diameter_cm: p.internal_diameter_cm ?? null, packing_type: p.packing_type ?? '', - box_width_mm: p.box_width_mm ?? null, - box_height_mm: p.box_height_mm ?? null, - box_length_mm: p.box_length_mm ?? null, - box_weight_kg: p.box_weight_kg ?? null, - box_quantity: p.box_quantity ?? null, - box_volume_cm3: p.box_volume_cm3 ?? null, - packaging_material: p.packaging_material ?? '', - packaging_color: p.packaging_color ?? '', + box_width_mm: p.box_width_mm ?? null, box_height_mm: p.box_height_mm ?? null, + box_length_mm: p.box_length_mm ?? null, box_weight_kg: p.box_weight_kg ?? null, + box_quantity: p.box_quantity ?? null, box_volume_cm3: p.box_volume_cm3 ?? null, + packaging_material: p.packaging_material ?? '', packaging_color: p.packaging_color ?? '', packaging_finish: p.packaging_finish ?? '', - is_active: p.is_active ?? p.active ?? true, - is_featured: p.is_featured ?? false, - is_bestseller: p.is_bestseller ?? false, - is_new: p.is_new ?? false, + is_active: p.is_active ?? p.active ?? true, is_featured: p.is_featured ?? false, + is_bestseller: p.is_bestseller ?? false, is_new: p.is_new ?? false, is_on_sale: p.is_on_sale ?? false, is_featured_expires_at: p.is_featured_expires_at ?? null, is_bestseller_expires_at: p.is_bestseller_expires_at ?? null, is_new_expires_at: p.is_new_expires_at ?? p.novelty_expires_at ?? null, is_on_sale_expires_at: p.is_on_sale_expires_at ?? null, - is_kit: p.is_kit ?? false, - has_commercial_packaging: p.has_commercial_packaging ?? false, - is_imported: p.is_imported ?? false, - is_textil: p.is_textil ?? false, - is_thermal: p.is_thermal ?? false, - allows_personalization: p.allows_personalization ?? true, - has_gift_box: p.has_gift_box ?? false, - has_optional_packaging: p.has_optional_packaging ?? false, - ncm_code: p.ncm_code ?? '', - ean: p.ean ?? '', - gtin: p.gtin ?? '', - ipi_rate: p.ipi_rate ?? null, - country_of_origin: p.country_of_origin ?? p.origin_country ?? '', - cfop: p.cfop ?? '', - csosn: p.csosn ?? '', - icms_rate: p.icms_rate ?? null, - pis_rate: p.pis_rate ?? null, - cofins_rate: p.cofins_rate ?? null, - tax_regime: p.tax_regime ?? '', - cest: p.cest ?? '', - freight_class: p.freight_class ?? '', - default_carrier: p.default_carrier ?? '', - shipping_weight_kg: p.shipping_weight_kg ?? null, - shipping_width_cm: p.shipping_width_cm ?? null, - shipping_height_cm: p.shipping_height_cm ?? null, - shipping_length_cm: p.shipping_length_cm ?? null, - cubic_weight: p.cubic_weight ?? null, - requires_special_shipping: p.requires_special_shipping ?? false, - shipping_notes: p.shipping_notes ?? '', - lead_time_days: p.lead_time_days ?? null, - // product_type already set above at line ~92 - supply_mode: p.supply_mode ?? '', - warranty_months: p.warranty_months ?? null, - gender: p.gender ?? '', - meta_title: p.meta_title ?? '', + is_kit: p.is_kit ?? false, has_commercial_packaging: p.has_commercial_packaging ?? false, + is_imported: p.is_imported ?? false, is_textil: p.is_textil ?? false, + is_thermal: p.is_thermal ?? false, allows_personalization: p.allows_personalization ?? true, + has_gift_box: p.has_gift_box ?? false, has_optional_packaging: p.has_optional_packaging ?? false, + ncm_code: p.ncm_code ?? '', ean: p.ean ?? '', gtin: p.gtin ?? '', + ipi_rate: p.ipi_rate ?? null, country_of_origin: p.country_of_origin ?? p.origin_country ?? '', + cfop: p.cfop ?? '', csosn: p.csosn ?? '', icms_rate: p.icms_rate ?? null, + pis_rate: p.pis_rate ?? null, cofins_rate: p.cofins_rate ?? null, + tax_regime: p.tax_regime ?? '', cest: p.cest ?? '', + freight_class: p.freight_class ?? '', default_carrier: p.default_carrier ?? '', + shipping_weight_kg: p.shipping_weight_kg ?? null, shipping_width_cm: p.shipping_width_cm ?? null, + shipping_height_cm: p.shipping_height_cm ?? null, shipping_length_cm: p.shipping_length_cm ?? null, + cubic_weight: p.cubic_weight ?? null, requires_special_shipping: p.requires_special_shipping ?? false, + shipping_notes: p.shipping_notes ?? '', lead_time_days: p.lead_time_days ?? null, + supply_mode: p.supply_mode ?? '', warranty_months: p.warranty_months ?? null, + gender: p.gender ?? '', meta_title: p.meta_title ?? '', meta_keywords: Array.isArray(p.meta_keywords) ? p.meta_keywords.join(', ') : '', - slug: p.slug ?? '', - canonical_url: p.canonical_url ?? '', + slug: p.slug ?? '', canonical_url: p.canonical_url ?? '', video_url: p.videos?.[0] ?? p.video_url ?? '', - key_benefits: p.key_benefits ?? '', - use_cases: p.use_cases ?? '', + key_benefits: p.key_benefits ?? '', use_cases: p.use_cases ?? '', }; }, []); const handleFormSubmit = async (data: ProductFormData, images: string[]) => { setIsSaving(true); try { - // Validate duplicate SKU const skuChanged = isEdit && product && data.sku !== product.sku; if (!isEdit || skuChanged) { const { fetchPromobrindProducts } = await import('@/lib/external-db'); @@ -203,89 +168,49 @@ export default function AdminProductFormPage() { } const productData: Record = { - sku: data.sku, - name: data.name, - description: data.description || null, - short_description: data.short_description || null, - meta_description: data.meta_description || null, - brand: data.brand || null, - category_id: data.category_id || null, - supplier_id: data.supplier_id || null, + sku: data.sku, name: data.name, + description: data.description || null, short_description: data.short_description || null, + meta_description: data.meta_description || null, brand: data.brand || null, + category_id: data.category_id || null, supplier_id: data.supplier_id || null, supplier_reference: data.supplier_reference || null, - sale_price: data.sale_price ?? 0, - cost_price: data.cost_price ?? null, - suggested_price: data.suggested_price ?? null, - stock_quantity: data.stock_quantity ?? 0, - stock_unit: data.stock_unit || 'un', - product_type: data.product_type || 'product', - is_kit: data.product_type === 'kit', - min_quantity: data.min_quantity ?? 1, + sale_price: data.sale_price ?? 0, cost_price: data.cost_price ?? null, + suggested_price: data.suggested_price ?? null, stock_quantity: data.stock_quantity ?? 0, + stock_unit: data.stock_unit || 'un', product_type: data.product_type || 'product', + is_kit: data.product_type === 'kit', min_quantity: data.min_quantity ?? 1, min_order_quantity: data.min_order_quantity ?? null, - is_active: data.is_active, - active: data.is_active, - is_featured: data.is_featured, - is_bestseller: data.is_bestseller, - is_new: data.is_new, - is_on_sale: data.is_on_sale, + is_active: data.is_active, active: data.is_active, + is_featured: data.is_featured, is_bestseller: data.is_bestseller, + is_new: data.is_new, is_on_sale: data.is_on_sale, is_featured_expires_at: data.is_featured_expires_at || null, is_bestseller_expires_at: data.is_bestseller_expires_at || null, is_new_expires_at: data.is_new_expires_at || null, is_on_sale_expires_at: data.is_on_sale_expires_at || null, - // is_kit already set above at line ~201 has_commercial_packaging: data.has_commercial_packaging, - is_imported: data.is_imported, - is_textil: data.is_textil, - is_thermal: data.is_thermal, - allows_personalization: data.allows_personalization, - has_gift_box: data.has_gift_box, - has_optional_packaging: data.has_optional_packaging, - packing_type: data.packing_type || null, - height_cm: data.height_cm ?? null, - width_cm: data.width_cm ?? null, - length_cm: data.length_cm ?? null, - diameter_cm: data.diameter_cm ?? null, - weight_g: data.weight_g ?? null, - capacity_ml: data.capacity_ml ?? null, - internal_height_cm: data.internal_height_cm ?? null, - internal_width_cm: data.internal_width_cm ?? null, - internal_length_cm: data.internal_length_cm ?? null, - internal_diameter_cm: data.internal_diameter_cm ?? null, - box_width_mm: data.box_width_mm ?? null, - box_height_mm: data.box_height_mm ?? null, - box_length_mm: data.box_length_mm ?? null, - box_weight_kg: data.box_weight_kg ?? null, - box_quantity: data.box_quantity ?? null, - box_volume_cm3: data.box_volume_cm3 ?? null, - packaging_material: data.packaging_material || null, - packaging_color: data.packaging_color || null, + is_imported: data.is_imported, is_textil: data.is_textil, is_thermal: data.is_thermal, + allows_personalization: data.allows_personalization, has_gift_box: data.has_gift_box, + has_optional_packaging: data.has_optional_packaging, packing_type: data.packing_type || null, + height_cm: data.height_cm ?? null, width_cm: data.width_cm ?? null, + length_cm: data.length_cm ?? null, diameter_cm: data.diameter_cm ?? null, + weight_g: data.weight_g ?? null, capacity_ml: data.capacity_ml ?? null, + internal_height_cm: data.internal_height_cm ?? null, internal_width_cm: data.internal_width_cm ?? null, + internal_length_cm: data.internal_length_cm ?? null, internal_diameter_cm: data.internal_diameter_cm ?? null, + box_width_mm: data.box_width_mm ?? null, box_height_mm: data.box_height_mm ?? null, + box_length_mm: data.box_length_mm ?? null, box_weight_kg: data.box_weight_kg ?? null, + box_quantity: data.box_quantity ?? null, box_volume_cm3: data.box_volume_cm3 ?? null, + packaging_material: data.packaging_material || null, packaging_color: data.packaging_color || null, packaging_finish: data.packaging_finish || null, - ncm_code: data.ncm_code || null, - ean: data.ean || null, - gtin: data.gtin || null, + ncm_code: data.ncm_code || null, ean: data.ean || null, gtin: data.gtin || null, country_of_origin: data.country_of_origin || null, supplier_product_url: data.supplier_product_url || null, - supply_mode: data.supply_mode || null, - ipi_rate: data.ipi_rate ?? null, - // Campos fiscais que NÃO existem no banco externo — mantidos apenas no formulário local - // cfop, csosn, icms_rate, pis_rate, cofins_rate, tax_regime - // Campos abaixo não existem no banco externo, mantidos apenas no formulário local - // cest, freight_class, default_carrier, shipping_weight_kg, shipping_width_cm, - // shipping_height_cm, shipping_length_cm, cubic_weight, requires_special_shipping, - // shipping_notes, product_type, warranty_months - lead_time_days: data.lead_time_days ?? null, - gender: data.gender || null, + supply_mode: data.supply_mode || null, ipi_rate: data.ipi_rate ?? null, + lead_time_days: data.lead_time_days ?? null, gender: data.gender || null, meta_title: data.meta_title || null, meta_keywords: data.meta_keywords - ? data.meta_keywords - .split(',') - .map((k: string) => k.trim()) - .filter(Boolean) + ? data.meta_keywords.split(',').map((k: string) => k.trim()).filter(Boolean) : null, - slug: data.slug || null, - canonical_url: data.canonical_url || null, + slug: data.slug || null, canonical_url: data.canonical_url || null, videos: data.video_url ? [data.video_url] : [], - key_benefits: data.key_benefits || null, - use_cases: data.use_cases || null, + key_benefits: data.key_benefits || null, use_cases: data.use_cases || null, updated_at: new Date().toISOString(), }; @@ -297,38 +222,21 @@ export default function AdminProductFormPage() { if (isEdit && product) { await invokeExternalDbSingle({ - table: 'products', - operation: 'update', - id: product.id, - data: productData, + table: 'products', operation: 'update', id: product.id, data: productData, }); const { oldFields, newFields } = getChangedFields( - { - sku: product.sku, - name: product.name, - description: product.description, - sale_price: getProductPrice(product), - stock_quantity: getProductStock(product), - is_active: product.is_active, - }, + { sku: product.sku, name: product.name, description: product.description, sale_price: getProductPrice(product), stock_quantity: getProductStock(product), is_active: product.is_active }, productData, ); - if (Object.keys(newFields).length > 0) { - await logAction({ - action: 'UPDATE', - entityType: 'products', - entityId: product.id, - oldValues: oldFields, - newValues: newFields, - }); + await logAction({ action: 'UPDATE', entityType: 'products', entityId: product.id, oldValues: oldFields, newValues: newFields }); } toast.success('Produto atualizado com sucesso'); - // Reload product data const refreshed = await fetchPromobrindProductById(product.id); if (refreshed) setProduct(refreshed); + } else { const newProduct = await invokeExternalDbSingle({ table: 'products', @@ -338,19 +246,24 @@ export default function AdminProductFormPage() { if (newProduct) { await logAction({ - action: 'INSERT', - entityType: 'products', - entityId: newProduct.id, + action: 'INSERT', entityType: 'products', entityId: newProduct.id, oldValues: null, - newValues: { - sku: productData.sku, - name: productData.name, - sale_price: productData.sale_price, - is_active: productData.is_active, - }, + newValues: { sku: productData.sku, name: productData.name, sale_price: productData.sale_price, is_active: productData.is_active }, }); + + // BUG-03 FIX: flush local engraving areas to DB BEFORE navigating to edit mode. + // This prevents areas configured in the wizard from being silently discarded. + if (engravingFlushRef.current) { + try { + await engravingFlushRef.current(newProduct.id); + } catch (flushErr) { + // Non-fatal: log and continue — user can re-add areas in edit mode + console.error('[AdminProductFormPage] Failed to flush engraving areas:', flushErr); + toast.warning('Produto criado, mas algumas áreas de gravação não puderam ser salvas. Verifique na aba de Gravação.'); + } + } + toast.success('Produto criado! Agora vincule Tags, Ramos, Marketing e Técnicas.'); - // Navigate to edit mode for the newly created product navigate(`/admin/cadastros/produto/${newProduct.id}`, { replace: true }); return; } @@ -369,22 +282,14 @@ export default function AdminProductFormPage() { if (!p) return []; const imgUrl = getProductImageUrl(p); if (imgUrl) - return [ - imgUrl, - ...(Array.isArray(p.images) ? p.images.filter((i: string) => i !== imgUrl) : []), - ]; + return [imgUrl, ...(Array.isArray(p.images) ? p.images.filter((i: string) => i !== imgUrl) : [])]; return Array.isArray(p.images) ? p.images : []; }, []); if (isLoading) { return ( <> - +
@@ -399,9 +304,7 @@ export default function AdminProductFormPage() { <> @@ -409,26 +312,16 @@ export default function AdminProductFormPage() {

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

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

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

+

{product.sku} — {product.name}

)}
@@ -436,54 +329,36 @@ export default function AdminProductFormPage() {
{isEdit && product && ( <> - - )} {isEdit && ( - setActiveTab(v as 'form' | 'history')} - > + setActiveTab(v as 'form' | 'history')}> - - Editar + Editar - - Histórico + Histórico @@ -492,14 +367,7 @@ export default function AdminProductFormPage() {
)} - {/* Content */} - - -
- } - > +
}> {activeTab === 'form' ? ( navigate('/admin/cadastros')} isSaving={isSaving} isEdit={isEdit} + engravingFlushRef={engravingFlushRef} /> ) : ( - isEdit && - id && ( - + isEdit && id && ( + ) )}