From fb8d68001320b451756eb92fd272279f9e8b0e3d Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 12 May 2026 21:30:12 -0300 Subject: [PATCH 01/35] refactor: clarify auth submit loading state --- app/change-password/change-password.model.ts | 9 +++++---- app/login/login.model.ts | 8 ++++---- app/register/register.model.ts | 9 +++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/change-password/change-password.model.ts b/app/change-password/change-password.model.ts index 889316a..1af59e8 100644 --- a/app/change-password/change-password.model.ts +++ b/app/change-password/change-password.model.ts @@ -13,7 +13,8 @@ import { mutate } from "swr"; export const useChangePasswordModel = () => { const router = useRouter(); - const [isLoading, setIsLoading] = useState(false); + const [isSubmittingPasswordChange, setIsSubmittingPasswordChange] = + useState(false); const form = useForm({ resolver: zodResolver(changePasswordSchema), @@ -25,7 +26,7 @@ export const useChangePasswordModel = () => { }); const onSubmit = async (data: ChangePasswordFormData) => { - setIsLoading(true); + setIsSubmittingPasswordChange(true); try { await api .post("auth/change-password", { @@ -51,13 +52,13 @@ export const useChangePasswordModel = () => { toast.error("Não foi possível alterar a senha. Tente novamente."); } } finally { - setIsLoading(false); + setIsSubmittingPasswordChange(false); } }; return { form, onSubmit, - isLoading, + isLoading: isSubmittingPasswordChange, }; }; diff --git a/app/login/login.model.ts b/app/login/login.model.ts index dc25b07..77a97da 100644 --- a/app/login/login.model.ts +++ b/app/login/login.model.ts @@ -15,7 +15,7 @@ const WAREHOUSE_STORAGE_KEY = "selected-warehouse-id"; export const useLoginModel = () => { const router = useRouter(); const { setUser } = useAuth(); - const [isLoading, setIsLoading] = useState(false); + const [isSubmittingLogin, setIsSubmittingLogin] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [requiresCaptcha, setRequiresCaptcha] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false); @@ -56,7 +56,7 @@ export const useLoginModel = () => { return; } - setIsLoading(true); + setIsSubmittingLogin(true); try { const payload = requiresCaptcha ? { ...data, captchaToken } : data; @@ -111,14 +111,14 @@ export const useLoginModel = () => { resetCaptcha(); } finally { - setIsLoading(false); + setIsSubmittingLogin(false); } }; return { form, onSubmit, - isLoading, + isLoading: isSubmittingLogin, errorMessage, requiresCaptcha, isPasswordVisible, diff --git a/app/register/register.model.ts b/app/register/register.model.ts index 31ec576..0606c31 100644 --- a/app/register/register.model.ts +++ b/app/register/register.model.ts @@ -12,7 +12,8 @@ import { HTTPError } from "ky"; export const useRegisterModel = () => { const router = useRouter(); const { setUser } = useAuth(); - const [isLoading, setIsLoading] = useState(false); + const [isSubmittingRegistration, setIsSubmittingRegistration] = + useState(false); const form = useForm({ resolver: zodResolver(registerSchema), @@ -25,7 +26,7 @@ export const useRegisterModel = () => { }); const onSubmit = async (data: RegisterFormData) => { - setIsLoading(true); + setIsSubmittingRegistration(true); try { const payload = { companyName: data.companyName, @@ -58,13 +59,13 @@ export const useRegisterModel = () => { toast.error("Falha no cadastro. Tente novamente."); } } finally { - setIsLoading(false); + setIsSubmittingRegistration(false); } }; return { form, onSubmit, - isLoading, + isLoading: isSubmittingRegistration, }; }; From cb69a6f6aa0fb4ead3c56b519a452743f8e0b8bc Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 12 May 2026 21:30:21 -0300 Subject: [PATCH 02/35] fix: load recharts components on client --- app/(pages)/sales/sales-chart.view.tsx | 57 +++++++++++++++++++++----- components/ui/chart.tsx | 47 +++++++++++++++++---- 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/app/(pages)/sales/sales-chart.view.tsx b/app/(pages)/sales/sales-chart.view.tsx index 558a5a9..523b0b1 100644 --- a/app/(pages)/sales/sales-chart.view.tsx +++ b/app/(pages)/sales/sales-chart.view.tsx @@ -1,19 +1,56 @@ "use client"; +import dynamic from "next/dynamic"; +import type { ComponentType, ReactNode } from "react"; import { useState, useEffect, useCallback } from "react"; -import { - AreaChart, - Area, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, -} from "recharts"; import type { DailyChartEntry } from "./sales.types"; import { formatCents } from "./sales.types"; +type RechartsModule = typeof import("recharts"); +type RechartsDynamicProps = Record & { + children?: ReactNode; +}; + +const loadRechartsComponent = async ( + key: Key, +): Promise> => { + const rechartsModule = await import("recharts"); + return rechartsModule[key] as unknown as ComponentType; +}; + +const Area = dynamic( + () => loadRechartsComponent("Area"), + { ssr: false }, +); +const AreaChart = dynamic( + () => loadRechartsComponent("AreaChart"), + { ssr: false }, +); +const CartesianGrid = dynamic( + () => loadRechartsComponent("CartesianGrid"), + { ssr: false }, +); +const Legend = dynamic( + () => loadRechartsComponent("Legend"), + { ssr: false }, +); +const ResponsiveContainer = dynamic( + () => loadRechartsComponent("ResponsiveContainer"), + { ssr: false }, +); +const Tooltip = dynamic( + () => loadRechartsComponent("Tooltip"), + { ssr: false }, +); +const XAxis = dynamic( + () => loadRechartsComponent("XAxis"), + { ssr: false }, +); +const YAxis = dynamic( + () => loadRechartsComponent("YAxis"), + { ssr: false }, +); + interface SalesChartProps { data: DailyChartEntry[]; } diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx index 5d6d9b4..c95491e 100644 --- a/components/ui/chart.tsx +++ b/components/ui/chart.tsx @@ -1,10 +1,43 @@ "use client" +import dynamic from "next/dynamic" import * as React from "react" -import * as RechartsPrimitive from "recharts" import { cn } from "@/lib/utils" +type RechartsModule = typeof import("recharts") +type ResponsiveContainerProps = React.ComponentProps< + RechartsModule["ResponsiveContainer"] +> +type RechartsTooltipProps = React.ComponentProps + +const loadResponsiveContainer = async (): Promise< + React.ComponentType +> => { + const rechartsModule = await import("recharts") + return rechartsModule.ResponsiveContainer as unknown as React.ComponentType< + ResponsiveContainerProps + > +} + +const loadChartTooltip = async (): Promise< + React.ComponentType +> => { + const rechartsModule = await import("recharts") + return rechartsModule.Tooltip as unknown as React.ComponentType< + RechartsTooltipProps + > +} + +const ResponsiveContainer = dynamic( + loadResponsiveContainer, + { ssr: false } +) +const ChartTooltip = dynamic( + loadChartTooltip, + { ssr: false } +) + // Format: { THEME_NAME: CSS_SELECTOR } const THEMES = { light: "", dark: ".dark" } as const @@ -42,9 +75,7 @@ function ChartContainer({ ...props }: React.ComponentProps<"div"> & { config: ChartConfig - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >["children"] + children: ResponsiveContainerProps["children"] }) { const uniqueId = React.useId() const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` @@ -61,9 +92,9 @@ function ChartContainer({ {...props} > - + {children} - + ) @@ -116,10 +147,8 @@ ${colorConfig return null } -const ChartTooltip = RechartsPrimitive.Tooltip - type ChartTooltipContentProps = - React.ComponentProps & + RechartsTooltipProps & React.ComponentProps<"div"> & { hideLabel?: boolean hideIndicator?: boolean From f4b06e1a169d104835870b7cada2801d0f32452d Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 12 May 2026 21:30:26 -0300 Subject: [PATCH 03/35] fix: stabilize warehouse client hydration --- lib/contexts/warehouse-context.tsx | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/contexts/warehouse-context.tsx b/lib/contexts/warehouse-context.tsx index 15847bf..60bf4fa 100644 --- a/lib/contexts/warehouse-context.tsx +++ b/lib/contexts/warehouse-context.tsx @@ -1,6 +1,13 @@ "use client"; -import { createContext, use, useEffect, useState, ReactNode } from "react"; +import { + createContext, + use, + useEffect, + useState, + useSyncExternalStore, + ReactNode, +} from "react"; import { useRouter } from "next/navigation"; interface WarehouseContextValue { @@ -12,13 +19,25 @@ const WarehouseContext = createContext(undefi const WAREHOUSE_STORAGE_KEY = "selected-warehouse-id"; +const subscribeToClientHydration = (onStoreChange: () => void): (() => void) => { + onStoreChange(); + return () => undefined; +}; + +const getClientSnapshot = (): boolean => true; + +const getServerSnapshot = (): boolean => false; + export const WarehouseProvider = ({ children }: { children: ReactNode }) => { const [selectedWarehouseId, setSelectedWarehouseIdState] = useState(null); - const [isClient, setIsClient] = useState(false); + const isClient = useSyncExternalStore( + subscribeToClientHydration, + getClientSnapshot, + getServerSnapshot, + ); const { push } = useRouter(); useEffect(() => { - setIsClient(true); const stored = localStorage.getItem(WAREHOUSE_STORAGE_KEY); if (stored) { setSelectedWarehouseIdState(stored); From 4e90669cc97ab330cce914c43878184980f9c2c6 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 12 May 2026 21:30:32 -0300 Subject: [PATCH 04/35] refactor: improve batch model state handling --- .../batches/[id]/edit/batches-edit.model.ts | 44 +++++++++---------- app/(pages)/batches/batches.model.ts | 21 +++++---- .../batches/create/batches-create.model.ts | 23 +++++----- 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/app/(pages)/batches/[id]/edit/batches-edit.model.ts b/app/(pages)/batches/[id]/edit/batches-edit.model.ts index 5c0ec9c..b27bf41 100644 --- a/app/(pages)/batches/[id]/edit/batches-edit.model.ts +++ b/app/(pages)/batches/[id]/edit/batches-edit.model.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useMemo } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; @@ -9,6 +9,18 @@ import type { BatchEditResponse } from "./batches-edit.types"; import type { Batch } from "../../batches.types"; import { useBreadcrumb } from "@/components/breadcrumb"; +const DEFAULT_BATCH_EDIT_FORM_VALUES: BatchEditFormData = { + productId: "", + warehouseId: "", + quantity: 1, + batchCode: "", + manufacturedDate: "", + expirationDate: "", + costPrice: undefined, + sellingPrice: undefined, + notes: "", +}; + export const mapBatchToFormValues = (batch: Batch): BatchEditFormData => ({ productId: batch.productId, warehouseId: batch.warehouseId, @@ -23,21 +35,6 @@ export const mapBatchToFormValues = (batch: Batch): BatchEditFormData => ({ export const useBatchEditModel = (batchId: string) => { const router = useRouter(); - const form = useForm({ - resolver: zodResolver(batchEditSchema), - defaultValues: { - productId: "", - warehouseId: "", - quantity: 1, - batchCode: "", - manufacturedDate: "", - expirationDate: "", - costPrice: undefined, - sellingPrice: undefined, - notes: "", - }, - }); - const { data, isLoading } = useSWR( batchId ? `batches/${batchId}` : null, async (url: string) => { @@ -47,6 +44,15 @@ export const useBatchEditModel = (batchId: string) => { ); const batch = data?.data || null; + const formValues = useMemo( + () => (batch ? mapBatchToFormValues(batch) : DEFAULT_BATCH_EDIT_FORM_VALUES), + [batch], + ); + const form = useForm({ + resolver: zodResolver(batchEditSchema), + defaultValues: DEFAULT_BATCH_EDIT_FORM_VALUES, + values: formValues, + }); useBreadcrumb({ title: batch?.batchNumber || batch?.batchCode || "Carregando...", @@ -55,12 +61,6 @@ export const useBatchEditModel = (batchId: string) => { subsection: "Edição", }); - useEffect(() => { - if (batch) { - form.reset(mapBatchToFormValues(batch)); - } - }, [batch, form]); - const onSubmit = async (values: BatchEditFormData) => { try { const { api } = await import("@/lib/api"); diff --git a/app/(pages)/batches/batches.model.ts b/app/(pages)/batches/batches.model.ts index de2eb1a..38b8629 100644 --- a/app/(pages)/batches/batches.model.ts +++ b/app/(pages)/batches/batches.model.ts @@ -1,5 +1,5 @@ import { differenceInCalendarDays, isValid, parseISO } from "date-fns"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import useSWR from "swr"; import type { Batch, @@ -162,11 +162,10 @@ export const useBatchesModel = () => { ), ); - useEffect(() => { - if (selectedWarehouseId) { - setFilters((prev) => ({ ...prev, warehouseId: selectedWarehouseId })); - } - }, [selectedWarehouseId]); + const effectiveFilters = useMemo(() => { + if (!selectedWarehouseId) return filters; + return { ...filters, warehouseId: selectedWarehouseId }; + }, [filters, selectedWarehouseId]); const { data, error, isLoading, mutate } = useSWR( "batches", @@ -177,8 +176,8 @@ export const useBatchesModel = () => { ); const filtered = useMemo( - () => filterBatches(data?.data ?? [], filters), - [data, filters], + () => filterBatches(data?.data ?? [], effectiveFilters), + [data, effectiveFilters], ); const sorted = useMemo( @@ -244,7 +243,7 @@ export const useBatchesModel = () => { const onOpenMobileFilters = () => { setMobileFiltersDraft( - buildFilterDraft(filters, sortConfig, isGroupedByProduct), + buildFilterDraft(effectiveFilters, sortConfig, isGroupedByProduct), ); setIsMobileFiltersOpen(true); }; @@ -285,7 +284,7 @@ export const useBatchesModel = () => { const onClearMobileFilters = () => { const nextFilters = { - ...filters, + ...effectiveFilters, searchQuery: "", status: "all" as const, lowStockThreshold: DEFAULT_LOW_STOCK_THRESHOLD, @@ -302,7 +301,7 @@ export const useBatchesModel = () => { groupedByProduct, isLoading, error, - filters, + filters: effectiveFilters, sortConfig, isGroupedByProduct, isMobileFiltersOpen, diff --git a/app/(pages)/batches/create/batches-create.model.ts b/app/(pages)/batches/create/batches-create.model.ts index cbb2a6d..34a8e36 100644 --- a/app/(pages)/batches/create/batches-create.model.ts +++ b/app/(pages)/batches/create/batches-create.model.ts @@ -22,6 +22,11 @@ import { useBreadcrumb } from "@/components/breadcrumb"; import { useSelectedWarehouse } from "@/hooks/use-selected-warehouse"; const PRODUCT_SEARCH_LIMIT = 5; +const priceFormatter = new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", +}); +const batchCreatedAtFormatter = new Intl.DateTimeFormat("pt-BR"); export const buildProductSearchUrl = (query: string): string | null => { const trimmedQuery = query.trim(); @@ -57,20 +62,18 @@ export const formatProductOptionLabel = ( export const formatPriceFromCents = (cents: number | null): string => { if (cents === null) return "Sem preço"; - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - }).format(cents / 100); + return priceFormatter.format(cents / 100); }; export const findMostRecentProductBatch = ( batches: ProductBatchPriceSource[], ): ProductBatchPriceSource | null => { - if (batches.length === 0) return null; - - return [...batches].sort((firstBatch, secondBatch) => { - return getBatchCreatedTime(secondBatch) - getBatchCreatedTime(firstBatch); - })[0]; + return batches.reduce((latestBatch, batch) => { + if (!latestBatch) return batch; + return getBatchCreatedTime(batch) > getBatchCreatedTime(latestBatch) + ? batch + : latestBatch; + }, null); }; export const buildLatestBatchPriceSuggestion = ( @@ -97,7 +100,7 @@ const getBatchCreatedTime = (batch: ProductBatchPriceSource): number => { const formatBatchCreatedAt = (createdAt: string): string => { const timestamp = new Date(createdAt).getTime(); if (!Number.isFinite(timestamp)) return createdAt; - return new Intl.DateTimeFormat("pt-BR").format(new Date(timestamp)); + return batchCreatedAtFormatter.format(new Date(timestamp)); }; export const buildBatchPayload = ( From a1bda712e2422484972b4745fed2a3aab5719166 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 12 May 2026 21:30:43 -0300 Subject: [PATCH 05/35] fix: refine stock movement draft routing --- .../create-stock-movement.model.test.ts | 11 +++-- .../create/create-stock-movement.model.ts | 9 +--- .../new-product-inline.model.test.ts | 31 +++++++++----- .../new-product/new-product-inline.model.ts | 42 +++++++++++-------- .../stock-movement-batch-pricing.model.ts | 16 ++++--- 5 files changed, 65 insertions(+), 44 deletions(-) diff --git a/app/(pages)/stock-movements/create/create-stock-movement.model.test.ts b/app/(pages)/stock-movements/create/create-stock-movement.model.test.ts index 75bae9a..414e1ad 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.model.test.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.model.test.ts @@ -507,14 +507,17 @@ describe("useCreateStockMovementModel", () => { }); }); - it("impede inicialização sem tipo válido e redireciona", () => { + it("mantém modelo sem tipo quando a rota não fornece tipo válido", () => { fakeSearchParams.setType(null); - renderHook(() => useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") })); + const { result } = renderHook(() => + useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") }), + ); - expect(fakeToast.error).toHaveBeenCalledWith( + expect(result.current.form.getValues("type")).toBeUndefined(); + expect(fakeToast.error).not.toHaveBeenCalledWith( "Selecione o tipo de movimentação antes de continuar.", ); - expect(fakeRouter.replace).toHaveBeenCalledWith("/stock-movements"); + expect(fakeRouter.replace).not.toHaveBeenCalledWith("/stock-movements"); }); it("carrega rascunho existente e limpa do storage", () => { diff --git a/app/(pages)/stock-movements/create/create-stock-movement.model.ts b/app/(pages)/stock-movements/create/create-stock-movement.model.ts index ea130fa..44e3b90 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.model.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.model.ts @@ -251,14 +251,9 @@ export function useCreateStockMovementModel({ }); useEffect(() => { - if (!selectedMovementType) { - toast.error("Selecione o tipo de movimentação antes de continuar."); - router.replace("/stock-movements"); - return; - } - + if (!selectedMovementType) return; form.setValue("type", selectedMovementType, { shouldValidate: true }); - }, [form, router, selectedMovementType]); + }, [form, selectedMovementType]); const { fields, append, remove, update } = useFieldArray({ control: form.control, diff --git a/app/(pages)/stock-movements/create/new-product/new-product-inline.model.test.ts b/app/(pages)/stock-movements/create/new-product/new-product-inline.model.test.ts index cab85c5..38693e6 100644 --- a/app/(pages)/stock-movements/create/new-product/new-product-inline.model.test.ts +++ b/app/(pages)/stock-movements/create/new-product/new-product-inline.model.test.ts @@ -1,4 +1,4 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useNewProductInlineModel } from "./new-product-inline.model"; import type { ProductCreateFormData } from "../../../products/create/products-create.types"; @@ -8,6 +8,9 @@ const mockSWR = vi.fn(); const mockGet = vi.fn(); const mockPush = vi.fn(); const mockReplace = vi.fn(); +const mockRedirect = vi.fn((url: string): never => { + throw new Error(`NEXT_REDIRECT:${url}`); +}); const mockWriteDraft = vi.fn(); const toastSuccess = vi.fn(); const toastError = vi.fn(); @@ -72,6 +75,7 @@ vi.mock("@/lib/api", () => ({ })); vi.mock("next/navigation", () => ({ + redirect: (url: string) => mockRedirect(url), useRouter: () => ({ push: (...args: unknown[]) => mockPush(...args), replace: (...args: unknown[]) => mockReplace(...args), @@ -385,12 +389,15 @@ describe("useNewProductInlineModel", () => { movementType = "PURCHASE_IN"; currentDraft = null; - renderHook(() => useNewProductInlineModel({ movementType, editItem: editItemQuery })); + expect(() => + renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ), + ).toThrow("NEXT_REDIRECT:/stock-movements"); - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith("/stock-movements"); - }); - expect(toastError).toHaveBeenCalledWith( + expect(mockRedirect).toHaveBeenCalledWith("/stock-movements"); + expect(mockReplace).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalledWith( "Volte para a movimentação antes de criar o produto.", ); }); @@ -401,12 +408,14 @@ describe("useNewProductInlineModel", () => { items: [{ quantity: 1, productName: "Apenas 1 item", newProductData: { name: "Apenas 1 item" } }], }); - renderHook(() => useNewProductInlineModel({ movementType, editItem: editItemQuery })); + expect(() => + renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ), + ).toThrow("NEXT_REDIRECT:/stock-movements"); - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith("/stock-movements"); - }); - expect(mockReplace).toHaveBeenCalled(); + expect(mockRedirect).toHaveBeenCalledWith("/stock-movements"); + expect(mockReplace).not.toHaveBeenCalled(); }); it("alterna estado do scanner e registra barcode escaneado", () => { diff --git a/app/(pages)/stock-movements/create/new-product/new-product-inline.model.ts b/app/(pages)/stock-movements/create/new-product/new-product-inline.model.ts index 82fc2af..794dd75 100644 --- a/app/(pages)/stock-movements/create/new-product/new-product-inline.model.ts +++ b/app/(pages)/stock-movements/create/new-product/new-product-inline.model.ts @@ -1,10 +1,10 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import useSWR from "swr"; -import { useRouter } from "next/navigation"; +import { redirect, useRouter } from "next/navigation"; import { toast } from "sonner"; import { useBreadcrumb } from "@/components/breadcrumb"; @@ -107,6 +107,18 @@ const buildInlineMovementItem = ( newProductData: product, }); +const isInlineProductRouteReady = ( + movementType: string | null, + initialDraft: ReturnType, + editItemIndex: number | null, + isEditingInlineProduct: boolean, +): boolean => { + const hasValidEditItem = editItemIndex === null || isEditingInlineProduct; + return Boolean( + isManualMovementType(movementType) && initialDraft && hasValidEditItem, + ); +}; + const appendProductToMovementDraft = ( product: InlineProductData, quantity: number, @@ -156,6 +168,17 @@ export const useNewProductInlineModel = ({ editItemIndex !== null ? initialDraft?.items[editItemIndex] : undefined; const editedProduct = editedItem?.newProductData; const isEditingInlineProduct = Boolean(editedProduct); + if ( + !isInlineProductRouteReady( + movementType, + initialDraft, + editItemIndex, + isEditingInlineProduct, + ) + ) { + redirect("/stock-movements"); + } + const [customAttributes, setCustomAttributes] = useState( () => buildCustomAttributes(editedProduct?.attributes), ); @@ -173,21 +196,6 @@ export const useNewProductInlineModel = ({ subsection: isEditingInlineProduct ? "Editar Produto" : "Produto Inline", }); - useEffect(() => { - const hasValidEditItem = editItemIndex === null || isEditingInlineProduct; - if (isManualMovementType(movementType) && initialDraft && hasValidEditItem) { - return; - } - toast.error("Volte para a movimentação antes de criar o produto."); - router.replace("/stock-movements"); - }, [ - editItemIndex, - initialDraft, - isEditingInlineProduct, - movementType, - router, - ]); - const { data: categoriesData, isLoading: isLoadingCategories } = useSWR("categories", (url: string) => api.get(url).json(), diff --git a/app/(pages)/stock-movements/create/stock-movement-batch-pricing.model.ts b/app/(pages)/stock-movements/create/stock-movement-batch-pricing.model.ts index 76c3ec2..31bb672 100644 --- a/app/(pages)/stock-movements/create/stock-movement-batch-pricing.model.ts +++ b/app/(pages)/stock-movements/create/stock-movement-batch-pricing.model.ts @@ -21,6 +21,7 @@ const currencyFormatter = new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL", }); +const batchCreatedAtFormatter = new Intl.DateTimeFormat("pt-BR"); export const buildExistingProductBatchesUrl = ( warehouseId: string | null | undefined, @@ -44,10 +45,15 @@ export const formatStockMovementBatchPrice = (cents: number): string => { export const findMostRecentWarehouseProductBatch = ( batches: StockMovementProductBatchPriceSource[], ): StockMovementProductBatchPriceSource | null => { - if (batches.length === 0) return null; - return [...batches].sort((firstBatch, secondBatch) => { - return getBatchCreatedTime(secondBatch) - getBatchCreatedTime(firstBatch); - })[0]; + return batches.reduce( + (latestBatch, batch) => { + if (!latestBatch) return batch; + return getBatchCreatedTime(batch) > getBatchCreatedTime(latestBatch) + ? batch + : latestBatch; + }, + null, + ); }; export const buildExistingProductSalePriceSuggestion = ( @@ -105,7 +111,7 @@ const getBatchCreatedTime = ( const formatBatchCreatedAt = (createdAt: string): string => { const timestamp = new Date(createdAt).getTime(); if (!Number.isFinite(timestamp)) return createdAt; - return new Intl.DateTimeFormat("pt-BR").format(new Date(timestamp)); + return batchCreatedAtFormatter.format(new Date(timestamp)); }; const resolveProfitValues = ({ From 2a0c17f75a7164fd453f02abacc2115a498728c1 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 12 May 2026 21:30:51 -0300 Subject: [PATCH 06/35] refactor: split product image dropzone states --- components/product/image-dropzone.tsx | 583 ++++++++++++++------------ 1 file changed, 310 insertions(+), 273 deletions(-) diff --git a/components/product/image-dropzone.tsx b/components/product/image-dropzone.tsx index e82a717..30a692b 100644 --- a/components/product/image-dropzone.tsx +++ b/components/product/image-dropzone.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import { useCallback, useState, useEffect } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Upload, X, Image as ImageIcon, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { compressImage } from "@/lib/image-compressor"; @@ -16,6 +16,258 @@ interface ImageDropzoneProps { text?: string; } +interface ImageActionProps { + disabled: boolean; + onRemove: () => void; + onReplace: () => void; +} + +interface EmptyImageDropzoneProps { + disabled: boolean; + isDragging: boolean; + text?: string; + onDragOver: (event: React.DragEvent) => void; + onDragLeave: (event: React.DragEvent) => void; + onDrop: (event: React.DragEvent) => void; + onFileInput: (event: React.ChangeEvent) => void; +} + +const ACCEPTED_IMAGE_TYPES = "image/png,image/jpeg,image/jpg,image/webp"; +const VALID_IMAGE_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/webp"]; +const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; + +const useObjectUrl = (file: File | null): string | null => { + const objectUrl = useMemo(() => { + return file ? URL.createObjectURL(file) : null; + }, [file]); + + useEffect(() => { + return () => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [objectUrl]); + + return objectUrl; +}; + +const validateImageFile = (file: File): boolean => { + if (!VALID_IMAGE_TYPES.includes(file.type)) { + alert("Formato inválido. Use PNG, JPG, JPEG ou WEBP"); + return false; + } + + if (file.size > MAX_IMAGE_SIZE_BYTES) { + alert("Imagem muito grande. Tamanho máximo: 5MB"); + return false; + } + + return true; +}; + +const ImagePreviewFrame = ({ + src, + alt, +}: { + src: string; + alt: string; +}) => ( +
+ {alt} +
+); + +const RemoveImageButton = ({ + disabled, + onRemove, +}: Pick) => ( + +); + +const SelectedImagePanel = ({ + previewUrl, + value, + disabled, + onRemove, +}: { + previewUrl: string; + value: File; + disabled: boolean; + onRemove: () => void; +}) => ( + + +
+
+

+ {value.name || "Imagem selecionada"} +

+

+ {(value.size / 1024).toFixed(0)} KB +

+
+ +
+
+); + +const CurrentImagePanel = ({ + currentImageUrl, + disabled, + onRemove, + onReplace, +}: { + currentImageUrl: string; +} & ImageActionProps) => ( + + +
+
+

+ Imagem atual do produto +

+

+ Gerenciado pelo sistema +

+
+
+ + +
+
+
+); + +const ReplaceImageButton = ({ + disabled, + onReplace, +}: Pick) => ( + +); + +const RemovedImagePanel = ({ + disabled, + onReplace, +}: Pick) => ( +
+
+
+ +

+ Imagem será removida ao salvar +

+
+
+
+ +
+
+); + +const CompressingImagePanel = () => ( +
+
+
+ +
+
+

+ Comprimindo imagem… +

+

+ Otimizando para upload +

+
+
+
+); + +const EmptyImageDropzone = ({ + disabled, + isDragging, + text, + onDragOver, + onDragLeave, + onDrop, + onFileInput, +}: EmptyImageDropzoneProps) => ( +
+ +
+
+ {isDragging ? ( + + ) : ( + + )} +
+
+

+ {isDragging ? "Solte a imagem aqui" : text || "Arraste uma imagem ou clique"} +

+

+ PNG, JPG, JPEG ou WEBP • Máx 5MB +

+
+
+
+); + +const ImagePanel = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + export const ImageDropzone = ({ onImageSelect, value, @@ -23,318 +275,103 @@ export const ImageDropzone = ({ currentImageUrl, onRemoveImage, className, - text + text, }: ImageDropzoneProps) => { const [isDragging, setIsDragging] = useState(false); - const [preview, setPreview] = useState(null); const [showRemovalIndicator, setShowRemovalIndicator] = useState(false); const [isCompressing, setIsCompressing] = useState(false); + const previewUrl = useObjectUrl(value); + const rootClassName = className ? `w-full ${className}` : "w-full"; - // Sync preview with value prop changes (e.g. from AI Fill) - useEffect(() => { - if (value) { - // If we have a value but no preview, or if the value object reference changed - // (assuming external updates pass a new File object) - const reader = new FileReader(); - reader.onloadend = () => { - setPreview(reader.result as string); - }; - reader.readAsDataURL(value); - } else { - setPreview(null); - } - }, [value]); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - if (!disabled) { - setIsDragging(true); - } + const handleDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + if (!disabled) setIsDragging(true); }, [disabled]); - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); + const handleDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault(); setIsDragging(false); }, []); - const validateFile = (file: File): boolean => { - const validTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"]; - const maxSize = 5 * 1024 * 1024; // 5MB - - if (!validTypes.includes(file.type)) { - alert("Formato inválido. Use PNG, JPG, JPEG ou WEBP"); - return false; - } - - if (file.size > maxSize) { - alert("Imagem muito grande. Tamanho máximo: 5MB"); - return false; - } - - return true; - }; - const handleFile = useCallback(async (file: File) => { - if (!validateFile(file)) { - return; - } - + if (!validateImageFile(file)) return; setIsCompressing(true); try { - // Compress image before passing to parent - const compressedFile = await compressImage(file, 0.7); - onImageSelect(compressedFile); - - // Create preview from compressed file - const reader = new FileReader(); - reader.onloadend = () => { - setPreview(reader.result as string); - }; - reader.readAsDataURL(compressedFile); + onImageSelect(await compressImage(file, 0.7)); } catch (error) { console.error("Error compressing image:", error); - // Fallback to original file if compression fails onImageSelect(file); - const reader = new FileReader(); - reader.onloadend = () => { - setPreview(reader.result as string); - }; - reader.readAsDataURL(file); } finally { setIsCompressing(false); } }, [onImageSelect]); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); + const handleDrop = useCallback((event: React.DragEvent) => { + event.preventDefault(); setIsDragging(false); - if (disabled) return; - - const files = e.dataTransfer.files; - if (files && files.length > 0) { - handleFile(files[0]); - } + const [file] = Array.from(event.dataTransfer.files); + if (file) void handleFile(file); }, [disabled, handleFile]); - const handleFileInput = useCallback((e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - handleFile(files[0]); - } + const handleFileInput = useCallback(( + event: React.ChangeEvent, + ) => { + const [file] = Array.from(event.target.files ?? []); + if (file) void handleFile(file); }, [handleFile]); const handleRemove = useCallback(() => { onImageSelect(null); - setPreview(null); - if (onRemoveImage) { - onRemoveImage(); - setShowRemovalIndicator(true); - } + if (!onRemoveImage) return; + onRemoveImage(); + setShowRemovalIndicator(true); }, [onImageSelect, onRemoveImage]); const handleReplace = useCallback(() => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'image/png,image/jpeg,image/jpg,image/webp'; - input.onchange = (e) => { - const files = (e.target as HTMLInputElement).files; - if (files && files.length > 0) { - handleFile(files[0]); - setShowRemovalIndicator(false); - } - input.remove(); // Cleanup the input element + const input = document.createElement("input"); + input.type = "file"; + input.accept = ACCEPTED_IMAGE_TYPES; + input.onchange = (event) => { + const [file] = Array.from((event.target as HTMLInputElement).files ?? []); + if (file) void handleFile(file); + setShowRemovalIndicator(false); + input.remove(); }; input.click(); }, [handleFile]); - const rootClassName = className ? `w-full ${className}` : "w-full"; - - // State 1: New image selected (preview exists) - if (preview && value) { - return ( -
-
-
- Preview -
-
-
-

- {value?.name || "Imagem selecionada"} -

-

- {value ? `${(value.size / 1024).toFixed(0)} KB` : ""} -

-
- -
-
-
- ); - } - - // State 2: Current image exists (edit mode, no new image) - if (currentImageUrl && !showRemovalIndicator) { - return ( -
-
-
- Imagem atual -
-
-
-

- Imagem atual do produto -

-

- Gerenciado pelo sistema -

-
-
- - -
-
-
-
- ); - } - - // State 3: Image was removed (edit mode, removal indicated) - if (showRemovalIndicator) { - return ( -
-
-
-
- -

- Imagem será removida ao salvar -

-
-
-
- -
-
-
- ); - } - - // State 4: Compressing image - if (isCompressing) { - return ( -
-
-
-
- -
-
-

- Comprimindo imagem… -

-

- Otimizando para upload -

-
-
-
-
- ); - } - - // State 5: No image (create mode or empty) return (
-
- -
-
- {isDragging ? ( - - ) : ( - - )} -
-
-

- {isDragging ? "Solte a imagem aqui" : (text || "Arraste uma imagem ou clique")} -

-

- PNG, JPG, JPEG ou WEBP • Máx 5MB -

-
-
-
+ ) : currentImageUrl && !showRemovalIndicator ? ( + + ) : showRemovalIndicator ? ( + + ) : isCompressing ? ( + + ) : ( + + )}
); }; From b5277afa55f3a4d7f121a6571b5f42733a5a2bf5 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 12 May 2026 21:30:58 -0300 Subject: [PATCH 07/35] refactor: redesign batch views --- .../batches/[id]/edit/batches-edit.view.tsx | 750 ++++--- app/(pages)/batches/batches.view.tsx | 1854 +++++++++-------- .../batches/create/batches-create.view.tsx | 1116 +++++----- 3 files changed, 2063 insertions(+), 1657 deletions(-) diff --git a/app/(pages)/batches/[id]/edit/batches-edit.view.tsx b/app/(pages)/batches/[id]/edit/batches-edit.view.tsx index cdc43b1..334c6d7 100644 --- a/app/(pages)/batches/[id]/edit/batches-edit.view.tsx +++ b/app/(pages)/batches/[id]/edit/batches-edit.view.tsx @@ -12,6 +12,7 @@ import { Plus, Save, } from "lucide-react"; +import type { ReactNode } from "react"; import type { UseFormReturn } from "react-hook-form"; import type { BatchEditFormData } from "./batches-edit.schema"; import type { Batch } from "../../batches.types"; @@ -40,12 +41,20 @@ interface BatchEditViewProps { batch?: Batch | null; } -export const BatchEditView = ({ - form, - onSubmit, - isLoading, - batch, -}: BatchEditViewProps) => { +interface BatchEditViewState extends BatchEditViewProps { + batchCode: string | undefined; + handleQuantityDecrement: () => void; + handleQuantityIncrement: () => void; + isProfitable: boolean; + isSubmitting: boolean; + margin: number; + productLabel: string; + profit: number; + warehouseLabel: string; +} + +export const BatchEditView = (props: BatchEditViewProps) => { + const { form, onSubmit, isLoading, batch } = props; const { isSubmitting } = form.formState; const costPrice = form.watch("costPrice") || 0; @@ -76,6 +85,18 @@ export const BatchEditView = ({ const handleQuantityDecrement = () => { updateQuantity((form.getValues("quantity") || 1) - 1); }; + const viewState: BatchEditViewState = { + ...props, + batchCode, + handleQuantityDecrement, + handleQuantityIncrement, + isProfitable, + isSubmitting, + margin, + productLabel, + profit, + warehouseLabel, + }; if (isLoading) { return ( @@ -92,341 +113,406 @@ export const BatchEditView = ({
- - -
- - - Identificação e Origem - -
-
- -
- ( - - - Produto - - - - - - )} - /> - - ( - - - Warehouse - - - - - - )} - /> -
- - {batchCode && ( -
-
- Código gerado -
-
- {batchCode} -
-
- )} -
-
- - - -
- - - Financeiro e Estoque - -
-
- -
- { - const { onChange, value, ...rest } = field; - return ( - - - Quantidade - -
- - - - - -
- -
- ); - }} - /> - - { - const { onChange, value, ...rest } = field; - return ( - - - Custo Unitário - - - - - - - ); - }} - /> - - { - const { onChange, value, ...rest } = field; - return ( - - - Preço de Venda - - - - - - - ); - }} - /> -
- -
- - Lucro Estimado - -
- - {(profit / 100).toLocaleString("pt-BR", { - style: "currency", - currency: "BRL", - })} - -
- {margin.toFixed(1)}% -
-
-
-
-
+ +
- - -
- - - Vigência - -
-
- - ( - - - Data de Fabricação - - - - - - - )} - /> - - ( - - - Data de Validade - - - - - - - Mantenha vazio quando o produto não tiver validade. - - - - )} - /> - -
- - - -
- - - Observações - -
-
- - ( - - -