From 4239361d992ea5d79b08d4976eaa14082716b846 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 9 Jun 2026 20:52:13 -0300 Subject: [PATCH 01/32] docs: add CHANGELOG.md with project history --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2fba3c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +### Added + +- Added production-only Microsoft Clarity tracking. +- Added AI product image prompt generation, including prompt management, price overlay controls, image sharing support, and a dedicated prompt generation page. +- Added a stock movement detail page with model, view, types, and focused tests. +- Added IndexedDB-backed stock movement draft persistence and runtime recovery tracking. +- Added stock movement submit progress feedback. +- Added product form image processing locks to prevent submitting while images are still being processed. + +### Changed + +- Redesigned the main operational views for batches, products, catalog setup, sales, stock movements, system administration, transfers, and warehouses. +- Modernized the product detail page with a restructured layout and fixed bottom action bar. +- Moved product prompt generation out of the product prompt list flow and into its own page. +- Split product image dropzone UI states for clearer current, selected, removed, and processing behavior. +- Centralized dynamic chart component loading for the sales chart. +- Improved batch model state handling and authentication submit loading state clarity. + +### Fixed + +- Fixed Recharts client-only loading issues. +- Stabilized warehouse client hydration. +- Refined stock movement draft routing and cleaned up lingering debounce timers in tests. +- Fixed uploaded company logo preview rendering. +- Fixed iOS PWA product prompt sharing flows by restoring touch interaction, returning to the prompt list, and blocking unsupported file sharing when needed. +- Preserved product image removal state when a replacement file picker is cancelled. +- Autofilled prompt pricing from the latest batch after async batch data loads. +- Fixed prompt share cleanup callback types so production builds pass type checking. + +### Maintenance + +- Added a React Doctor report. +- Updated project agent instructions. +- Expanded model and browser tests around product prompts, stock movement details, draft storage, and image handling. From ecd53f6d0b7137bf73a18c204920c57a647e596b Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 9 Jun 2026 20:52:21 -0300 Subject: [PATCH 02/32] feat(barcode-scanner): add error modal with copy content functionality --- components/product/barcode-scanner.tsx | 77 +++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/components/product/barcode-scanner.tsx b/components/product/barcode-scanner.tsx index 0e6b59d..75ca466 100644 --- a/components/product/barcode-scanner.tsx +++ b/components/product/barcode-scanner.tsx @@ -5,6 +5,8 @@ import { Scanner, type IScannerProps, } from "@yudiel/react-qr-scanner"; +import { Button } from "@/components/ui/button"; +import { ResponsiveModal } from "@/components/ui/responsive-modal"; import { barcodeScannerFormats, createBarcodeScannerCameraConstraints, @@ -16,6 +18,26 @@ type BarcodeScannerProps = Omit; const BARCODE_SCANNER_DEVICE_REFRESH_DELAYS_MS = [750, 2500] as const; +const formatBarcodeScannerError = (error: unknown): string => { + if (error instanceof Error) { + return JSON.stringify( + { + name: error.name, + message: error.message, + stack: error.stack, + }, + null, + 2, + ); + } + + try { + return JSON.stringify(error, null, 2); + } catch { + return String(error); + } +}; + export const BarcodeScanner = ({ onError, onScan, @@ -24,6 +46,7 @@ export const BarcodeScanner = ({ const [usesCompatibleCamera, setUsesCompatibleCamera] = useState(false); const [cameraDeviceIds, setCameraDeviceIds] = useState([]); const [cameraDeviceIndex, setCameraDeviceIndex] = useState(0); + const [scannerErrorContent, setScannerErrorContent] = useState(null); const selectedCameraDeviceId = cameraDeviceIds[cameraDeviceIndex] ?? null; const cameraConstraints = useMemo( @@ -56,6 +79,8 @@ export const BarcodeScanner = ({ const handleScannerError = useCallback( (error: unknown) => { + setScannerErrorContent(formatBarcodeScannerError(error)); + if (shouldRetryBarcodeScannerCamera(error)) { selectNextCameraConfiguration(); } @@ -66,6 +91,11 @@ export const BarcodeScanner = ({ [onError, refreshCameraDevices, selectNextCameraConfiguration], ); + const handleCopyScannerError = useCallback((): void => { + if (!scannerErrorContent) return; + void navigator.clipboard?.writeText(scannerErrorContent); + }, [scannerErrorContent]); + useEffect(() => { void refreshCameraDevices(); @@ -84,13 +114,44 @@ export const BarcodeScanner = ({ }, [cameraDeviceIds.length]); return ( - + <> + + !open && setScannerErrorContent(null)} + title="Erro no leitor" + description="Conteúdo capturado pelo leitor de código de barras." + footer={ + <> + + + + } + > +
+          {scannerErrorContent}
+        
+
+ ); }; From f8d183c58493a0d1909f0517c96442bd36feb887 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 9 Jun 2026 20:52:29 -0300 Subject: [PATCH 03/32] feat(product-form): add existing product modal and batch overlay types --- .../products/components/product-form.types.ts | 59 ++++++++++++++++- .../products/components/product-form.view.tsx | 64 +++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/app/(pages)/products/components/product-form.types.ts b/app/(pages)/products/components/product-form.types.ts index 6bbc16c..be9fe90 100644 --- a/app/(pages)/products/components/product-form.types.ts +++ b/app/(pages)/products/components/product-form.types.ts @@ -2,6 +2,11 @@ import { UseFormReturn } from "react-hook-form"; import { ProductCreateFormData, AiFillData } from "../create/products-create.types"; import { CustomAttribute } from "@/components/product/custom-attributes-builder"; +import type { + ExistingProductBatchFormState, + ExistingProductPriceSuggestion, + ExistingProductProfitSummary, +} from "../../stock-movements/create/create-stock-movement.types"; interface Category { id: string; @@ -45,6 +50,33 @@ export interface BatchesDrawerProps { form: UseFormReturn<{ batches: BatchDrawerFormItem[] }>; } +export interface ExistingProductInfo { + id: string; + name: string; + barcode: string; +} + +export interface NewProductBatchOverlay { + batchForm: ExistingProductBatchFormState; + onBatchOpenChange: (open: boolean) => void; + onBatchQuantityChange: (quantity: string) => void; + onBatchQuantityIncrement: () => void; + onBatchQuantityDecrement: () => void; + onBatchManufacturedDateChange: (date: string) => void; + onBatchExpirationDateChange: (date: string) => void; + onBatchCostPriceChange: (price?: number) => void; + onBatchSellingPriceChange: (price?: number) => void; + onApplyBatchCostPriceSuggestion: () => void; + onApplyBatchSalePriceSuggestion: () => void; + onConfirmBatch: () => void; + batchCostPriceSuggestion: ExistingProductPriceSuggestion | null; + batchSalePriceSuggestion: ExistingProductPriceSuggestion | null; + isBatchPriceSuggestionLoading: boolean; + shouldShowMissingBatchCostPriceSuggestion: boolean; + shouldShowMissingBatchSalePriceSuggestion: boolean; + batchProfitSummary: ExistingProductProfitSummary; +} + /** * Props for the shared ProductForm component used in both create and edit modes. * @@ -92,7 +124,32 @@ export interface ProductFormProps { openScanner: () => void; closeScanner: () => void; isScannerOpen: boolean; - handleBarcodeScan: (barcode: string) => void; + handleBarcodeScan: (barcode: string) => void | Promise; + + // Existing product modal (new-product page) + scannedExistingProduct: ExistingProductInfo | null; + onExistingProductModalOpenChange?: (open: boolean) => void; + onCreateBatchForExistingProduct?: () => void; + + // Batch overlay (new-product page) + batchForm?: ExistingProductBatchFormState; + onBatchOpenChange?: (open: boolean) => void; + onBatchQuantityChange?: (quantity: string) => void; + onBatchQuantityIncrement?: () => void; + onBatchQuantityDecrement?: () => void; + onBatchManufacturedDateChange?: (date: string) => void; + onBatchExpirationDateChange?: (date: string) => void; + onBatchCostPriceChange?: (price?: number) => void; + onBatchSellingPriceChange?: (price?: number) => void; + onApplyBatchCostPriceSuggestion?: () => void; + onApplyBatchSalePriceSuggestion?: () => void; + onConfirmBatch?: () => void; + batchCostPriceSuggestion?: ExistingProductPriceSuggestion | null; + batchSalePriceSuggestion?: ExistingProductPriceSuggestion | null; + isBatchPriceSuggestionLoading?: boolean; + shouldShowMissingBatchCostPriceSuggestion?: boolean; + shouldShowMissingBatchSalePriceSuggestion?: boolean; + batchProfitSummary?: ExistingProductProfitSummary; // AI Fill isAiModalOpen?: boolean; diff --git a/app/(pages)/products/components/product-form.view.tsx b/app/(pages)/products/components/product-form.view.tsx index c52827c..5d36e47 100644 --- a/app/(pages)/products/components/product-form.view.tsx +++ b/app/(pages)/products/components/product-form.view.tsx @@ -6,6 +6,7 @@ import { CustomAttributesBuilder } from "@/components/product/custom-attributes- import { BarcodeScannerModal } from "@/components/product/barcode-scanner-modal"; import { ProductAiFillModal } from "@/components/product/product-ai-fill-modal"; import { ImageDropzone } from "@/components/product/image-dropzone"; +import { ResponsiveModal } from "@/components/ui/responsive-modal"; import { Button } from "@/components/ui/button"; import { Card, @@ -49,6 +50,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { + AlertCircle, Calendar, CheckCircle2, DollarSign, @@ -222,6 +224,7 @@ const ProductFormShell = ({ onClose={productForm.closeScanner} onScan={productForm.handleBarcodeScan} /> +
@@ -230,6 +233,67 @@ const ProductFormShell = ({ ); +const ExistingProductFoundModal = ({ + productForm, +}: { + productForm: ProductFormProps; +}) => { + const { scannedExistingProduct, onExistingProductModalOpenChange, onCreateBatchForExistingProduct } = productForm; + if (!onExistingProductModalOpenChange || !onCreateBatchForExistingProduct) return null; + + return ( + + + + + } + > +
+
+ +
+

+ Produto existente encontrado +

+

+ Não é possível criar um novo produto com este código. Deseja adicionar um novo lote ao produto existente? +

+
+
+ {scannedExistingProduct && ( +
+

+ Código de barras +

+

+ {scannedExistingProduct.barcode} +

+
+ )} +
+
+ ); +}; + const ProductAiModal = ({ productForm, viewState, From 89ae90ebc387362c71279f22429e3f0936e5b523 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 9 Jun 2026 20:52:38 -0300 Subject: [PATCH 04/32] feat(stock-movements): replace missing product toast with modal dialog --- .../create-stock-movement.model.test.ts | 11 +-- .../create/create-stock-movement.model.ts | 25 ++++--- .../create/create-stock-movement.types.ts | 3 + .../create/create-stock-movement.view.tsx | 70 +++++++++++++++++++ 4 files changed, 93 insertions(+), 16 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 92f670a..c188899 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 @@ -1122,7 +1122,7 @@ describe("useCreateStockMovementModel", () => { }); }); - it("mostra erro com ação para criação quando produto não existe", async () => { + it("abre modal de produto não encontrado quando produto não existe", async () => { fakeApi.get.mockRejectedValue(new Error("não encontrado")); const { result } = renderHook(() => useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") })); @@ -1131,13 +1131,8 @@ describe("useCreateStockMovementModel", () => { await result.current.onBarcodeScan("7891009999999"); }); - const lastCall = fakeToast.error.mock.calls.at(-1); - expect(lastCall?.[0]).toBe("Produto com código 7891009999999 não existe."); - expect(lastCall?.[1]).toMatchObject({ - action: { - label: "Criar Produto", - }, - }); + expect(result.current.missingProductBarcode).toBe("7891009999999"); + expect(fakeToast.error).not.toHaveBeenCalled(); }); it("mostra aviso sem ação para tipo de saída", async () => { 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 bcd0ef1..c85ff15 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.model.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.model.ts @@ -283,6 +283,7 @@ export function useCreateStockMovementModel({ const [addItemError, setAddItemError] = useState(null); const [isScannerOpen, setIsScannerOpen] = useState(false); const [isFooterVisible, setIsFooterVisible] = useState(false); + const [missingProductBarcode, setMissingProductBarcode] = useState(null); const [existingProductBatchForm, setExistingProductBatchForm] = useState(EMPTY_EXISTING_BATCH_FORM); const lastScannedBarcodeRef = useRef(null); @@ -901,14 +902,19 @@ export function useCreateStockMovementModel({ return; } - toast.error(`Produto com código ${barcode} não existe.`, { - action: { - label: "Criar Produto", - onClick: () => { - void navigateToInlineProductWithBarcode(barcode); - }, - }, - }); + setMissingProductBarcode(barcode); + }; + + const handleMissingProductModalOpenChange = (open: boolean): void => { + if (!open) { + setMissingProductBarcode(null); + } + }; + + const handleCreateProductFromMissingModal = async (): Promise => { + if (!missingProductBarcode) return; + await navigateToInlineProductWithBarcode(missingProductBarcode); + setMissingProductBarcode(null); }; const onSubmit = async (data: CreateStockMovementSchema) => { @@ -1000,5 +1006,8 @@ export function useCreateStockMovementModel({ shouldShowMissingSalePriceSuggestion, existingProductProfitSummary, items: fields, + missingProductBarcode, + onMissingProductModalOpenChange: handleMissingProductModalOpenChange, + onCreateProductFromMissingModal: handleCreateProductFromMissingModal, }; } diff --git a/app/(pages)/stock-movements/create/create-stock-movement.types.ts b/app/(pages)/stock-movements/create/create-stock-movement.types.ts index 3c14b62..cf070c4 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.types.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.types.ts @@ -153,6 +153,9 @@ export interface CreateStockMovementViewProps { shouldShowMissingCostPriceSuggestion: boolean; shouldShowMissingSalePriceSuggestion: boolean; existingProductProfitSummary: ExistingProductProfitSummary; + missingProductBarcode: string | null; + onMissingProductModalOpenChange: (open: boolean) => void; + onCreateProductFromMissingModal: () => void; items: Array<{ id: string; productId?: string; diff --git a/app/(pages)/stock-movements/create/create-stock-movement.view.tsx b/app/(pages)/stock-movements/create/create-stock-movement.view.tsx index bed48c4..b0a6a68 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.view.tsx +++ b/app/(pages)/stock-movements/create/create-stock-movement.view.tsx @@ -126,6 +126,7 @@ function StockMovementCreateOverlays({ onApplyExistingProductSalePriceSuggestion, onBarcodeScan, onConfirmExistingProductBatchData, + onCreateProductFromMissingModal, onExistingProductBatchCostPriceChange, onExistingProductBatchExpirationDateChange, onExistingProductBatchManufacturedDateChange, @@ -134,11 +135,13 @@ function StockMovementCreateOverlays({ onExistingProductBatchQuantityDecrement, onExistingProductBatchQuantityIncrement, onExistingProductBatchSellingPriceChange, + onMissingProductModalOpenChange, existingProductCostPriceSuggestion, existingProductProfitSummary, existingProductSalePriceSuggestion, isExistingProductPriceSuggestionLoading, isScannerOpen, + missingProductBarcode, onScannerOpenChange, shouldShowMissingCostPriceSuggestion, shouldShowMissingSalePriceSuggestion, @@ -171,6 +174,11 @@ function StockMovementCreateOverlays({ shouldShowMissingSalePriceSuggestion={shouldShowMissingSalePriceSuggestion} profitSummary={existingProductProfitSummary} /> + ); } @@ -656,3 +664,65 @@ function StockMovementSubmitOverlay({ ); } + +function StockMovementMissingProductModal({ + barcode, + onOpenChange, + onCreateProduct, +}: { + barcode: string | null; + onOpenChange: (open: boolean) => void; + onCreateProduct: () => void; +}) { + return ( + + + + + } + > +
+
+ +
+

+ Produto inexistente +

+

+ Deseja criar um novo produto com este código de barras? +

+
+
+ {barcode && ( +
+

+ Código de barras +

+

+ {barcode} +

+
+ )} +
+
+ ); +} From 434fd2a4f95da63a20382c727e351f6eda030c69 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 9 Jun 2026 20:52:46 -0300 Subject: [PATCH 05/32] feat(new-product-inline): check API for existing product on barcode scan and allow adding batch --- .../new-product-inline.model.test.ts | 80 ++++++- .../new-product/new-product-inline.model.ts | 196 +++++++++++++++++- .../create/new-product/page.client.tsx | 35 ++++ 3 files changed, 305 insertions(+), 6 deletions(-) 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 3913eeb..654d0a8 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 @@ -105,6 +105,10 @@ vi.mock("@/components/breadcrumb", () => ({ useBreadcrumb: vi.fn(), })); +vi.mock("@/hooks/use-selected-warehouse", () => ({ + useSelectedWarehouse: () => ({ warehouseId: "wh-1" }), +})); + vi.mock("../create-stock-movement.storage", () => ({ fileToInlineProductImage: async (file: File) => ({ name: file.name, @@ -501,7 +505,12 @@ describe("useNewProductInlineModel", () => { expect(mockRedirect).not.toHaveBeenCalled(); }); - it("alterna estado do scanner e registra barcode escaneado", () => { + it("alterna estado do scanner e preenche barcode quando produto não existe na API", async () => { + mockGet.mockReset(); + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => { throw new Error("não encontrado"); }), + })); + const { result } = renderHook(() => useNewProductInlineModel({ movementType, editItem: editItemQuery })); expect(result.current.isScannerOpen).toBe(false); @@ -510,10 +519,12 @@ describe("useNewProductInlineModel", () => { }); expect(result.current.isScannerOpen).toBe(true); - act(() => { - result.current.handleBarcodeScan("123456789"); + await act(async () => { + await result.current.handleBarcodeScan("123456789"); }); - expect(result.current.form.getValues("barcode")).toBe("123456789"); + + expect(mockGet).toHaveBeenCalledWith("products/barcode/123456789"); + expect(result.current.scannedExistingProduct).toBeNull(); act(() => { result.current.closeScanner(); @@ -521,6 +532,67 @@ describe("useNewProductInlineModel", () => { expect(result.current.isScannerOpen).toBe(false); }); + it("abre modal de produto existente quando barcode escaneado encontra produto", async () => { + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => ({ + success: true, + data: { id: "p-123", name: "Produto Existente", barcode: "987654321" }, + })), + })); + + const { result } = renderHook(() => useNewProductInlineModel({ movementType, editItem: editItemQuery })); + + await act(async () => { + await result.current.handleBarcodeScan("987654321"); + }); + + expect(result.current.scannedExistingProduct).toEqual({ + id: "p-123", + name: "Produto Existente", + barcode: "987654321", + }); + expect(result.current.form.getValues("barcode")).toBe("7891000000001"); + }); + + it("abre formulário de lote ao confirmar produto existente e adiciona ao draft", async () => { + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => ({ + success: true, + data: { id: "p-123", name: "Produto Existente", barcode: "987654321" }, + })), + })); + + const { result } = renderHook(() => useNewProductInlineModel({ movementType, editItem: editItemQuery })); + + await act(async () => { + await result.current.handleBarcodeScan("987654321"); + }); + + act(() => { + result.current.onCreateBatchForExistingProduct?.(); + }); + + expect(result.current.scannedExistingProduct).toBeNull(); + expect(result.current.batchForm?.isOpen).toBe(true); + expect(result.current.batchForm?.productId).toBe("p-123"); + expect(result.current.batchForm?.productName).toBe("Produto Existente"); + + act(() => { + result.current.onBatchQuantityChange?.("5"); + result.current.onBatchCostPriceChange?.(1000); + result.current.onBatchSellingPriceChange?.(1500); + }); + + await act(async () => { + await result.current.onConfirmBatch?.(); + }); + + expect(toastSuccess).toHaveBeenCalledWith("Produto Existente foi adicionado."); + expect(result.current.batchForm?.isOpen).toBe(false); + expect(result.current.form.getValues("name")).toBe(""); + expect(mockWriteDraft).toHaveBeenCalled(); + }); + it("controla quantidade com incremento e decremento sem ficar negativa", () => { const { result } = renderHook(() => useNewProductInlineModel({ movementType, editItem: editItemQuery })); 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 e7ee88c..0bca05e 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 @@ -9,6 +9,7 @@ import { toast } from "sonner"; import { useBreadcrumb } from "@/components/breadcrumb"; import { api } from "@/lib/api"; +import { useSelectedWarehouse } from "@/hooks/use-selected-warehouse"; import { CustomAttribute } from "@/components/product/custom-attributes-builder"; import { AiFillData, @@ -18,10 +19,13 @@ import { } from "../../../products/create/products-create.types"; import { applyProductAiFillData } from "../../../products/components/product-ai-fill.model"; import { productInlineSchema } from "../../../products/create/products-create.schema"; -import { ProductFormProps } from "../../../products/components/product-form.types"; +import { ProductFormProps, ExistingProductInfo } from "../../../products/components/product-form.types"; import type { InlineProductData, StockMovementDraftItem, + ExistingProductBatchFormState, + StockMovementProductBatchesResponse, + StockMovementProductOption, } from "../create-stock-movement.types"; import { isManualMovementType } from "../../stock-movements.constants"; import { @@ -31,6 +35,13 @@ import { writeStockMovementDraft, } from "../create-stock-movement.storage"; import type { StockMovementDraft } from "../create-stock-movement.storage"; +import { + buildExistingProductBatchesUrl, + buildExistingProductProfitSummary, + buildExistingProductSalePriceSuggestion, + buildExistingProductCostPriceSuggestion, + findMostRecentWarehouseProductBatch, +} from "../stock-movement-batch-pricing.model"; const buildReturnHref = (type: string | null): string => { if (!isManualMovementType(type)) return "/stock-movements/create"; @@ -108,6 +119,31 @@ const buildInlineMovementItem = ( newProductData: product, }); +const buildExistingProductBatchItem = ( + productId: string, + productName: string, + form: ExistingProductBatchFormState, +): StockMovementDraftItem => ({ + productId, + productName, + quantity: Number(form.quantity), + manufacturedDate: form.manufacturedDate || undefined, + expirationDate: form.expirationDate || undefined, + costPrice: form.costPrice, + sellingPrice: form.sellingPrice, +}); + +const EMPTY_BATCH_FORM: ExistingProductBatchFormState = { + isOpen: false, + productId: "", + productName: "", + quantity: "", + manufacturedDate: "", + expirationDate: "", + editingIndex: null, + error: null, +}; + const isInlineProductRouteReady = ( movementType: string | null, initialDraft: StockMovementDraft | null, @@ -133,6 +169,18 @@ const appendProductToMovementDraft = ( })); }; +const appendExistingProductBatchToDraft = ( + item: StockMovementDraftItem, +): Promise => { + return updateMovementDraft((draft) => ({ + ...draft, + items: [...draft.items, item], + selectedProductId: "", + itemQuantity: "", + inlineProductBarcode: undefined, + })); +}; + const updateMovementDraft = async ( buildNextDraft: (draft: StockMovementDraft) => StockMovementDraft, ): Promise => { @@ -184,7 +232,10 @@ export const useNewProductInlineModel = ({ ); const [isScannerOpen, setIsScannerOpen] = useState(false); const [isAiModalOpen, setIsAiModalOpen] = useState(false); + const [scannedExistingProduct, setScannedExistingProduct] = useState(null); + const [existingProductBatchForm, setExistingProductBatchForm] = useState(EMPTY_BATCH_FORM); const nameInputRef = useRef(null); + const { warehouseId } = useSelectedWarehouse(); useBreadcrumb({ title: isEditingInlineProduct ? "Editar Produto" : "Novo Produto", @@ -203,6 +254,28 @@ export const useNewProductInlineModel = ({ api.get(url).json(), ); + const batchProductId = existingProductBatchForm.isOpen ? existingProductBatchForm.productId : null; + const productBatchesUrl = buildExistingProductBatchesUrl(warehouseId, batchProductId); + const { data: productBatchesData, isLoading: isLoadingProductBatches } = + useSWR( + productBatchesUrl, + (url: string) => api.get(url).json(), + ); + const mostRecentBatch = findMostRecentWarehouseProductBatch(productBatchesData?.data ?? []); + const batchCostPriceSuggestion = buildExistingProductCostPriceSuggestion(mostRecentBatch); + const batchSalePriceSuggestion = buildExistingProductSalePriceSuggestion(mostRecentBatch); + const batchProfitSummary = buildExistingProductProfitSummary({ + quantity: existingProductBatchForm.quantity, + costPrice: existingProductBatchForm.costPrice, + sellingPrice: existingProductBatchForm.sellingPrice, + }); + const shouldShowMissingBatchCostPriceSuggestion = Boolean( + productBatchesUrl && !isLoadingProductBatches && !batchCostPriceSuggestion, + ); + const shouldShowMissingBatchSalePriceSuggestion = Boolean( + productBatchesUrl && !isLoadingProductBatches && !batchSalePriceSuggestion, + ); + const form = useForm({ resolver: zodResolver(productInlineSchema), defaultValues: { @@ -391,6 +464,104 @@ export const useNewProductInlineModel = ({ window.setTimeout(() => nameInputRef.current?.focus(), 100); }; + const handleBarcodeScan = async (barcode: string): Promise => { + try { + const response = await api + .get(`products/barcode/${encodeURIComponent(barcode)}`) + .json<{ success: boolean; data: StockMovementProductOption }>(); + setScannedExistingProduct({ + id: response.data.id, + name: response.data.name, + barcode, + }); + } catch { + form.setValue("barcode", barcode); + } + }; + + const handleExistingProductModalOpenChange = (open: boolean): void => { + if (!open) { + setScannedExistingProduct(null); + } + }; + + const handleCreateBatchForExistingProduct = (): void => { + if (!scannedExistingProduct) return; + setExistingProductBatchForm({ + ...EMPTY_BATCH_FORM, + isOpen: true, + productId: scannedExistingProduct.id, + productName: scannedExistingProduct.name, + }); + setScannedExistingProduct(null); + }; + + const updateBatchForm = ( + patch: Partial, + ): void => { + setExistingProductBatchForm((current) => ({ + ...current, + ...patch, + error: patch.error ?? null, + })); + }; + + const handleBatchOpenChange = (open: boolean): void => { + if (!open) { + setExistingProductBatchForm(EMPTY_BATCH_FORM); + } + }; + + const handleConfirmBatch = async (): Promise => { + const quantity = Number(existingProductBatchForm.quantity); + if (!quantity || quantity <= 0) { + updateBatchForm({ error: "Informe uma quantidade válida para o lote." }); + return; + } + + if (existingProductBatchForm.costPrice === undefined || existingProductBatchForm.costPrice < 0) { + updateBatchForm({ error: "Informe um preço de custo válido." }); + return; + } + + if (existingProductBatchForm.sellingPrice === undefined || existingProductBatchForm.sellingPrice < 0) { + updateBatchForm({ error: "Informe um preço de venda válido." }); + return; + } + + const item = buildExistingProductBatchItem( + existingProductBatchForm.productId, + existingProductBatchForm.productName, + existingProductBatchForm, + ); + + await appendExistingProductBatchToDraft(item); + toast.success(`${existingProductBatchForm.productName} foi adicionado.`); + setExistingProductBatchForm(EMPTY_BATCH_FORM); + resetInlineFormForNextProduct(); + }; + + const handleApplyBatchCostPriceSuggestion = (): void => { + if (!batchCostPriceSuggestion) return; + updateBatchForm({ costPrice: batchCostPriceSuggestion.priceCents }); + }; + + const handleApplyBatchSalePriceSuggestion = (): void => { + if (!batchSalePriceSuggestion) return; + updateBatchForm({ sellingPrice: batchSalePriceSuggestion.priceCents }); + }; + + const handleBatchQuantityIncrement = (): void => { + const current = Number(existingProductBatchForm.quantity) || 0; + updateBatchForm({ quantity: String(current + 1) }); + }; + + const handleBatchQuantityDecrement = (): void => { + const current = Number(existingProductBatchForm.quantity) || 0; + const next = Math.max(current - 1, 0); + updateBatchForm({ quantity: next > 0 ? String(next) : "" }); + }; + const updateInlineQuantity = (quantity: number): void => { form.setValue("quantity", Math.max(0, quantity), { shouldDirty: true, @@ -475,7 +646,7 @@ export const useNewProductInlineModel = ({ openScanner: () => setIsScannerOpen(true), closeScanner: () => setIsScannerOpen(false), isScannerOpen, - handleBarcodeScan: (barcode: string) => form.setValue("barcode", barcode), + handleBarcodeScan, isAiModalOpen, openAiModal: () => setIsAiModalOpen(true), closeAiModal: () => setIsAiModalOpen(false), @@ -486,5 +657,26 @@ export const useNewProductInlineModel = ({ isInlineEdit: isEditingInlineProduct, onQuantityIncrement, onQuantityDecrement, + scannedExistingProduct, + onExistingProductModalOpenChange: handleExistingProductModalOpenChange, + onCreateBatchForExistingProduct: handleCreateBatchForExistingProduct, + batchForm: existingProductBatchForm, + onBatchOpenChange: handleBatchOpenChange, + onBatchQuantityChange: (quantity: string) => updateBatchForm({ quantity }), + onBatchQuantityIncrement: handleBatchQuantityIncrement, + onBatchQuantityDecrement: handleBatchQuantityDecrement, + onBatchManufacturedDateChange: (date: string) => updateBatchForm({ manufacturedDate: date }), + onBatchExpirationDateChange: (date: string) => updateBatchForm({ expirationDate: date }), + onBatchCostPriceChange: (price?: number) => updateBatchForm({ costPrice: price }), + onBatchSellingPriceChange: (price?: number) => updateBatchForm({ sellingPrice: price }), + onApplyBatchCostPriceSuggestion: handleApplyBatchCostPriceSuggestion, + onApplyBatchSalePriceSuggestion: handleApplyBatchSalePriceSuggestion, + onConfirmBatch: handleConfirmBatch, + batchCostPriceSuggestion, + batchSalePriceSuggestion, + isBatchPriceSuggestionLoading: isLoadingProductBatches, + shouldShowMissingBatchCostPriceSuggestion, + shouldShowMissingBatchSalePriceSuggestion, + batchProfitSummary, }; }; diff --git a/app/(pages)/stock-movements/create/new-product/page.client.tsx b/app/(pages)/stock-movements/create/new-product/page.client.tsx index 7cc20b4..b251c1f 100644 --- a/app/(pages)/stock-movements/create/new-product/page.client.tsx +++ b/app/(pages)/stock-movements/create/new-product/page.client.tsx @@ -4,6 +4,7 @@ import { Suspense } from "react"; import { ProductForm } from "../../../products/components/product-form.view"; import { useNewProductInlineModel } from "./new-product-inline.model"; import { StockMovementReloadGuard } from "../stock-movement-reload-guard"; +import { StockMovementBatchDataModal } from "../stock-movement-batch-data-modal.view"; interface NewProductInlineContentProps { movementType: string | null; @@ -15,10 +16,44 @@ function NewProductInlineContent({ editItem, }: NewProductInlineContentProps) { const model = useNewProductInlineModel({ movementType, editItem }); + const isBatchOverlayOpen = model.batchForm?.isOpen ?? false; + return ( <> + {isBatchOverlayOpen && ( +
+ )} + {})} + onQuantityChange={model.onBatchQuantityChange ?? (() => {})} + onQuantityIncrement={model.onBatchQuantityIncrement ?? (() => {})} + onQuantityDecrement={model.onBatchQuantityDecrement ?? (() => {})} + onManufacturedDateChange={model.onBatchManufacturedDateChange ?? (() => {})} + onExpirationDateChange={model.onBatchExpirationDateChange ?? (() => {})} + onCostPriceChange={model.onBatchCostPriceChange ?? (() => {})} + onSellingPriceChange={model.onBatchSellingPriceChange ?? (() => {})} + onApplyCostPriceSuggestion={model.onApplyBatchCostPriceSuggestion ?? (() => {})} + onApplySalePriceSuggestion={model.onApplyBatchSalePriceSuggestion ?? (() => {})} + onConfirm={model.onConfirmBatch ?? (() => {})} + costPriceSuggestion={model.batchCostPriceSuggestion ?? null} + salePriceSuggestion={model.batchSalePriceSuggestion ?? null} + isPriceSuggestionLoading={model.isBatchPriceSuggestionLoading ?? false} + shouldShowMissingCostPriceSuggestion={model.shouldShowMissingBatchCostPriceSuggestion ?? false} + shouldShowMissingSalePriceSuggestion={model.shouldShowMissingBatchSalePriceSuggestion ?? false} + profitSummary={model.batchProfitSummary ?? { kind: "incomplete", title: "" }} + /> ); } From 16cd0ee50b6ca5a632d2647ef7adbbb2b3dadbb7 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 9 Jun 2026 20:52:56 -0300 Subject: [PATCH 06/32] refactor(transfers): extract subcomponents, add batch drawer, product search, scanner, and helper functions --- .../new/new-transfer-batch-drawer.view.tsx | 403 +++++++++++++ .../new/new-transfer-items-list.view.tsx | 207 +++++++ .../new/new-transfer-product-search.view.tsx | 196 +++++++ .../new/new-transfer-scanner.view.tsx | 91 +++ .../transfers/new/new-transfer.model.test.ts | 549 +++++++++++++----- .../transfers/new/new-transfer.model.ts | 504 +++++++++++++--- .../transfers/new/new-transfer.types.ts | 67 ++- .../transfers/new/new-transfer.view.test.tsx | 85 ++- .../transfers/new/new-transfer.view.tsx | 373 ++---------- 9 files changed, 1907 insertions(+), 568 deletions(-) create mode 100644 app/(pages)/transfers/new/new-transfer-batch-drawer.view.tsx create mode 100644 app/(pages)/transfers/new/new-transfer-items-list.view.tsx create mode 100644 app/(pages)/transfers/new/new-transfer-product-search.view.tsx create mode 100644 app/(pages)/transfers/new/new-transfer-scanner.view.tsx diff --git a/app/(pages)/transfers/new/new-transfer-batch-drawer.view.tsx b/app/(pages)/transfers/new/new-transfer-batch-drawer.view.tsx new file mode 100644 index 0000000..54cf479 --- /dev/null +++ b/app/(pages)/transfers/new/new-transfer-batch-drawer.view.tsx @@ -0,0 +1,403 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + AlertCircle, + CalendarDays, + Check, + Loader2, + Minus, + PackageCheck, + Plus, + X, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { NumberInput } from "@/components/ui/number-input"; +import { cn } from "@/lib/utils"; +import type { + TransferBatchDrawerState, + TransferBatchOption, +} from "./new-transfer.types"; + +interface NewTransferBatchDrawerProps { + form: TransferBatchDrawerState; + batches: TransferBatchOption[]; + isLoading: boolean; + onOpenChange: (open: boolean) => void; + onBatchChange: (batchId: string) => void; + onQuantityChange: (quantity: string) => void; + onQuantityIncrement: () => void; + onQuantityDecrement: () => void; + onConfirm: () => void; +} + +const transferBatchDateFormatter = new Intl.DateTimeFormat("pt-BR"); + +export function NewTransferBatchDrawer({ + form, + batches, + isLoading, + onOpenChange, + onBatchChange, + onQuantityChange, + onQuantityIncrement, + onQuantityDecrement, + onConfirm, +}: NewTransferBatchDrawerProps) { + const selectedBatch = batches.find((batch) => batch.id === form.selectedBatchId); + + return ( + + + +
+ + + + +
+ + + + +
+
+ ); +} + +function TransferBatchDrawerHeader({ + onOpenChange, +}: { + onOpenChange: (open: boolean) => void; +}) { + return ( + +
+
+ +
+ + Qual lote será utilizado? + + + Escolha o lote e a quantidade que será transferida + +
+
+ +
+
+ ); +} + +function TransferBatchProductSummary({ + form, +}: { + form: TransferBatchDrawerState; +}) { + return ( +
+

+ Produto +

+

+ {form.productName || "Produto"} +

+
+ ); +} + +function TransferBatchOptions({ + batches, + isLoading, + quantity, + selectedBatchId, + onBatchChange, +}: { + batches: TransferBatchOption[]; + isLoading: boolean; + quantity: string; + selectedBatchId: string; + onBatchChange: (batchId: string) => void; +}) { + if (isLoading) return ; + if (batches.length === 0) return ; + + return ( +
+

+ Lotes disponíveis +

+ {batches.map((batch) => ( + + ))} +
+ ); +} + +function TransferBatchOptionCard({ + batch, + isSelected, + quantity, + onBatchChange, +}: { + batch: TransferBatchOption; + isSelected: boolean; + quantity: string; + onBatchChange: (batchId: string) => void; +}) { + return ( + + ); +} + +function TransferBatchSelectionIcon({ isSelected }: { isSelected: boolean }) { + return ( + + + + ); +} + +function TransferBatchOptionDetails({ + batch, +}: { + batch: TransferBatchOption; +}) { + return ( + + + {batch.batchCode} + + + {batch.quantity} un. disponíveis + + + Validade: {formatTransferBatchDate(batch.expirationDate, "Sem validade")} + + + {batch.manufacturedDate ? ( + + Fabricação: {formatTransferBatchDate(batch.manufacturedDate, "-")} + + ) : null} + + ); +} + +function TransferBatchQuantityBadge({ + isSelected, + quantity, +}: { + isSelected: boolean; + quantity: string; +}) { + if (!isSelected) return null; + + return ( + + {quantity || "0"} un. + + ); +} + +function TransferBatchQuantitySection({ + quantity, + selectedBatch, + onQuantityChange, + onQuantityIncrement, + onQuantityDecrement, +}: { + quantity: string; + selectedBatch?: TransferBatchOption; + onQuantityChange: (quantity: string) => void; + onQuantityIncrement: () => void; + onQuantityDecrement: () => void; +}) { + const numericQuantity = Number(quantity); + const canDecrement = Number.isFinite(numericQuantity) && numericQuantity > 1; + const canIncrement = selectedBatch + ? Number.isFinite(numericQuantity) && numericQuantity < selectedBatch.quantity + : true; + + return ( +
+ +
+ + + + + onQuantityChange(value !== undefined ? String(value) : "") + } + className="h-10 min-w-0 flex-1 rounded-none border-x-0 border-neutral-800 bg-neutral-900 text-center font-mono text-sm text-white focus:border-blue-600" + placeholder="1" + /> + + + +
+ {selectedBatch ? ( +

+ Lote selecionado com {selectedBatch.quantity} un. disponíveis. +

+ ) : null} +
+ ); +} + +function TransferBatchQuantityButton({ + children, + disabled, + label, + onClick, +}: { + children: ReactNode; + disabled: boolean; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function TransferBatchDrawerError({ error }: { error: string | null }) { + if (!error) return null; + + return ( +
+ +

{error}

+
+ ); +} + +function TransferBatchLoading() { + return ( +
+ + Carregando lotes disponíveis… +
+ ); +} + +function TransferBatchEmpty() { + return ( +
+

+ Nenhum lote disponível +

+

+ Este produto não possui quantidade disponível no depósito atual. +

+
+ ); +} + +function formatTransferBatchDate(value: string | null, fallback: string): string { + if (!value) return fallback; + const timestamp = new Date(value).getTime(); + if (!Number.isFinite(timestamp)) return value; + return transferBatchDateFormatter.format(new Date(timestamp)); +} diff --git a/app/(pages)/transfers/new/new-transfer-items-list.view.tsx b/app/(pages)/transfers/new/new-transfer-items-list.view.tsx new file mode 100644 index 0000000..1a91243 --- /dev/null +++ b/app/(pages)/transfers/new/new-transfer-items-list.view.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { Hash, Package, Trash2 } from "lucide-react"; + +import { EmptyState } from "@/components/ui/empty-state"; +import { SectionLabel } from "@/components/ui/section-label"; +import { Button } from "@/components/ui/button"; +import type { NewTransferViewProps } from "./new-transfer.types"; + +interface TransferItemsListProps { + viewState: NewTransferViewProps; + totalQuantity: number; +} + +export function TransferItemsList({ + viewState, + totalQuantity, +}: TransferItemsListProps) { + const { form, items } = viewState; + + return ( +
+ + Itens na Transferência ({items.length}) + + {items.length === 0 ? ( + + ) : ( + <> + + + + )} + {form.formState.errors.items ? ( +

+ {form.formState.errors.items.message} +

+ ) : null} +
+ ); +} + +function TransferItemsMobileList({ + viewState, +}: { + viewState: NewTransferViewProps; +}) { + return ( +
+ {viewState.items.map((item, index) => ( +
+
+

+ {item.productName || "Produto"} +

+
+ + Lote: {item.batchCode} + + + Qtd: {" "} + + {item.quantity} + + +
+
+ +
+ ))} +
+ ); +} + +function TransferItemsDesktopTable({ + viewState, + totalQuantity, +}: { + viewState: NewTransferViewProps; + totalQuantity: number; +}) { + return ( +
+
+ + + + {viewState.items.map((item, index) => ( + + ))} + + +
+
+
+ ); +} + +function TransferItemsDesktopHeader() { + return ( + + + + Produto + + + Lote + + + Quantidade + + + + + ); +} + +function TransferItemsDesktopFooter({ totalQuantity }: { totalQuantity: number }) { + return ( + + + + Total + + + {totalQuantity} + + + + + ); +} + +function TransferItemsDesktopRow({ + index, + item, + onRemoveItem, +}: { + index: number; + item: NewTransferViewProps["items"][number]; + onRemoveItem: (index: number) => void; +}) { + return ( + + + {item.productName || "Produto"} + + + {item.batchCode || "—"} + + + {item.quantity} + + + + + + ); +} + +function TransferRemoveItemButton({ + index, + onRemoveItem, + sizeClassName, +}: { + index: number; + onRemoveItem: (index: number) => void; + sizeClassName: string; +}) { + return ( + + ); +} diff --git a/app/(pages)/transfers/new/new-transfer-product-search.view.tsx b/app/(pages)/transfers/new/new-transfer-product-search.view.tsx new file mode 100644 index 0000000..7356d1f --- /dev/null +++ b/app/(pages)/transfers/new/new-transfer-product-search.view.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { Loader2, Package, ScanLine, Search, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { NewTransferViewProps } from "./new-transfer.types"; + +interface NewTransferProductSearchProps { + viewState: NewTransferViewProps; +} + +export function TransferProductSearch({ + viewState, +}: NewTransferProductSearchProps) { + const { + isLoading, + isProductOptionsOpen, + isProductSearchLoading, + onProductClear, + onProductSearchBlur, + onProductSearchChange, + onProductSearchFocus, + onProductSelect, + onScannerOpenChange, + productOptions, + productSearchQuery, + } = viewState; + + return ( +
+ +
+
+ + +
+ +
+
+ ); +} + +function TransferProductSearchInput({ + isLoading, + productSearchQuery, + onProductClear, + onProductSearchBlur, + onProductSearchChange, + onProductSearchFocus, +}: { + isLoading: boolean; + productSearchQuery: string; + onProductClear: () => void; + onProductSearchBlur: () => void; + onProductSearchChange: (query: string) => void; + onProductSearchFocus: () => void; +}) { + return ( +
+ + onProductSearchChange(event.target.value)} + onFocus={onProductSearchFocus} + onBlur={onProductSearchBlur} + disabled={isLoading} + placeholder={ + isLoading + ? "Carregando produtos…" + : "Pesquisar produto por nome, SKU ou código" + } + className="h-10 w-full rounded-[4px] border-2 border-neutral-800 bg-neutral-900 pl-9 pr-9 text-sm text-white focus:border-blue-600 disabled:opacity-40" + /> + {productSearchQuery ? ( + + ) : null} +
+ ); +} + +function TransferProductOptions({ + isOpen, + isLoading, + products, + onProductSelect, +}: { + isOpen: boolean; + isLoading: boolean; + products: NewTransferViewProps["productOptions"]; + onProductSelect: NewTransferViewProps["onProductSelect"]; +}) { + if (!isOpen || (!isLoading && products.length === 0)) return null; + + const handleProductOptionSelect = ( + product: NewTransferViewProps["productOptions"][number], + ): void => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + onProductSelect(product); + }; + + return ( +
+ {isLoading ? ( +
+ + Buscando produtos… +
+ ) : ( + products.map((product) => ( + + )) + )} +
+ ); +} + +function TransferProductOptionImage({ + product, +}: { + product: NewTransferViewProps["productOptions"][number]; +}) { + if (product.imageUrl) { + return ( + + ); + } + + return ( + + + + ); +} diff --git a/app/(pages)/transfers/new/new-transfer-scanner.view.tsx b/app/(pages)/transfers/new/new-transfer-scanner.view.tsx new file mode 100644 index 0000000..9c575b7 --- /dev/null +++ b/app/(pages)/transfers/new/new-transfer-scanner.view.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { type IDetectedBarcode } from "@yudiel/react-qr-scanner"; +import { ScanLine, X } from "lucide-react"; + +import { BarcodeScanner } from "@/components/product/barcode-scanner"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; + +interface NewTransferScannerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onScan: (barcode: string) => void; +} + +export function NewTransferScanner({ + open, + onOpenChange, + onScan, +}: NewTransferScannerProps) { + const handleScan = (detectedCodes: IDetectedBarcode[]): void => { + const barcode = detectedCodes[0]?.rawValue; + if (!barcode) return; + onScan(barcode); + }; + + const handleError = (error: unknown): void => { + console.error("Erro ao acessar câmera:", error); + }; + + return ( + + + +
+
+ +
+ + Scanner de Produto + + + Aponte para o código de barras para escolher o lote + +
+
+ +
+
+ +
+
+ +
+

+ Leitura contínua ativa +

+
+
+
+
+
+ ); +} diff --git a/app/(pages)/transfers/new/new-transfer.model.test.ts b/app/(pages)/transfers/new/new-transfer.model.test.ts index bc44993..7476c96 100644 --- a/app/(pages)/transfers/new/new-transfer.model.test.ts +++ b/app/(pages)/transfers/new/new-transfer.model.test.ts @@ -1,22 +1,50 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useNewTransferModel } from "./new-transfer.model"; +import { + buildTransferProductBatchesUrl, + clampTransferBatchQuantity, + filterTransferProductOptions, + formatTransferProductLabel, + formatTransferProductQuantityLabel, + getWarehouseBatchQuantityByProduct, + shouldShowTransferFooter, + useNewTransferModel, +} from "./new-transfer.model"; import type { NewTransferSchema } from "./new-transfer.schema"; +import type { TransferProductOption } from "./new-transfer.types"; type JsonResponse = { json: () => Promise }; -type ProductResponse = Array<{ id: string; name: string }> | { content: Array<{ id: string; name: string }> }; -type BatchResponse = Array<{ id: string; code: string; quantity: number; productId: string }> | { content: Array<{ id: string; code: string; quantity: number; productId: string }> }; - type SwrState = { data?: T; error: Error | null; isLoading: boolean; }; -type WarehouseListResponse = { success: boolean; data: Array<{ id: string; name: string }> }; -type ProductListResponse = { success: boolean; data: ProductResponse }; -type BatchListResponse = { success: boolean; data: BatchResponse }; +type WarehouseListResponse = { + success: boolean; + data: Array<{ id: string; name: string }>; +}; +type ProductListResponse = { + success: boolean; + data: TransferProductOption[] | { content: TransferProductOption[] }; +}; +type BatchListResponse = { + success: boolean; + data: + | TransferBatchTestSource[] + | { content: TransferBatchTestSource[] }; +}; +type TransferBatchTestSource = { + id: string; + productId: string; + productName?: string; + batchCode?: string | null; + code?: string | null; + quantity: number; + manufacturedDate?: string | null; + expirationDate?: string | null; +}; const fakeSWR = vi.hoisted(() => { class FakeSWR { @@ -47,9 +75,9 @@ const fakeSWR = vi.hoisted(() => { const fakeApi = vi.hoisted(() => { class FakeApi { public readonly post = vi.fn< - (url: string, body: { json: NewTransferSchema }) => JsonResponse + (url: string, body: { json: unknown }) => JsonResponse >(); - public readonly get = vi.fn<(url: string) => Promise>>(); + public readonly get = vi.fn<(url: string) => JsonResponse>(); } return new FakeApi(); @@ -128,6 +156,36 @@ const createResponse = (payload: T): JsonResponse => ({ json: vi.fn(async () => payload), }); +const products: TransferProductOption[] = [ + { + id: "prod-leite", + name: "Leite Integral", + sku: "LEI-01", + barcode: "7891000000001", + imageUrl: "https://img.test/leite.png", + totalQuantity: 12, + stockQuantityLabel: "Quantidade: 12 un.", + }, + { + id: "prod-cafe", + name: "Café Torrado", + sku: "CAF-02", + barcode: "7891000000002", + imageUrl: null, + totalQuantity: 7, + stockQuantityLabel: "Quantidade: 7 un.", + }, + { + id: "prod-cha", + name: "Chá Mate", + sku: null, + barcode: "7891000000003", + imageUrl: null, + totalQuantity: 0, + stockQuantityLabel: "Quantidade: 0 un.", + }, +]; + const warehousesResponse: WarehouseListResponse = { success: true, data: [ @@ -139,59 +197,106 @@ const warehousesResponse: WarehouseListResponse = { const productsArrayResponse: ProductListResponse = { success: true, - data: [ - { id: "prod-leite", name: "Leite" }, - { id: "prod-cafe", name: "Café" }, - ], + data: products, }; const productsContentResponse: ProductListResponse = { success: true, - data: { - content: [ - { id: "prod-suco", name: "Suco" }, - { id: "prod-cha", name: "Chá" }, - ], - }, + data: { content: [products[2]] }, }; -const batchContentResponse: BatchListResponse = { +const leiteBatchResponse: BatchListResponse = { + success: true, + data: [ + { + id: "batch-leite-1", + productId: "prod-leite", + productName: "Leite Integral", + batchCode: "L-01", + quantity: 5, + manufacturedDate: "2026-01-01", + expirationDate: "2026-12-31", + }, + { + id: "batch-leite-2", + productId: "prod-leite", + productName: "Leite Integral", + code: "L-02", + quantity: 0, + expirationDate: "2027-01-31", + }, + ], +}; + +const cafeBatchResponse: BatchListResponse = { success: true, data: { content: [ - { - id: "batch-leite-1", - code: "L-01", - quantity: 5, - productId: "prod-leite", - }, - { - id: "batch-leite-2", - code: "L-02", - quantity: 2, - productId: "prod-leite", - }, { id: "batch-cafe", - code: "C-01", - quantity: 10, productId: "prod-cafe", + productName: "Café Torrado", + batchCode: "C-01", + quantity: 10, + manufacturedDate: null, + expirationDate: null, }, ], }, }; +const warehouseBatchesResponse: BatchListResponse = { + success: true, + data: [ + { + id: "batch-leite-warehouse-1", + productId: "prod-leite", + productName: "Leite Integral", + batchCode: "L-WH-01", + quantity: 4, + }, + { + id: "batch-leite-warehouse-2", + productId: "prod-leite", + productName: "Leite Integral", + batchCode: "L-WH-02", + quantity: 6, + }, + { + id: "batch-cafe-warehouse", + productId: "prod-cafe", + productName: "Café Torrado", + batchCode: "C-WH-01", + quantity: 7, + }, + ], +}; + +const getWarehouseBatchSources = ( + response: BatchListResponse, +): TransferBatchTestSource[] => { + return Array.isArray(response.data) ? response.data : response.data.content; +}; + beforeEach(() => { vi.clearAllMocks(); + vi.useRealTimers(); fakeSWR.reset(); fakeApi.post.mockReset(); fakeApi.get.mockReset(); - fakeToast.success.mockClear(); - fakeToast.error.mockClear(); fakeRouter.push.mockClear(); fakeBreadcrumb.useBreadcrumb.mockClear(); fakeSelectedWarehouse.warehouseId = "warehouse-origin"; fakeApi.post.mockReturnValue(createResponse({ success: true })); + fakeApi.get.mockImplementation((url: string) => { + if (url === "products/barcode/7891000000001") { + return createResponse({ success: true, data: products[0] }); + } + if (url === "products/barcode/7891000000002") { + return createResponse({ success: true, data: products[1] }); + } + throw new Error(`Sem fake para GET ${url}`); + }); fakeSWR.setState("warehouses", { data: warehousesResponse, @@ -204,23 +309,126 @@ beforeEach(() => { isLoading: false, }); fakeSWR.setState("batches/warehouse/warehouse-origin", { - data: batchContentResponse, + data: warehouseBatchesResponse, + error: null, + isLoading: false, + }); + fakeSWR.setState("batches/warehouses/warehouse-origin/products/prod-leite/batches", { + data: leiteBatchResponse, + error: null, + isLoading: false, + }); + fakeSWR.setState("batches/warehouses/warehouse-origin/products/prod-cafe/batches", { + data: cafeBatchResponse, error: null, isLoading: false, }); }); +describe("helpers de nova transferência", () => { + it("formata produto com SKU quando disponível", () => { + expect(formatTransferProductLabel(products[0])).toBe( + "Leite Integral (LEI-01)", + ); + expect(formatTransferProductLabel(products[2])).toBe("Chá Mate"); + }); + + it("formata quantidade disponível do produto", () => { + expect(formatTransferProductQuantityLabel(products[0])).toBe( + "Quantidade: 12 un.", + ); + expect(formatTransferProductQuantityLabel({ id: "p", name: "Produto" })).toBe( + "Quantidade: 0 un.", + ); + }); + + it("soma quantidades de todos os lotes do produto", () => { + const quantityByProduct = getWarehouseBatchQuantityByProduct( + getWarehouseBatchSources(warehouseBatchesResponse), + ); + + expect(quantityByProduct.get("prod-leite")).toBe(10); + expect(quantityByProduct.get("prod-cafe")).toBe(7); + expect(quantityByProduct.get("prod-cha")).toBeUndefined(); + }); + + it("filtra produtos por nome, SKU e barcode com mínimo de dois caracteres", () => { + expect(filterTransferProductOptions(products, " l ")).toEqual([]); + expect(filterTransferProductOptions(products, "caf")[0].id).toBe("prod-cafe"); + expect(filterTransferProductOptions(products, "LEI-01")[0].id).toBe("prod-leite"); + expect(filterTransferProductOptions(products, "0003")[0].id).toBe("prod-cha"); + }); + + it("limita resultados de autocomplete a cinco produtos", () => { + const manyProducts = Array.from({ length: 6 }, (_, index) => ({ + id: `prod-${index}`, + name: `Produto ${index}`, + })); + + expect(filterTransferProductOptions(manyProducts, "produto")).toHaveLength(5); + }); + + it("monta URL de lotes apenas com drawer aberto e IDs válidos", () => { + expect(buildTransferProductBatchesUrl(null, "prod-1", true)).toBeNull(); + expect(buildTransferProductBatchesUrl("wh-1", "", true)).toBeNull(); + expect(buildTransferProductBatchesUrl("wh-1", "prod-1", false)).toBeNull(); + expect(buildTransferProductBatchesUrl("wh-1", "prod-1", true)).toBe( + "batches/warehouses/wh-1/products/prod-1/batches", + ); + }); + + it("limita quantidade do lote entre um e o estoque disponível", () => { + expect(clampTransferBatchQuantity("0", 5)).toBe("1"); + expect(clampTransferBatchQuantity("texto", 5)).toBe("1"); + expect(clampTransferBatchQuantity("3", 5)).toBe("3"); + expect(clampTransferBatchQuantity("8", 5)).toBe("5"); + expect(clampTransferBatchQuantity("8")).toBe("8"); + }); + + it("mostra footer no fim da página ou quando usuário rola para cima", () => { + expect( + shouldShowTransferFooter({ + currentScrollY: 200, + lastScrollY: 100, + maxScrollY: 1000, + }), + ).toBe(false); + expect( + shouldShowTransferFooter({ + currentScrollY: 995, + lastScrollY: 900, + maxScrollY: 1000, + }), + ).toBe(true); + expect( + shouldShowTransferFooter({ + currentScrollY: 600, + lastScrollY: 700, + maxScrollY: 1000, + }), + ).toBe(true); + expect( + shouldShowTransferFooter({ + currentScrollY: 0, + lastScrollY: 0, + maxScrollY: 0, + }), + ).toBe(true); + }); +}); + describe("useNewTransferModel", () => { - it("mapeia opções de destino ignorando o warehouse atual", () => { + it("mapeia destinos e produtos ignorando warehouse atual", () => { const { result } = renderHook(() => useNewTransferModel()); expect(result.current.warehouses).toEqual([ { id: "warehouse-destination", name: "Loja Matriz" }, { id: "warehouse-destination-b", name: "Loja B" }, ]); - expect(result.current.products).toEqual([ - { id: "prod-leite", name: "Leite" }, - { id: "prod-cafe", name: "Café" }, + expect(result.current.products).toMatchObject([ + { id: "prod-leite", totalQuantity: 10, stockQuantityLabel: "Quantidade: 10 un." }, + { id: "prod-cafe", totalQuantity: 7, stockQuantityLabel: "Quantidade: 7 un." }, + { id: "prod-cha", totalQuantity: 0, stockQuantityLabel: "Quantidade: 0 un." }, ]); expect(result.current.isLoading).toBe(false); }); @@ -234,148 +442,227 @@ describe("useNewTransferModel", () => { const { result } = renderHook(() => useNewTransferModel()); - expect(result.current.products).toEqual([ - { id: "prod-suco", name: "Suco" }, - { id: "prod-cha", name: "Chá" }, + expect(result.current.products).toMatchObject([ + { id: "prod-cha", totalQuantity: 0, stockQuantityLabel: "Quantidade: 0 un." }, ]); }); - it("limpa seleção e erro ao trocar produto", () => { + it("abre autocomplete e seleciona produto para abrir drawer de lote", () => { const { result } = renderHook(() => useNewTransferModel()); act(() => { - result.current.onProductChange("prod-leite"); - result.current.onBatchChange("batch-leite-1"); - result.current.onQuantityChange("1"); - result.current.onAddItem(); + result.current.onProductSearchFocus(); + result.current.onProductSearchChange("lei"); }); + expect(result.current.isProductOptionsOpen).toBe(true); + expect(result.current.addItemError).toBeNull(); act(() => { - result.current.onProductChange("prod-cafe"); + result.current.onProductSelect(products[0]); }); - expect(result.current.selectedProductId).toBe("prod-cafe"); - expect(result.current.selectedBatchId).toBe(""); - expect(result.current.itemQuantity).toBe(""); - expect(result.current.addItemError).toBeNull(); + expect(result.current.selectedProductId).toBe("prod-leite"); + expect(result.current.productSearchQuery).toBe("Leite Integral (LEI-01)"); + expect(result.current.batchDrawer).toMatchObject({ + isOpen: true, + productId: "prod-leite", + productName: "Leite Integral", + quantity: "1", + }); + expect(fakeSWR.hook).toHaveBeenCalledWith( + "batches/warehouses/warehouse-origin/products/prod-leite/batches", + expect.any(Function), + ); }); - it("rejeita inclusão de item sem produto e sem lote", () => { + it("limpa seleção quando busca muda após produto selecionado", () => { const { result } = renderHook(() => useNewTransferModel()); act(() => { - result.current.onAddItem(); + result.current.onProductSelect(products[0]); }); - expect(result.current.addItemError).toBe("Selecione um produto."); - act(() => { - result.current.onProductChange("prod-leite"); + result.current.onProductSearchChange("outro"); }); + + expect(result.current.selectedProductId).toBe(""); + expect(result.current.productSearchQuery).toBe("outro"); + expect(result.current.addItemError).toBeNull(); + }); + + it("limpa produto, busca e drawer", () => { + const { result } = renderHook(() => useNewTransferModel()); + act(() => { - result.current.onAddItem(); + result.current.onProductSelect(products[0]); + result.current.onProductClear(); }); - expect(result.current.addItemError).toBe("Selecione um lote."); - act(() => { - result.current.onBatchChange("batch-leite-1"); - result.current.onQuantityChange("0"); + expect(result.current.selectedProductId).toBe(""); + expect(result.current.productSearchQuery).toBe(""); + expect(result.current.batchDrawer.isOpen).toBe(false); + }); + + it("abre drawer de lote ao ler código de barras", async () => { + const { result } = renderHook(() => useNewTransferModel()); + + await act(async () => { + await result.current.onBarcodeScan("7891000000001"); + }); + + expect(fakeApi.get).toHaveBeenCalledWith("products/barcode/7891000000001"); + expect(result.current.isScannerOpen).toBe(false); + expect(result.current.batchDrawer).toMatchObject({ + isOpen: true, + productId: "prod-leite", + productName: "Leite Integral", + }); + }); + + it("ignora leitura duplicada imediata", async () => { + const { result } = renderHook(() => useNewTransferModel()); + + await act(async () => { + await result.current.onBarcodeScan("7891000000001"); + await result.current.onBarcodeScan("7891000000001"); + }); + + expect(fakeApi.get).toHaveBeenCalledTimes(1); + }); + + it("mostra erro quando barcode não encontra produto", async () => { + fakeApi.get.mockImplementation(() => { + throw new Error("não encontrado"); }); + const { result } = renderHook(() => useNewTransferModel()); + + await act(async () => { + await result.current.onBarcodeScan("7891009999999"); + }); + + expect(fakeToast.error).toHaveBeenCalledWith( + "Produto com código 7891009999999 não existe.", + ); + }); + + it("normaliza lotes e remove lote sem estoque", () => { + const { result } = renderHook(() => useNewTransferModel()); + act(() => { - result.current.onAddItem(); + result.current.onProductSelect(products[0]); }); - expect(result.current.addItemError).toBe("Quantidade inválida."); + + expect(result.current.batches).toEqual([ + { + id: "batch-leite-1", + productId: "prod-leite", + productName: "Leite Integral", + batchCode: "L-01", + quantity: 5, + manufacturedDate: "2026-01-01", + expirationDate: "2026-12-31", + }, + ]); }); - it("rejeita lote inválido ou quantidade acima do disponível", () => { + it("exige seleção de lote antes de confirmar", () => { const { result } = renderHook(() => useNewTransferModel()); act(() => { - result.current.onProductChange("prod-cafe"); + result.current.onProductSelect(products[0]); }); act(() => { - result.current.onBatchChange("batch-invalido"); + result.current.onConfirmBatch(); }); + expect(result.current.batchDrawer.error).toBe("Selecione um lote."); + }); + + it("mantém quantidade do drawer entre um e o estoque do lote", () => { + const { result } = renderHook(() => useNewTransferModel()); + act(() => { - result.current.onQuantityChange("1"); + result.current.onProductSelect(products[0]); + result.current.onQuantityChange("8"); }); + expect(result.current.batchDrawer.quantity).toBe("8"); + act(() => { - result.current.onAddItem(); + result.current.onBatchChange("batch-leite-1"); }); + expect(result.current.batchDrawer.quantity).toBe("5"); - expect(result.current.addItemError).toBe("Lote inválido."); + act(() => { + result.current.onQuantityChange("0"); + }); + expect(result.current.batchDrawer.quantity).toBe("1"); act(() => { - result.current.onProductChange("prod-cafe"); + result.current.onQuantityDecrement(); }); + expect(result.current.batchDrawer.quantity).toBe("1"); + act(() => { - result.current.onBatchChange("batch-cafe"); + result.current.onQuantityChange("5"); }); act(() => { - result.current.onQuantityChange("11"); + result.current.onQuantityIncrement(); }); + expect(result.current.batchDrawer.quantity).toBe("5"); + }); + + it("incrementa, decrementa e confirma lote adicionando item", () => { + const { result } = renderHook(() => useNewTransferModel()); + act(() => { - result.current.onAddItem(); + result.current.onProductSelect(products[0]); + result.current.onBatchChange("batch-leite-1"); + result.current.onQuantityIncrement(); }); + expect(result.current.batchDrawer.quantity).toBe("2"); - expect(result.current.addItemError).toBe( - "Quantidade indisponível no lote (Máx: 10).", - ); - }); + act(() => { + result.current.onQuantityDecrement(); + }); + expect(result.current.batchDrawer.quantity).toBe("1"); - it("adiciona item com sucesso e limpa campos do formulário", () => { - fakeSWR.setState("batches/warehouse/warehouse-origin", { - data: { - success: true, - data: [ - { - id: "batch-cha-array", - code: "CH-99", - quantity: 8, - productId: "prod-cha", - }, - ], - }, - error: null, - isLoading: false, + act(() => { + result.current.onConfirmBatch(); }); - fakeSWR.setState("products", { - data: { - success: true, - data: { - content: [{ id: "prod-cha", name: "Chá" }], - }, - }, - error: null, - isLoading: false, + + expect(result.current.items).toHaveLength(1); + expect(result.current.items[0]).toMatchObject({ + sourceBatchId: "batch-leite-1", + quantity: 1, + productName: "Leite Integral", + batchCode: "L-01", + availableQuantity: 5, }); + expect(result.current.selectedProductId).toBe(""); + expect(result.current.productSearchQuery).toBe(""); + expect(result.current.batchDrawer.isOpen).toBe(false); + }); + it("confirma lote com resposta no formato content", () => { const { result } = renderHook(() => useNewTransferModel()); act(() => { - result.current.onProductChange("prod-cha"); - }); - act(() => { - result.current.onBatchChange("batch-cha-array"); + result.current.onProductSelect(products[1]); }); act(() => { + result.current.onBatchChange("batch-cafe"); result.current.onQuantityChange("4"); }); act(() => { - result.current.onAddItem(); + result.current.onConfirmBatch(); }); - expect(result.current.items).toHaveLength(1); expect(result.current.items[0]).toMatchObject({ - sourceBatchId: "batch-cha-array", + sourceBatchId: "batch-cafe", quantity: 4, - productName: "Chá", - batchCode: "CH-99", - availableQuantity: 8, + productName: "Café Torrado", + batchCode: "C-01", + availableQuantity: 10, }); - expect(result.current.selectedProductId).toBe(""); - expect(result.current.selectedBatchId).toBe(""); - expect(result.current.itemQuantity).toBe(""); - expect(result.current.addItemError).toBeNull(); }); it("sinaliza erro quando não existe warehouse de origem", async () => { @@ -387,9 +674,9 @@ describe("useNewTransferModel", () => { notes: "", items: [ { - sourceBatchId: "batch-leite-1", + sourceBatchId: "11111111-1111-4111-8111-111111111111", quantity: 1, - productName: "Leite", + productName: "Leite Integral", batchCode: "L-01", availableQuantity: 5, }, @@ -412,9 +699,9 @@ describe("useNewTransferModel", () => { notes: "", items: [ { - sourceBatchId: "batch-leite-1", + sourceBatchId: "11111111-1111-4111-8111-111111111111", quantity: 1, - productName: "Leite", + productName: "Leite Integral", batchCode: "L-01", availableQuantity: 5, }, @@ -433,21 +720,14 @@ describe("useNewTransferModel", () => { it("envia nova transferência com sucesso e redireciona", async () => { const { result } = renderHook(() => useNewTransferModel()); - fakeApi.post.mockReturnValue( - createResponse({ - success: true, - message: "Transferência criada", - }), - ); - const payload: NewTransferSchema = { destinationWarehouseId: "warehouse-destination-b", notes: "Observação do lote", items: [ { - sourceBatchId: "batch-cafe", + sourceBatchId: "11111111-1111-4111-8111-111111111111", quantity: 2, - productName: "Café", + productName: "Café Torrado", batchCode: "C-01", availableQuantity: 10, }, @@ -464,7 +744,7 @@ describe("useNewTransferModel", () => { notes: "Observação do lote", items: [ { - sourceBatchId: "batch-cafe", + sourceBatchId: "11111111-1111-4111-8111-111111111111", quantity: 2, }, ], @@ -480,15 +760,14 @@ describe("useNewTransferModel", () => { fakeApi.post.mockImplementation(() => { throw new Error("Falha no servidor"); }); - const payload: NewTransferSchema = { destinationWarehouseId: "warehouse-destination-b", notes: "", items: [ { - sourceBatchId: "batch-cafe", + sourceBatchId: "11111111-1111-4111-8111-111111111111", quantity: 2, - productName: "Café", + productName: "Café Torrado", batchCode: "C-01", availableQuantity: 10, }, diff --git a/app/(pages)/transfers/new/new-transfer.model.ts b/app/(pages)/transfers/new/new-transfer.model.ts index 84af8fa..934f962 100644 --- a/app/(pages)/transfers/new/new-transfer.model.ts +++ b/app/(pages)/transfers/new/new-transfer.model.ts @@ -1,15 +1,31 @@ -import { useState } from "react"; -import { useForm, useFieldArray } from "react-hook-form"; +import { useEffect, useRef, useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import useSWR from "swr"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { api } from "@/lib/api"; -import { useSelectedWarehouse } from "@/hooks/use-selected-warehouse"; -import { newTransferSchema, NewTransferSchema } from "./new-transfer.schema"; -import { NewTransferViewProps } from "./new-transfer.types"; import { useBreadcrumb } from "@/components/breadcrumb"; +import { useSelectedWarehouse } from "@/hooks/use-selected-warehouse"; +import { api } from "@/lib/api"; +import { newTransferSchema, type NewTransferSchema } from "./new-transfer.schema"; +import type { + NewTransferViewProps, + TransferBatchDrawerState, + TransferBatchOption, + TransferProductOption, +} from "./new-transfer.types"; + +const PRODUCT_SEARCH_LIMIT = 5; + +const EMPTY_BATCH_DRAWER: TransferBatchDrawerState = { + isOpen: false, + productId: "", + productName: "", + selectedBatchId: "", + quantity: "1", + error: null, +}; interface WarehouseListResponse { success: boolean; @@ -18,26 +34,151 @@ interface WarehouseListResponse { interface ProductListResponse { success: boolean; - data: - | { content: Array<{ id: string; name: string }> } - | Array<{ id: string; name: string }>; + data: { content: TransferProductOption[] } | TransferProductOption[]; +} + +interface ProductByBarcodeResponse { + success: boolean; + data: TransferProductOption; +} + +interface TransferBatchSource { + id: string; + productId: string; + productName?: string; + batchCode?: string | null; + code?: string | null; + quantity: number; + manufacturedDate?: string | null; + expirationDate?: string | null; } interface BatchListResponse { success: boolean; - data: - | { content: Array<{ id: string; code: string; quantity: number; productId: string }> } - | Array<{ id: string; code: string; quantity: number; productId: string }>; + data: { content: TransferBatchSource[] } | TransferBatchSource[]; +} + +export const formatTransferProductLabel = ( + product: TransferProductOption, +): string => (product.sku ? `${product.name} (${product.sku})` : product.name); + +export const formatTransferProductQuantityLabel = ( + product: TransferProductOption, +): string => `Quantidade: ${product.totalQuantity ?? 0} un.`; + +export const filterTransferProductOptions = ( + products: TransferProductOption[], + query: string, +): TransferProductOption[] => { + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length < 2) return []; + + return products + .filter((product) => doesTransferProductMatch(product, normalizedQuery)) + .slice(0, PRODUCT_SEARCH_LIMIT); +}; + +export const buildTransferProductBatchesUrl = ( + warehouseId: string | null, + productId: string, + isDrawerOpen: boolean, +): string | null => { + if (!warehouseId || !productId || !isDrawerOpen) return null; + return `batches/warehouses/${warehouseId}/products/${productId}/batches`; +}; + +export const clampTransferBatchQuantity = ( + value: string, + maxQuantity?: number, +): string => { + const quantity = Number(value); + if (!Number.isFinite(quantity) || quantity < 1) return "1"; + if (maxQuantity === undefined) return String(quantity); + return String(Math.min(quantity, Math.max(1, maxQuantity))); +}; + +const doesTransferProductMatch = ( + product: TransferProductOption, + normalizedQuery: string, +): boolean => { + const searchText = [product.name, product.sku || "", product.barcode || ""] + .join(" ") + .toLowerCase(); + return searchText.includes(normalizedQuery); +}; + +const getRawResponseContent = (response: { content: T[] } | T[] | undefined): T[] => { + if (!response) return []; + return Array.isArray(response) ? response : response.content; +}; + +const normalizeTransferBatch = ( + batch: TransferBatchSource, +): TransferBatchOption => ({ + id: batch.id, + productId: batch.productId, + productName: batch.productName, + batchCode: batch.batchCode?.trim() || batch.code?.trim() || batch.id, + quantity: batch.quantity, + manufacturedDate: batch.manufacturedDate ?? null, + expirationDate: batch.expirationDate ?? null, +}); + +export const getWarehouseBatchQuantityByProduct = ( + batches: TransferBatchSource[], +): Map => { + return batches.reduce((quantityByProduct, batch) => { + const currentQuantity = quantityByProduct.get(batch.productId) ?? 0; + quantityByProduct.set(batch.productId, currentQuantity + batch.quantity); + return quantityByProduct; + }, new Map()); +}; + +interface TransferFooterVisibilityParams { + currentScrollY: number; + lastScrollY: number; + maxScrollY: number; } +export const shouldShowTransferFooter = ({ + currentScrollY, + lastScrollY, + maxScrollY, +}: TransferFooterVisibilityParams): boolean => { + const isShortPage = maxScrollY <= 8; + const isAtPageEnd = currentScrollY >= maxScrollY - 8; + const isScrollingUp = currentScrollY < lastScrollY; + return isShortPage || isAtPageEnd || isScrollingUp; +}; + +const resolvePositiveQuantity = (value: string): number => { + const quantity = Number(value); + return Number.isFinite(quantity) && quantity > 0 ? quantity : 0; +}; + export function useNewTransferModel(): NewTransferViewProps { const router = useRouter(); const { warehouseId: currentWarehouseId } = useSelectedWarehouse(); const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedProduct, setSelectedProduct] = + useState(null); const [selectedProductId, setSelectedProductId] = useState(""); - const [selectedBatchId, setSelectedBatchId] = useState(""); - const [itemQuantity, setItemQuantity] = useState(""); + const [productSearchQuery, setProductSearchQuery] = useState(""); + const [debouncedProductSearchQuery, setDebouncedProductSearchQuery] = + useState(""); + const [isProductOptionsOpen, setIsProductOptionsOpen] = useState(false); + const [isScannerOpen, setIsScannerOpen] = useState(false); + const [batchDrawer, setBatchDrawer] = + useState(EMPTY_BATCH_DRAWER); const [addItemError, setAddItemError] = useState(null); + const [isFooterVisible, setIsFooterVisible] = useState(true); + const productSearchBlurTimeoutRef = + useRef | null>(null); + const lastScannedBarcodeRef = useRef(null); + const barcodeResetTimeoutRef = useRef | null>( + null, + ); + const lastScrollYRef = useRef(0); useBreadcrumb({ title: "Nova Transferência", @@ -60,103 +201,275 @@ export function useNewTransferModel(): NewTransferViewProps { }); const { data: warehousesData, isLoading: isLoadingWarehouses } = - useSWR("warehouses", async (url: string) => - api.get(url).json() + useSWR("warehouses", (url: string) => + api.get(url).json(), ); const { data: productsData, isLoading: isLoadingProducts } = - useSWR("products", async (url: string) => - api.get(url).json() + useSWR("products", (url: string) => + api.get(url).json(), ); - const { data: batchesData } = useSWR( - selectedProductId && currentWarehouseId - ? `batches/warehouse/${currentWarehouseId}` - : null, - async (url: string) => api.get(url).json() + const { data: warehouseBatchesData } = useSWR( + currentWarehouseId ? `batches/warehouse/${currentWarehouseId}` : null, + (url: string) => api.get(url).json(), ); + const batchesUrl = buildTransferProductBatchesUrl( + currentWarehouseId, + batchDrawer.productId, + batchDrawer.isOpen, + ); + const { data: batchesData, isLoading: isBatchLoading } = + useSWR(batchesUrl, (url: string) => + api.get(url).json(), + ); + + useEffect(() => { + const timeoutId = setTimeout(() => { + setDebouncedProductSearchQuery(productSearchQuery); + }, 350); + return () => clearTimeout(timeoutId); + }, [productSearchQuery]); + + useEffect(() => { + return () => { + clearProductSearchBlurTimeout(); + if (!barcodeResetTimeoutRef.current) return; + clearTimeout(barcodeResetTimeoutRef.current); + }; + }, []); + + useEffect(() => { + const handleScroll = (): void => { + const currentScrollY = window.scrollY; + const maxScrollY = + document.documentElement.scrollHeight - window.innerHeight; + setIsFooterVisible( + shouldShowTransferFooter({ + currentScrollY, + lastScrollY: lastScrollYRef.current, + maxScrollY, + }), + ); + lastScrollYRef.current = Math.max(currentScrollY, 0); + }; + + handleScroll(); + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + const warehouses = (warehousesData?.data || []).flatMap((warehouse) => warehouse.id === currentWarehouseId ? [] : [{ id: warehouse.id, name: warehouse.name }], ); - - const rawProducts = productsData?.data; - const products = ( - Array.isArray(rawProducts) ? rawProducts : rawProducts?.content || [] - ).map((p) => ({ - id: p.id, - name: p.name, - })); - - const rawBatches = batchesData?.data; - const batches = ( - Array.isArray(rawBatches) ? rawBatches : rawBatches?.content || [] - ).flatMap((batch) => - batch.productId === selectedProductId - ? [ - { - id: batch.id, - code: batch.code, - quantity: batch.quantity, - }, - ] - : [], + const warehouseBatchQuantityByProduct = getWarehouseBatchQuantityByProduct( + getRawResponseContent(warehouseBatchesData?.data), ); + const products = getRawResponseContent(productsData?.data).map((product) => { + const totalQuantity = warehouseBatchQuantityByProduct.get(product.id) ?? 0; + return { + id: product.id, + name: product.name, + sku: product.sku, + barcode: product.barcode, + imageUrl: product.imageUrl, + totalQuantity, + stockQuantityLabel: formatTransferProductQuantityLabel({ + ...product, + totalQuantity, + }), + }; + }); + const batches = getRawResponseContent(batchesData?.data).flatMap((batch) => { + const normalizedBatch = normalizeTransferBatch(batch); + if (normalizedBatch.productId !== batchDrawer.productId) return []; + return normalizedBatch.quantity > 0 ? [normalizedBatch] : []; + }); + const productOptions = filterTransferProductOptions( + products, + debouncedProductSearchQuery, + ); + + const clearProductSearchBlurTimeout = (): void => { + if (!productSearchBlurTimeoutRef.current) return; + clearTimeout(productSearchBlurTimeoutRef.current); + productSearchBlurTimeoutRef.current = null; + }; + + const openBatchDrawerForProduct = (product: TransferProductOption): void => { + if (!currentWarehouseId) { + setAddItemError("Selecione um warehouse de origem."); + return; + } + + setBatchDrawer({ + isOpen: true, + productId: product.id, + productName: product.name, + selectedBatchId: "", + quantity: "1", + error: null, + }); + }; - const handleProductChange = (productId: string) => { - setSelectedProductId(productId); - setSelectedBatchId(""); - setItemQuantity(""); + const handleProductSelect = (product: TransferProductOption): void => { + setSelectedProduct(product); + setSelectedProductId(product.id); + setProductSearchQuery(formatTransferProductLabel(product)); + setIsProductOptionsOpen(false); setAddItemError(null); + openBatchDrawerForProduct(product); + }; + + const handleProductSearchFocus = (): void => { + clearProductSearchBlurTimeout(); + setIsProductOptionsOpen(true); }; - const handleAddItem = () => { + const handleProductSearchBlur = (): void => { + productSearchBlurTimeoutRef.current = setTimeout(() => { + setIsProductOptionsOpen(false); + }, 120); + }; + + const handleProductSearchChange = (query: string): void => { + setProductSearchQuery(query); + setIsProductOptionsOpen(true); setAddItemError(null); + if (!selectedProduct) return; + if (query === formatTransferProductLabel(selectedProduct)) return; + setSelectedProduct(null); + setSelectedProductId(""); + }; - if (!selectedProductId) { - setAddItemError("Selecione um produto."); - return; - } - if (!selectedBatchId) { - setAddItemError("Selecione um lote."); - return; - } + const resetProductBuilder = (): void => { + setSelectedProduct(null); + setSelectedProductId(""); + setProductSearchQuery(""); + setIsProductOptionsOpen(false); + setBatchDrawer(EMPTY_BATCH_DRAWER); + }; + + const handleProductClear = (): void => { + resetProductBuilder(); + setAddItemError(null); + }; + + const handleBatchDrawerOpenChange = (open: boolean): void => { + setBatchDrawer((current) => ({ + ...current, + isOpen: open, + error: open ? current.error : null, + })); + }; + + const updateBatchDrawer = ( + patch: Partial, + ): void => { + setBatchDrawer((current) => ({ + ...current, + ...patch, + error: patch.error ?? null, + })); + }; + + const updateBatchQuantity = ( + calculateNextQuantity: (quantity: number) => number, + ): void => { + setBatchDrawer((current) => { + const batch = batches.find((item) => item.id === current.selectedBatchId); + const currentQuantity = Number( + clampTransferBatchQuantity(current.quantity, batch?.quantity), + ); + const nextQuantity = calculateNextQuantity(currentQuantity); + return { + ...current, + quantity: clampTransferBatchQuantity(String(nextQuantity), batch?.quantity), + error: null, + }; + }); + }; - const qty = Number(itemQuantity); - if (!qty || qty <= 0) { - setAddItemError("Quantidade inválida."); + const handleBatchChange = (batchId: string): void => { + const batch = batches.find((item) => item.id === batchId); + setBatchDrawer((current) => ({ + ...current, + selectedBatchId: batchId, + quantity: clampTransferBatchQuantity(current.quantity, batch?.quantity), + error: null, + })); + }; + + const handleQuantityChange = (quantity: string): void => { + const batch = batches.find((item) => item.id === batchDrawer.selectedBatchId); + updateBatchDrawer({ + quantity: clampTransferBatchQuantity(quantity, batch?.quantity), + }); + }; + + const appendSelectedBatch = ( + batch: TransferBatchOption, + quantity: number, + ): void => { + append({ + sourceBatchId: batch.id, + quantity, + productName: batch.productName || batchDrawer.productName, + batchCode: batch.batchCode, + availableQuantity: batch.quantity, + }); + resetProductBuilder(); + }; + + const handleConfirmBatch = (): void => { + const batch = batches.find((item) => item.id === batchDrawer.selectedBatchId); + const quantity = resolvePositiveQuantity(batchDrawer.quantity); + if (!batchDrawer.selectedBatchId) { + updateBatchDrawer({ error: "Selecione um lote." }); return; } - - const batch = batches.find((b) => b.id === selectedBatchId); if (!batch) { - setAddItemError("Lote inválido."); + updateBatchDrawer({ error: "Lote indisponível para o produto selecionado." }); return; } - - if (qty > batch.quantity) { - setAddItemError(`Quantidade indisponível no lote (Máx: ${batch.quantity}).`); + if (!quantity) { + updateBatchDrawer({ error: "Informe uma quantidade válida." }); return; } + if (quantity > batch.quantity) { + updateBatchDrawer({ error: `Quantidade indisponível no lote (Máx: ${batch.quantity}).` }); + return; + } + appendSelectedBatch(batch, quantity); + }; - const product = products.find((p) => p.id === selectedProductId); - - append({ - sourceBatchId: selectedBatchId, - quantity: qty, - productName: product?.name || "Desconhecido", - batchCode: batch.code, - availableQuantity: batch.quantity, - }); + const markBarcodeAsProcessing = (barcode: string): boolean => { + if (lastScannedBarcodeRef.current === barcode) return false; + lastScannedBarcodeRef.current = barcode; + if (barcodeResetTimeoutRef.current) clearTimeout(barcodeResetTimeoutRef.current); + barcodeResetTimeoutRef.current = setTimeout(() => { + lastScannedBarcodeRef.current = null; + }, 1500); + return true; + }; - setSelectedProductId(""); - setSelectedBatchId(""); - setItemQuantity(""); + const handleBarcodeScan = async (barcode: string): Promise => { + if (!markBarcodeAsProcessing(barcode)) return; + try { + const response = await api + .get(`products/barcode/${encodeURIComponent(barcode)}`) + .json(); + setIsScannerOpen(false); + handleProductSelect(response.data); + } catch { + toast.error(`Produto com código ${barcode} não existe.`); + } }; - const onSubmit = async (data: NewTransferSchema) => { + const onSubmit = async (data: NewTransferSchema): Promise => { if (!currentWarehouseId) { toast.error("Selecione um warehouse de origem."); return; @@ -186,8 +499,7 @@ export function useNewTransferModel(): NewTransferViewProps { router.push("/transfers"); } catch (err: unknown) { const error = err as Error; - const errorMessage = error.message || "Erro ao criar transferência."; - toast.error(errorMessage); + toast.error(error.message || "Erro ao criar transferência."); } finally { setIsSubmitting(false); } @@ -198,17 +510,33 @@ export function useNewTransferModel(): NewTransferViewProps { onSubmit, warehouses, products, + productOptions, batches, + batchDrawer, isLoading: isLoadingWarehouses || isLoadingProducts, + isProductSearchLoading: + isLoadingProducts && debouncedProductSearchQuery.trim().length >= 2, + isProductOptionsOpen, + isBatchLoading, + isScannerOpen, + isFooterVisible, isSubmitting, selectedProductId, - selectedBatchId, - itemQuantity, + productSearchQuery, addItemError, - onProductChange: handleProductChange, - onBatchChange: setSelectedBatchId, - onQuantityChange: setItemQuantity, - onAddItem: handleAddItem, + onProductSearchChange: handleProductSearchChange, + onProductSearchFocus: handleProductSearchFocus, + onProductSearchBlur: handleProductSearchBlur, + onProductSelect: handleProductSelect, + onProductClear: handleProductClear, + onScannerOpenChange: setIsScannerOpen, + onBarcodeScan: handleBarcodeScan, + onBatchDrawerOpenChange: handleBatchDrawerOpenChange, + onBatchChange: handleBatchChange, + onQuantityChange: handleQuantityChange, + onQuantityIncrement: () => updateBatchQuantity((quantity) => quantity + 1), + onQuantityDecrement: () => updateBatchQuantity((quantity) => quantity - 1), + onConfirmBatch: handleConfirmBatch, onRemoveItem: remove, items: fields, }; diff --git a/app/(pages)/transfers/new/new-transfer.types.ts b/app/(pages)/transfers/new/new-transfer.types.ts index 4100e95..4bd1fa8 100644 --- a/app/(pages)/transfers/new/new-transfer.types.ts +++ b/app/(pages)/transfers/new/new-transfer.types.ts @@ -1,24 +1,75 @@ import { UseFormReturn } from "react-hook-form"; import { NewTransferSchema } from "./new-transfer.schema"; +export interface TransferProductOption { + id: string; + name: string; + sku?: string | null; + barcode?: string | null; + imageUrl?: string | null; + totalQuantity?: number | null; + stockQuantityLabel?: string; +} + +export interface TransferBatchOption { + id: string; + productId: string; + productName?: string; + batchCode: string; + quantity: number; + manufacturedDate: string | null; + expirationDate: string | null; +} + +export interface TransferBatchDrawerState { + isOpen: boolean; + productId: string; + productName: string; + selectedBatchId: string; + quantity: string; + error: string | null; +} + +export interface NewTransferItemView { + id: string; + productName?: string; + batchCode?: string; + quantity: number; + availableQuantity?: number; +} + export interface NewTransferViewProps { form: UseFormReturn; onSubmit: (data: NewTransferSchema) => void; warehouses: { id: string; name: string }[]; - products: { id: string; name: string }[]; - batches: { id: string; code: string; quantity: number }[]; + products: TransferProductOption[]; + productOptions: TransferProductOption[]; + batches: TransferBatchOption[]; + batchDrawer: TransferBatchDrawerState; isLoading: boolean; + isProductSearchLoading: boolean; + isProductOptionsOpen: boolean; + isBatchLoading: boolean; + isScannerOpen: boolean; + isFooterVisible: boolean; isSubmitting: boolean; - // Item builder state (managed by model) selectedProductId: string; - selectedBatchId: string; - itemQuantity: string; + productSearchQuery: string; addItemError: string | null; - onProductChange: (productId: string) => void; + onProductSearchChange: (query: string) => void; + onProductSearchFocus: () => void; + onProductSearchBlur: () => void; + onProductSelect: (product: TransferProductOption) => void; + onProductClear: () => void; + onScannerOpenChange: (open: boolean) => void; + onBarcodeScan: (barcode: string) => void; + onBatchDrawerOpenChange: (open: boolean) => void; onBatchChange: (batchId: string) => void; onQuantityChange: (value: string) => void; - onAddItem: () => void; + onQuantityIncrement: () => void; + onQuantityDecrement: () => void; + onConfirmBatch: () => void; onRemoveItem: (index: number) => void; - items: Array<{ id: string; productName?: string; batchCode?: string; quantity: number }>; + items: NewTransferItemView[]; } diff --git a/app/(pages)/transfers/new/new-transfer.view.test.tsx b/app/(pages)/transfers/new/new-transfer.view.test.tsx index ae273e4..f363c1c 100644 --- a/app/(pages)/transfers/new/new-transfer.view.test.tsx +++ b/app/(pages)/transfers/new/new-transfer.view.test.tsx @@ -26,6 +26,14 @@ vi.mock("@/lib/contexts/auth-context", () => ({ }), })); +vi.mock("./new-transfer-scanner.view", () => ({ + NewTransferScanner: () =>
, +})); + +vi.mock("./new-transfer-batch-drawer.view", () => ({ + NewTransferBatchDrawer: () =>
, +})); + const TestWrapper = (props: Partial) => { const form = useForm({ resolver: zodResolver(newTransferSchema), @@ -42,18 +50,67 @@ const TestWrapper = (props: Partial) => { { id: "w1", name: "Warehouse A" }, { id: "w2", name: "Warehouse B" }, ], - products: [{ id: "p1", name: "Product 1" }], - batches: [{ id: "b1", code: "BATCH-001", quantity: 100 }], + products: [ + { + id: "p1", + name: "Product 1", + sku: "P1", + barcode: "789", + totalQuantity: 12, + stockQuantityLabel: "Quantidade: 12 un.", + }, + ], + productOptions: [ + { + id: "p1", + name: "Product 1", + sku: "P1", + barcode: "789", + totalQuantity: 12, + stockQuantityLabel: "Quantidade: 12 un.", + }, + ], + batches: [ + { + id: "b1", + productId: "p1", + batchCode: "BATCH-001", + quantity: 100, + manufacturedDate: null, + expirationDate: "2026-12-31", + }, + ], + batchDrawer: { + isOpen: false, + productId: "", + productName: "", + selectedBatchId: "", + quantity: "1", + error: null, + }, isLoading: false, + isProductSearchLoading: false, + isProductOptionsOpen: false, + isBatchLoading: false, + isScannerOpen: false, + isFooterVisible: true, isSubmitting: false, selectedProductId: "", - selectedBatchId: "", - itemQuantity: "", + productSearchQuery: "", addItemError: null, - onProductChange: vi.fn(), + onProductSearchChange: vi.fn(), + onProductSearchFocus: vi.fn(), + onProductSearchBlur: vi.fn(), + onProductSelect: vi.fn(), + onProductClear: vi.fn(), + onScannerOpenChange: vi.fn(), + onBarcodeScan: vi.fn(), + onBatchDrawerOpenChange: vi.fn(), onBatchChange: vi.fn(), onQuantityChange: vi.fn(), - onAddItem: vi.fn(), + onQuantityIncrement: vi.fn(), + onQuantityDecrement: vi.fn(), + onConfirmBatch: vi.fn(), onRemoveItem: vi.fn(), items: [], ...props, @@ -66,7 +123,7 @@ describe("NewTransferView", () => { it("renders the form correctly", () => { render(); expect(screen.getByText("Rota")).toBeTruthy(); - expect(screen.getByText("Adicionar Item")).toBeTruthy(); + expect(screen.getByText("Selecionar produto")).toBeTruthy(); const buttons = screen.getAllByText("CRIAR TRANSFERÊNCIA"); expect(buttons.length).toBeGreaterThan(0); @@ -81,4 +138,18 @@ describe("NewTransferView", () => { expect(await screen.findByText("Selecione um warehouse de destino")).toBeTruthy(); expect(await screen.findByText("Adicione pelo menos um item")).toBeTruthy(); }); + + it("shows product quantity instead of product code in autocomplete", () => { + render( + , + ); + + expect(screen.getByText("Product 1")).toBeTruthy(); + expect(screen.getByText("Quantidade: 12 un.")).toBeTruthy(); + expect(screen.queryByText(/P1/)).toBeNull(); + expect(screen.queryByText(/789/)).toBeNull(); + }); }); diff --git a/app/(pages)/transfers/new/new-transfer.view.tsx b/app/(pages)/transfers/new/new-transfer.view.tsx index 9760711..2f8e1c7 100644 --- a/app/(pages)/transfers/new/new-transfer.view.tsx +++ b/app/(pages)/transfers/new/new-transfer.view.tsx @@ -2,17 +2,13 @@ import { AlertCircle, ArrowRight, FileText, - Hash, MapPin, Package, - Plus, Save, - Trash2, Warehouse, } from "lucide-react"; import { useState, type Dispatch, type SetStateAction } from "react"; import { PermissionGate } from "@/components/permission-gate"; -import { EmptyState } from "@/components/ui/empty-state"; import { FixedBottomBar } from "@/components/ui/fixed-bottom-bar"; import { Form, @@ -22,11 +18,9 @@ import { FormMessage, } from "@/components/ui/form"; import { FormSection } from "@/components/ui/form-section"; -import { NumberInput } from "@/components/ui/number-input"; import { PageContainer } from "@/components/ui/page-container"; import { PageHeader } from "@/components/ui/page-header"; import { ResponsiveModal } from "@/components/ui/responsive-modal"; -import { SectionLabel } from "@/components/ui/section-label"; import { Select, SelectContent, @@ -36,7 +30,12 @@ import { } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; import type { NewTransferViewProps } from "./new-transfer.types"; +import { NewTransferBatchDrawer } from "./new-transfer-batch-drawer.view"; +import { TransferItemsList } from "./new-transfer-items-list.view"; +import { TransferProductSearch } from "./new-transfer-product-search.view"; +import { NewTransferScanner } from "./new-transfer-scanner.view"; interface NewTransferViewState extends NewTransferViewProps { isNotesOpen: boolean; @@ -61,8 +60,24 @@ export function NewTransferView(props: NewTransferViewProps) { }; return ( - + + +
- + @@ -261,147 +279,17 @@ function TransferItemBuilder({
- - +
); } -function TransferProductBatchSelectors({ - viewState, -}: { - viewState: NewTransferViewState; -}) { - return ( -
- - -
- ); -} - -function TransferProductSelect({ - viewState, -}: { - viewState: NewTransferViewState; -}) { - const { onProductChange, products, selectedProductId } = viewState; - - return ( -
- - -
- ); -} - -function TransferBatchSelect({ - viewState, -}: { - viewState: NewTransferViewState; -}) { - const { batches, onBatchChange, selectedBatchId, selectedProductId } = - viewState; - - return ( -
- - -
- ); -} - -function TransferQuantityInput({ - viewState, -}: { - viewState: NewTransferViewState; -}) { - const { itemQuantity, onAddItem, onQuantityChange } = viewState; - - return ( -
- -
- - onQuantityChange(value !== undefined ? String(value) : "") - } - className="h-10 w-full rounded-[4px] border-2 border-neutral-800 bg-neutral-900 font-mono text-sm tracking-tighter text-white focus:border-blue-600" - placeholder="0" - /> - -
-
- ); -} - function TransferAddItemError({ addItemError, }: { @@ -417,206 +305,31 @@ function TransferAddItemError({ ); } -function TransferItemsList({ +function TransferSubmitBar({ viewState, }: { viewState: NewTransferViewState; }) { - const { form, items } = viewState; + const { isFooterVisible, isLoading, isSubmitting, items, totalQuantity } = + viewState; return ( -
- - Itens na Transferência ({items.length}) - - {items.length === 0 ? ( - - ) : ( - <> - - - + - {form.formState.errors.items.message} -

- )} -
- ); -} - -function TransferItemsMobileList({ - viewState, -}: { - viewState: NewTransferViewState; -}) { - return ( -
- {viewState.items.map((item, index) => ( -
-
-

- {item.productName || "Produto"} -

-
- - Lote:{" "} - - {item.batchCode} - - - - Qtd:{" "} - - {item.quantity} - - -
-
- -
- ))} -
- ); -} - -function TransferItemsDesktopTable({ - viewState, -}: { - viewState: NewTransferViewState; -}) { - const { items, totalQuantity } = viewState; - - return ( -
-
- - - - - - - - - - {items.map((item, index) => ( - - ))} - - - - - - - -
- Produto - - Lote - - Quantidade - -
- Total - - {totalQuantity} - -
-
-
- ); -} - -function TransferItemsDesktopRow({ - index, - item, - onRemoveItem, -}: { - index: number; - item: NewTransferViewProps["items"][number]; - onRemoveItem: (index: number) => void; -}) { - return ( - - - {item.productName || "Produto"} - - - {item.batchCode || "—"} - - - {item.quantity} - - - - - - ); -} - -function TransferRemoveItemButton({ - index, - onRemoveItem, - sizeClassName, -}: { - index: number; - onRemoveItem: (index: number) => void; - sizeClassName: string; -}) { - return ( - - ); -} - -function TransferSubmitBar({ - viewState, -}: { - viewState: NewTransferViewState; -}) { - const { isLoading, isSubmitting, items, totalQuantity } = viewState; - - return ( - -
+
-
+
@@ -624,7 +337,7 @@ function TransferSubmitBar({
diff --git a/app/(pages)/transfers/new/new-transfer-scanner.view.tsx b/app/(pages)/transfers/new/new-transfer-scanner.view.tsx index 9c575b7..591bec2 100644 --- a/app/(pages)/transfers/new/new-transfer-scanner.view.tsx +++ b/app/(pages)/transfers/new/new-transfer-scanner.view.tsx @@ -1,6 +1,6 @@ "use client"; -import { type IDetectedBarcode } from "@yudiel/react-qr-scanner"; +import { type BarcodeScannerDetectedCode } from "@/components/product/barcode-scanner.types"; import { ScanLine, X } from "lucide-react"; import { BarcodeScanner } from "@/components/product/barcode-scanner"; @@ -24,7 +24,7 @@ export function NewTransferScanner({ onOpenChange, onScan, }: NewTransferScannerProps) { - const handleScan = (detectedCodes: IDetectedBarcode[]): void => { + const handleScan = (detectedCodes: BarcodeScannerDetectedCode[]): void => { const barcode = detectedCodes[0]?.rawValue; if (!barcode) return; onScan(barcode); @@ -67,12 +67,7 @@ export function NewTransferScanner({ ; +type BarcodeScannerFormatList = BarcodeFormat[]; type BarcodeScannerCameraOptions = { deviceId: string | null; diff --git a/components/product/barcode-scanner-error-modal.tsx b/components/product/barcode-scanner-error-modal.tsx new file mode 100644 index 0000000..f0c7780 --- /dev/null +++ b/components/product/barcode-scanner-error-modal.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ResponsiveModal } from "@/components/ui/responsive-modal"; + +interface BarcodeScannerErrorModalProps { + content: string | null; + onClose: () => void; +} + +export const formatBarcodeScannerError = (error: unknown): string => { + if (error instanceof Error) { + return JSON.stringify( + { + name: error.name, + message: error.message, + stack: error.stack, + }, + null, + 2, + ); + } + + try { + return JSON.stringify(error, null, 2); + } catch { + return String(error); + } +}; + +export const BarcodeScannerErrorModal = ({ + content, + onClose, +}: BarcodeScannerErrorModalProps) => { + const handleCopyContent = (): void => { + if (!content) return; + void navigator.clipboard?.writeText(content); + }; + + return ( + !open && onClose()} + title="Erro no leitor" + description="Conteúdo capturado pelo leitor de código de barras." + footer={ + <> + + + + } + > +
+        {content}
+      
+
+ ); +}; diff --git a/components/product/barcode-scanner-modal.tsx b/components/product/barcode-scanner-modal.tsx index de59ed2..54b4491 100644 --- a/components/product/barcode-scanner-modal.tsx +++ b/components/product/barcode-scanner-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { type IDetectedBarcode } from "@yudiel/react-qr-scanner"; +import { type BarcodeScannerDetectedCode } from "@/components/product/barcode-scanner.types"; import { AlertCircle } from "lucide-react"; import { ResponsiveModal } from "@/components/ui/responsive-modal"; import { Button } from "@/components/ui/button"; @@ -17,7 +17,7 @@ export const BarcodeScannerModal = ({ onClose, onScan, }: BarcodeScannerModalProps) => { - const handleScan = (detectedCodes: IDetectedBarcode[]) => { + const handleScan = (detectedCodes: BarcodeScannerDetectedCode[]) => { if (detectedCodes && detectedCodes.length > 0) { const code = detectedCodes[0].rawValue; onScan(code); @@ -61,12 +61,7 @@ export const BarcodeScannerModal = ({ objectFit: "cover", }, }} - components={{ - onOff: false, - torch: false, - zoom: false, - finder: true, - }} + components={{ finder: true }} /> {/* Overlay Instructions - Corporate Solid */} diff --git a/components/product/barcode-scanner.tsx b/components/product/barcode-scanner.tsx index 75ca466..5465354 100644 --- a/components/product/barcode-scanner.tsx +++ b/components/product/barcode-scanner.tsx @@ -1,48 +1,49 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { DetectedBarcode } from "barcode-detector/ponyfill"; import { - Scanner, - type IScannerProps, -} from "@yudiel/react-qr-scanner"; -import { Button } from "@/components/ui/button"; -import { ResponsiveModal } from "@/components/ui/responsive-modal"; -import { - barcodeScannerFormats, createBarcodeScannerCameraConstraints, getBarcodeScannerDeviceIds, shouldRetryBarcodeScannerCamera, } from "@/components/product/barcode-scanner-camera"; - -type BarcodeScannerProps = Omit; +import { + BarcodeScannerErrorModal, + formatBarcodeScannerError, +} from "@/components/product/barcode-scanner-error-modal"; +import { useBarcodeDetectionLoop } from "@/components/product/use-barcode-detection-loop"; +import { useBarcodeScannerStream } from "@/components/product/use-barcode-scanner-stream"; +import type { + BarcodeScannerDetectedCode, + BarcodeScannerProps, +} from "@/components/product/barcode-scanner.types"; const BARCODE_SCANNER_DEVICE_REFRESH_DELAYS_MS = [750, 2500] as const; - -const formatBarcodeScannerError = (error: unknown): string => { - if (error instanceof Error) { - return JSON.stringify( - { - name: error.name, - message: error.message, - stack: error.stack, - }, - null, - 2, - ); - } - - try { - return JSON.stringify(error, null, 2); - } catch { - return String(error); - } -}; +const BARCODE_SCANNER_RESCAN_DELAY_MS = 1500; +const BARCODE_SCANNER_MAX_CAMERA_RETRIES = 4; + +const toBarcodeScannerDetectedCode = ( + detectedBarcode: DetectedBarcode, +): BarcodeScannerDetectedCode => ({ + rawValue: detectedBarcode.rawValue, + format: detectedBarcode.format, +}); + +const BarcodeScannerFinder = () => ( +
+
+
+); export const BarcodeScanner = ({ - onError, onScan, - ...scannerProps + onError, + styles, + components, }: BarcodeScannerProps) => { + const videoRef = useRef(null); + const lastScanRef = useRef({ value: "", scannedAt: 0 }); + const cameraRetryCountRef = useRef(0); const [usesCompatibleCamera, setUsesCompatibleCamera] = useState(false); const [cameraDeviceIds, setCameraDeviceIds] = useState([]); const [cameraDeviceIndex, setCameraDeviceIndex] = useState(0); @@ -58,10 +59,6 @@ export const BarcodeScanner = ({ [selectedCameraDeviceId, usesCompatibleCamera], ); - const scannerKey = `${selectedCameraDeviceId ?? "environment"}:${ - usesCompatibleCamera ? "compatible" : "preferred" - }`; - const refreshCameraDevices = useCallback(async (): Promise => { if (!navigator.mediaDevices?.enumerateDevices) return; @@ -70,6 +67,9 @@ export const BarcodeScanner = ({ }, []); const selectNextCameraConfiguration = useCallback(() => { + if (cameraRetryCountRef.current >= BARCODE_SCANNER_MAX_CAMERA_RETRIES) return; + + cameraRetryCountRef.current += 1; setUsesCompatibleCamera(true); setCameraDeviceIndex((currentIndex) => { if (cameraDeviceIds.length < 2) return currentIndex; @@ -77,6 +77,11 @@ export const BarcodeScanner = ({ }); }, [cameraDeviceIds.length]); + const handleStreamStart = useCallback(() => { + cameraRetryCountRef.current = 0; + void refreshCameraDevices(); + }, [refreshCameraDevices]); + const handleScannerError = useCallback( (error: unknown) => { setScannerErrorContent(formatBarcodeScannerError(error)); @@ -91,10 +96,33 @@ export const BarcodeScanner = ({ [onError, refreshCameraDevices, selectNextCameraConfiguration], ); - const handleCopyScannerError = useCallback((): void => { - if (!scannerErrorContent) return; - void navigator.clipboard?.writeText(scannerErrorContent); - }, [scannerErrorContent]); + const emitDetectedBarcodes = useCallback( + (detectedBarcodes: DetectedBarcode[]) => { + const scannedValue = detectedBarcodes[0]?.rawValue; + if (!scannedValue) return; + + const scannedAt = Date.now(); + const lastScan = lastScanRef.current; + const isRepeatedScan = + scannedValue === lastScan.value && + scannedAt - lastScan.scannedAt < BARCODE_SCANNER_RESCAN_DELAY_MS; + + lastScanRef.current = { value: scannedValue, scannedAt }; + if (isRepeatedScan) return; + + onScan(detectedBarcodes.map(toBarcodeScannerDetectedCode)); + }, + [onScan], + ); + + useBarcodeScannerStream({ + videoRef, + constraints: cameraConstraints, + onStreamStart: handleStreamStart, + onStreamError: handleScannerError, + }); + + useBarcodeDetectionLoop({ videoRef, onDetect: emitDetectedBarcodes }); useEffect(() => { void refreshCameraDevices(); @@ -115,43 +143,32 @@ export const BarcodeScanner = ({ return ( <> - - !open && setScannerErrorContent(null)} - title="Erro no leitor" - description="Conteúdo capturado pelo leitor de código de barras." - footer={ - <> - - - - } +
-
-          {scannerErrorContent}
-        
- +
+ setScannerErrorContent(null)} + /> ); }; diff --git a/components/product/barcode-scanner.types.ts b/components/product/barcode-scanner.types.ts new file mode 100644 index 0000000..5443392 --- /dev/null +++ b/components/product/barcode-scanner.types.ts @@ -0,0 +1,22 @@ +import type { CSSProperties } from "react"; + +export interface BarcodeScannerDetectedCode { + rawValue: string; + format: string; +} + +export interface BarcodeScannerStyles { + container?: CSSProperties; + video?: CSSProperties; +} + +export interface BarcodeScannerComponents { + finder?: boolean; +} + +export interface BarcodeScannerProps { + onScan: (detectedCodes: BarcodeScannerDetectedCode[]) => void; + onError?: (error: unknown) => void; + styles?: BarcodeScannerStyles; + components?: BarcodeScannerComponents; +} diff --git a/components/product/use-barcode-detection-loop.ts b/components/product/use-barcode-detection-loop.ts new file mode 100644 index 0000000..da9845f --- /dev/null +++ b/components/product/use-barcode-detection-loop.ts @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect, type RefObject } from "react"; +import { + BarcodeDetector, + type DetectedBarcode, +} from "barcode-detector/ponyfill"; +import { barcodeScannerFormats } from "@/components/product/barcode-scanner-camera"; + +type BarcodeDetectionLoopOptions = { + videoRef: RefObject; + onDetect: (detectedBarcodes: DetectedBarcode[]) => void; +}; + +const BARCODE_DETECTION_MAX_FRAME_WIDTH_PX = 640; +const BARCODE_DETECTION_VERTICAL_CROP_RATIO = 0.6; + +const canDetectFromVideo = (video: HTMLVideoElement | null): video is HTMLVideoElement => + video !== null && + video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && + video.videoWidth > 0; + +const cropBarcodeDetectionFrame = ( + video: HTMLVideoElement, + canvas: HTMLCanvasElement, +): ImageData | null => { + const sourceHeight = video.videoHeight * BARCODE_DETECTION_VERTICAL_CROP_RATIO; + const sourceTop = (video.videoHeight - sourceHeight) / 2; + const scale = Math.min(1, BARCODE_DETECTION_MAX_FRAME_WIDTH_PX / video.videoWidth); + + canvas.width = Math.round(video.videoWidth * scale); + canvas.height = Math.round(sourceHeight * scale); + + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return null; + + context.drawImage( + video, + 0, + sourceTop, + video.videoWidth, + sourceHeight, + 0, + 0, + canvas.width, + canvas.height, + ); + return context.getImageData(0, 0, canvas.width, canvas.height); +}; + +export const useBarcodeDetectionLoop = ({ + videoRef, + onDetect, +}: BarcodeDetectionLoopOptions): void => { + useEffect(() => { + const detector = new BarcodeDetector({ formats: barcodeScannerFormats }); + const frameCanvas = document.createElement("canvas"); + let isCancelled = false; + + const detectCurrentFrame = async (): Promise => { + const video = videoRef.current; + if (!canDetectFromVideo(video)) return; + + try { + const frame = cropBarcodeDetectionFrame(video, frameCanvas); + if (!frame) return; + + const detectedBarcodes = await detector.detect(frame); + if (detectedBarcodes.length > 0 && !isCancelled) onDetect(detectedBarcodes); + } catch { + // Frames podem falhar durante troca de câmera; o loop continua. + } + }; + + const runDetectionLoop = (): void => { + if (isCancelled) return; + void detectCurrentFrame().finally(() => + window.requestAnimationFrame(runDetectionLoop), + ); + }; + + runDetectionLoop(); + return () => { + isCancelled = true; + }; + }, [onDetect, videoRef]); +}; diff --git a/components/product/use-barcode-scanner-stream.ts b/components/product/use-barcode-scanner-stream.ts new file mode 100644 index 0000000..62e24fb --- /dev/null +++ b/components/product/use-barcode-scanner-stream.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, type RefObject } from "react"; + +type BarcodeScannerStreamOptions = { + videoRef: RefObject; + constraints: MediaTrackConstraints; + onStreamStart: () => void; + onStreamError: (error: unknown) => void; +}; + +const stopMediaStream = (stream: MediaStream | null): void => { + if (!stream) return; + stream.getTracks().forEach((track) => track.stop()); +}; + +const isInterruptedPlaybackError = (error: unknown): boolean => + error instanceof DOMException && error.name === "AbortError"; + +const attachStreamToVideo = async ( + video: HTMLVideoElement, + stream: MediaStream, +): Promise => { + video.srcObject = stream; + await video.play(); +}; + +export const useBarcodeScannerStream = ({ + videoRef, + constraints, + onStreamStart, + onStreamError, +}: BarcodeScannerStreamOptions): void => { + useEffect(() => { + const video = videoRef.current; + if (!video || !navigator.mediaDevices?.getUserMedia) return; + + let activeStream: MediaStream | null = null; + let isCancelled = false; + + const startStream = async (): Promise => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: constraints, + }); + if (isCancelled) { + stopMediaStream(stream); + return; + } + activeStream = stream; + await attachStreamToVideo(video, stream); + onStreamStart(); + } catch (error) { + if (isCancelled || isInterruptedPlaybackError(error)) return; + onStreamError(error); + } + }; + + void startStream(); + + return () => { + isCancelled = true; + stopMediaStream(activeStream); + video.srcObject = null; + }; + }, [constraints, onStreamError, onStreamStart, videoRef]); +}; diff --git a/package.json b/package.json index 6b50536..3651d6b 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@testing-library/react": "^16.3.1", - "@yudiel/react-qr-scanner": "^2.5.0", + "barcode-detector": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6a8295..162cec4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,9 +95,9 @@ importers: '@testing-library/react': specifier: ^16.3.1 version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@yudiel/react-qr-scanner': - specifier: ^2.5.0 - version: 2.5.0(@types/emscripten@1.41.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + barcode-detector: + specifier: ^3.2.0 + version: 3.2.0(@types/emscripten@1.41.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1947,12 +1947,6 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@yudiel/react-qr-scanner@2.5.0': - resolution: {integrity: sha512-wVWvm0z5kGGc3tiMcxyr2aji3C6aU5K3Q7R08MOdyFDFkvMbFMF1Hz5P5WFYW+zuuqxDPBcRZPCvmVs6jBheTQ==} - peerDependencies: - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2069,8 +2063,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - barcode-detector@3.0.8: - resolution: {integrity: sha512-Z9jzzE8ngEDyN9EU7lWdGgV07mcnEQnrX8W9WecXDqD2v+5CcVjt9+a134a5zb+kICvpsrDx6NYA6ay4LGFs8A==} + barcode-detector@3.2.0: + resolution: {integrity: sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==} bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -3378,9 +3372,6 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} - sdp@3.2.1: - resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3624,8 +3615,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@5.3.1: - resolution: {integrity: sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==} + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} typed-array-buffer@1.0.3: @@ -3781,10 +3772,6 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} - webrtc-adapter@9.0.3: - resolution: {integrity: sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ==} - engines: {node: '>=6.0.0', npm: '>=3.10.0'} - whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -3861,8 +3848,8 @@ packages: zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} - zxing-wasm@2.2.4: - resolution: {integrity: sha512-1gq5zs4wuNTs5umWLypzNNeuJoluFvwmvjiiT3L9z/TMlVveeJRWy7h90xyUqCe+Qq0zL0w7o5zkdDMWDr9aZA==} + zxing-wasm@3.1.0: + resolution: {integrity: sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==} peerDependencies: '@types/emscripten': '>=1.39.6' @@ -5404,15 +5391,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@yudiel/react-qr-scanner@2.5.0(@types/emscripten@1.41.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - barcode-detector: 3.0.8(@types/emscripten@1.41.5) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - webrtc-adapter: 9.0.3 - transitivePeerDependencies: - - '@types/emscripten' - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -5543,9 +5521,9 @@ snapshots: balanced-match@4.0.4: {} - barcode-detector@3.0.8(@types/emscripten@1.41.5): + barcode-detector@3.2.0(@types/emscripten@1.41.5): dependencies: - zxing-wasm: 2.2.4(@types/emscripten@1.41.5) + zxing-wasm: 3.1.0(@types/emscripten@1.41.5) transitivePeerDependencies: - '@types/emscripten' @@ -7051,8 +7029,6 @@ snapshots: scheduler@0.26.0: {} - sdp@3.2.1: {} - semver@6.3.1: {} semver@7.7.3: {} @@ -7340,7 +7316,7 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@5.3.1: + type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 @@ -7546,10 +7522,6 @@ snapshots: webidl-conversions@8.0.1: {} - webrtc-adapter@9.0.3: - dependencies: - sdp: 3.2.1 - whatwg-mimetype@4.0.0: {} whatwg-url@15.1.0: @@ -7633,7 +7605,7 @@ snapshots: zod@4.1.12: {} - zxing-wasm@2.2.4(@types/emscripten@1.41.5): + zxing-wasm@3.1.0(@types/emscripten@1.41.5): dependencies: '@types/emscripten': 1.41.5 - type-fest: 5.3.1 + type-fest: 5.7.0 From 4aaa23c1404874fb5472bed60ec796043a633d59 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 9 Jun 2026 23:58:04 -0300 Subject: [PATCH 08/32] fix(warehouse): hydrate selected warehouse synchronously --- lib/contexts/warehouse-context.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/contexts/warehouse-context.tsx b/lib/contexts/warehouse-context.tsx index 60bf4fa..053b824 100644 --- a/lib/contexts/warehouse-context.tsx +++ b/lib/contexts/warehouse-context.tsx @@ -3,7 +3,6 @@ import { createContext, use, - useEffect, useState, useSyncExternalStore, ReactNode, @@ -28,8 +27,14 @@ const getClientSnapshot = (): boolean => true; const getServerSnapshot = (): boolean => false; +const readStoredWarehouseId = (): string | null => { + if (typeof window === "undefined") return null; + return localStorage.getItem(WAREHOUSE_STORAGE_KEY); +}; + export const WarehouseProvider = ({ children }: { children: ReactNode }) => { - const [selectedWarehouseId, setSelectedWarehouseIdState] = useState(null); + const [selectedWarehouseId, setSelectedWarehouseIdState] = + useState(readStoredWarehouseId); const isClient = useSyncExternalStore( subscribeToClientHydration, getClientSnapshot, @@ -37,13 +42,6 @@ export const WarehouseProvider = ({ children }: { children: ReactNode }) => { ); const { push } = useRouter(); - useEffect(() => { - const stored = localStorage.getItem(WAREHOUSE_STORAGE_KEY); - if (stored) { - setSelectedWarehouseIdState(stored); - } - }, []); - const setSelectedWarehouseId = (id: string) => { setSelectedWarehouseIdState(id); if (typeof window !== "undefined") { From 935b364416204462352a2265b280c1ae4bebceec Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Tue, 9 Jun 2026 23:58:29 -0300 Subject: [PATCH 09/32] feat(stock-movements): harden draft and barcode flows --- .../products/components/product-form.types.ts | 2 +- .../create-stock-movement.model.test.ts | 209 +++++++++++-- .../create/create-stock-movement.model.ts | 212 +++++++------ .../create-stock-movement.storage.test.ts | 101 ++++++- .../create/create-stock-movement.storage.ts | 102 ++++++- .../create/create-stock-movement.types.ts | 1 + .../new-product-inline.model.test.ts | 278 +++++++++++++++++- .../new-product/new-product-inline.model.ts | 195 ++++++------ .../stock-movement-batch-data-modal.view.tsx | 6 + ...ock-movement-batch-form-validation.test.ts | 88 ++++++ .../stock-movement-batch-form-validation.ts | 35 +++ .../stock-movement-draft-guards.test.ts | 98 ++++++ .../create/stock-movement-draft-guards.ts | 60 ++++ .../stock-movement-product-lookup.test.ts | 75 +++++ .../create/stock-movement-product-lookup.ts | 37 +++ lib/api.ts | 6 +- 16 files changed, 1272 insertions(+), 233 deletions(-) create mode 100644 app/(pages)/stock-movements/create/stock-movement-batch-form-validation.test.ts create mode 100644 app/(pages)/stock-movements/create/stock-movement-batch-form-validation.ts create mode 100644 app/(pages)/stock-movements/create/stock-movement-draft-guards.test.ts create mode 100644 app/(pages)/stock-movements/create/stock-movement-draft-guards.ts create mode 100644 app/(pages)/stock-movements/create/stock-movement-product-lookup.test.ts create mode 100644 app/(pages)/stock-movements/create/stock-movement-product-lookup.ts diff --git a/app/(pages)/products/components/product-form.types.ts b/app/(pages)/products/components/product-form.types.ts index be9fe90..a4a875c 100644 --- a/app/(pages)/products/components/product-form.types.ts +++ b/app/(pages)/products/components/product-form.types.ts @@ -129,7 +129,7 @@ export interface ProductFormProps { // Existing product modal (new-product page) scannedExistingProduct: ExistingProductInfo | null; onExistingProductModalOpenChange?: (open: boolean) => void; - onCreateBatchForExistingProduct?: () => void; + onCreateBatchForExistingProduct?: () => void | Promise; // Batch overlay (new-product page) batchForm?: ExistingProductBatchFormState; 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 c188899..3f54b10 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 @@ -3,8 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useCreateStockMovementModel } from "./create-stock-movement.model"; import { buildMovementPayload, - filterStockMovementProductOptions, + buildStockMovementProductSearchUrl, formatStockMovementProductLabel, + mapStockMovementProductOptions, shouldShowStockMovementFooter, } from "./create-stock-movement.model"; import type { CreateStockMovementSchema } from "./create-stock-movement.schema"; @@ -134,14 +135,20 @@ const fakeStorage = vi.hoisted(() => { public readonly readStockMovementDraft = vi.fn<() => Promise>(async () => { return this.draftState; }); - public readonly writeStockMovementDraft = vi.fn<(draft: WritableStockMovementDraft) => Promise>( - async (draft) => { + public readonly writeStockMovementDraft = vi.fn( + async (draft: WritableStockMovementDraft, expectedRevision?: number) => { + const storedRevision = this.draftState?.revision ?? 0; + if (expectedRevision !== undefined && storedRevision !== expectedRevision) { + return { status: "conflict" as const, revision: storedRevision }; + } this.draftState = { ...draft, - schemaVersion: 1, + schemaVersion: 2, updatedAt: "2026-01-20T10:00:00.000Z", + revision: storedRevision + 1, runtimeId: this.currentRuntimeId, }; + return { status: "written" as const, revision: storedRevision + 1 }; }, ); public readonly clearStockMovementDraft = vi.fn(async () => { @@ -210,14 +217,18 @@ vi.mock("@/hooks/use-selected-warehouse", () => ({ vi.mock("@/lib/api", () => ({ api: fakeApi, + isApiNotFoundError: (error: unknown) => + Boolean((error as { notFound?: boolean })?.notFound), })); vi.mock("./create-stock-movement.storage", () => ({ readStockMovementDraft: () => fakeStorage.readStockMovementDraft(), isStockMovementDraftRecoveredFromPreviousRuntime: (draft: StockMovementDraft) => fakeStorage.isStockMovementDraftRecoveredFromPreviousRuntime(draft), - writeStockMovementDraft: (draft: WritableStockMovementDraft) => - fakeStorage.writeStockMovementDraft(draft), + writeStockMovementDraft: ( + draft: WritableStockMovementDraft, + expectedRevision?: number, + ) => fakeStorage.writeStockMovementDraft(draft, expectedRevision), clearStockMovementDraft: () => fakeStorage.clearStockMovementDraft(), inlineProductImageToFile: (image: { name: string; @@ -308,9 +319,11 @@ const barcodeIndex: Record = { const createDraftState = ( overrides?: Partial, ): StockMovementDraft => ({ - schemaVersion: 1, + schemaVersion: 2, updatedAt: "2026-01-20T09:00:00.000Z", + revision: 1, type: "PURCHASE_IN", + warehouseId: "wh-1", notes: "Notas iniciais", items: [ { @@ -406,7 +419,12 @@ beforeEach(() => { if (url.startsWith("products/barcode/")) { const barcode = decodeURIComponent(url.replace("products/barcode/", "")); const product = barcodeIndex[barcode]; - if (!product) throw new Error(`Produto com código ${barcode} não existe.`); + if (!product) { + throw Object.assign( + new Error(`Produto com código ${barcode} não existe.`), + { notFound: true }, + ); + } return createJsonResponse({ success: true, data: product, @@ -440,23 +458,28 @@ afterEach(() => { describe("helpers de produto", () => { const products: StockMovementProductOption[] = movementProducts; - it("exige pelo menos dois caracteres para filtrar", () => { - expect(filterStockMovementProductOptions(products, " c ")).toEqual([]); + it("não monta URL de busca com menos de dois caracteres", () => { + expect(buildStockMovementProductSearchUrl(" c ")).toBeNull(); + expect(buildStockMovementProductSearchUrl("")).toBeNull(); }); - it("busca por nome, sku e código de barras", () => { - expect(filterStockMovementProductOptions(products, "fil")[0].id).toBe("p-2"); - expect(filterStockMovementProductOptions(products, "ACU")[0].id).toBe("p-3"); - expect(filterStockMovementProductOptions(products, "0001")[0].id).toBe("p-1"); + it("monta URL de busca com query escapada", () => { + expect(buildStockMovementProductSearchUrl(" café cristal ")).toBe( + "products/search?q=caf%C3%A9%20cristal", + ); }); - it("limita os resultados da busca para cinco itens", () => { - const manyProducts = Array.from({ length: 6 }, (_, index) => ({ - id: `extra-${index}`, - name: `Produto ${index}`, - })); - - expect(filterStockMovementProductOptions(manyProducts, "produto")).toHaveLength(5); + it("mapeia resposta de produtos em lista ou paginada", () => { + expect( + mapStockMovementProductOptions({ success: true, data: products }), + ).toHaveLength(4); + expect( + mapStockMovementProductOptions({ + success: true, + data: { content: products.slice(0, 2) }, + }), + ).toHaveLength(2); + expect(mapStockMovementProductOptions(null)).toEqual([]); }); it("mostra SKU quando disponível", () => { @@ -669,6 +692,7 @@ describe("useCreateStockMovementModel", () => { expect.objectContaining({ notes: "Nova observação", }), + expect.any(Number), ); }); @@ -686,6 +710,7 @@ describe("useCreateStockMovementModel", () => { await waitFor(() => { expect(fakeStorage.writeStockMovementDraft).toHaveBeenCalledWith( expect.objectContaining({ + warehouseId: "wh-1", items: [ { productId: validExistingProductUuid, @@ -694,12 +719,19 @@ describe("useCreateStockMovementModel", () => { }, ], }), + expect.any(Number), ); }); }); - it("aplica debounce de busca de produto", () => { + it("aplica debounce e busca produtos na API de pesquisa", () => { vi.useFakeTimers(); + fakeSWR.setState("products/search?q=filtro", { + data: { success: true, data: [movementProducts[1]] }, + error: null, + isLoading: false, + mutate: vi.fn(), + }); const { result } = renderHook(() => useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") }), ); @@ -714,7 +746,12 @@ describe("useCreateStockMovementModel", () => { act(() => { vi.advanceTimersByTime(350); }); + expect(fakeSWR.hook).toHaveBeenCalledWith( + "products/search?q=filtro", + expect.any(Function), + ); expect(result.current.productOptions).toHaveLength(1); + expect(result.current.productOptions[0].id).toBe("p-2"); }); it("cancela debounce de busca ao desmontar", () => { @@ -1123,8 +1160,6 @@ describe("useCreateStockMovementModel", () => { }); it("abre modal de produto não encontrado quando produto não existe", async () => { - fakeApi.get.mockRejectedValue(new Error("não encontrado")); - const { result } = renderHook(() => useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") })); await act(async () => { @@ -1138,7 +1173,6 @@ describe("useCreateStockMovementModel", () => { it("mostra aviso sem ação para tipo de saída", async () => { fakeSearchParams.setType("USAGE"); const { result } = renderHook(() => useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") })); - fakeApi.get.mockRejectedValue(new Error("não encontrado")); await act(async () => { await result.current.onBarcodeScan("7891009999999"); @@ -1151,6 +1185,131 @@ describe("useCreateStockMovementModel", () => { expect(lastCall?.[1]).toBeUndefined(); }); + it("não trata falha de rede do scanner como produto inexistente", async () => { + fakeApi.get.mockImplementation(() => { + throw new Error("timeout"); + }); + const { result } = renderHook(() => + useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") }), + ); + + await act(async () => { + await result.current.onBarcodeScan("7891009999999"); + }); + + expect(result.current.missingProductBarcode).toBeNull(); + expect(fakeToast.error).toHaveBeenCalledWith( + "Não foi possível consultar o código 7891009999999 (timeout). Verifique a conexão e tente novamente.", + ); + }); + + it("bloqueia produto existente quando há produto novo pendente com o mesmo barcode", async () => { + fakeSearchParams.setType("USAGE"); + const { result } = renderHook(() => + useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") }), + ); + + act(() => { + result.current.form.setValue("items", [ + { + quantity: 1, + productName: "Produto Pendente", + newProductData: { + name: "Produto Pendente", + barcode: "7891000000004", + active: true, + isKit: false, + hasExpiration: false, + }, + }, + ]); + }); + + await act(async () => { + await result.current.onBarcodeScan("7891000000004"); + }); + + expect(fakeToast.error).toHaveBeenCalledWith( + 'O código 7891000000004 já pertence ao produto novo "Produto Pendente" nesta movimentação. Remova-o antes de adicionar o produto existente.', + ); + expect(result.current.form.getValues("items")).toHaveLength(1); + + act(() => { + result.current.onProductSelect(movementProducts[3]); + }); + + expect(result.current.selectedProductId).toBe(""); + expect(result.current.addItemError).toBe( + 'O código 7891000000004 já pertence ao produto novo "Produto Pendente" nesta movimentação. Remova-o antes de adicionar o produto existente.', + ); + }); + + it("avisa ao abrir dados de lote de produto que já está na movimentação", () => { + const { result } = renderHook(() => + useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") }), + ); + + act(() => { + result.current.form.setValue("items", [ + { + productId: "p-1", + productName: "Café Torrado", + quantity: 2, + }, + ]); + }); + act(() => { + result.current.onProductSelect(movementProducts[0]); + }); + + expect(result.current.existingProductBatchForm.isOpen).toBe(true); + expect(result.current.existingProductBatchForm.repeatedProductWarning).toBe( + "Café Torrado já está na movimentação. Este lote será adicionado como um novo item.", + ); + }); + + it("não restaura rascunho de outro warehouse", async () => { + fakeStorage.setDraftState( + createDraftState({ warehouseId: "wh-2", selectedProductId: "p-2" }), + ); + + const { result } = renderHook(() => + useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") }), + ); + + await waitFor(() => { + expect(fakeStorage.readStockMovementDraft).toHaveBeenCalled(); + }); + + expect(result.current.form.getValues("notes")).toBe(""); + expect(result.current.selectedProductId).toBe(""); + }); + + it("recusa lote com validade anterior à fabricação", () => { + const { result } = renderHook(() => + useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") }), + ); + + act(() => { + result.current.onProductSelect(movementProducts[0]); + }); + act(() => { + result.current.onExistingProductBatchQuantityChange("2"); + result.current.onExistingProductBatchCostPriceChange(1290); + result.current.onExistingProductBatchSellingPriceChange(2490); + result.current.onExistingProductBatchManufacturedDateChange("2026-06-01"); + result.current.onExistingProductBatchExpirationDateChange("2026-01-01"); + }); + act(() => { + result.current.onConfirmExistingProductBatchData(); + }); + + expect(result.current.existingProductBatchForm.error).toBe( + "A data de validade não pode ser anterior à data de fabricação.", + ); + expect(result.current.items).toHaveLength(0); + }); + it("abre editor de produto novo quando o item do índice é válido", async () => { const { result } = renderHook(() => useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") })); 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 c85ff15..c9de2af 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.model.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.model.ts @@ -37,27 +37,22 @@ import { buildExistingProductCostPriceSuggestion, findMostRecentWarehouseProductBatch, } from "./stock-movement-batch-pricing.model"; - -const getOptionalText = (value: string | undefined): string | undefined => { - const trimmedValue = value?.trim(); - return trimmedValue || undefined; -}; +import { + getOptionalText, + validateExistingProductBatchForm, +} from "./stock-movement-batch-form-validation"; +import { + buildRepeatedProductBatchWarning, + getPendingInlineProductBarcodeConflictError, + hasExistingProductInItems, +} from "./stock-movement-draft-guards"; +import { lookupStockMovementProductByBarcode } from "./stock-movement-product-lookup"; const resolveExistingProductBatchQuantity = (value: string): number => { const quantity = Number(value); return Number.isFinite(quantity) && quantity > 0 ? quantity : 0; }; -const isExistingProductBatchDateRangeInvalid = ( - manufacturedDate: string, - expirationDate: string, -): boolean => { - const optionalManufacturedDate = getOptionalText(manufacturedDate); - const optionalExpirationDate = getOptionalText(expirationDate); - if (!optionalManufacturedDate || !optionalExpirationDate) return false; - return new Date(optionalExpirationDate) < new Date(optionalManufacturedDate); -}; - const buildExistingProductItemPayload = ( item: CreateStockMovementSchema["items"][number], ): ExistingProductMovementPayload => { @@ -201,11 +196,6 @@ interface ProductListResponse { | StockMovementProductOption[]; } -interface ProductByBarcodeResponse { - success: boolean; - data: StockMovementProductOption; -} - interface CreateStockMovementModelParams { typeParam?: string | null; } @@ -244,25 +234,28 @@ export const formatStockMovementProductLabel = ( product: StockMovementProductOption, ): string => (product.sku ? `${product.name} (${product.sku})` : product.name); -export const filterStockMovementProductOptions = ( - products: StockMovementProductOption[], - query: string, +export const mapStockMovementProductOptions = ( + response: ProductListResponse | null | undefined, ): StockMovementProductOption[] => { - const normalizedQuery = query.trim().toLowerCase(); - if (normalizedQuery.length < 2) return []; - - return products - .filter((product) => { - const searchText = [ - product.name, - product.sku || "", - product.barcode || "", - ] - .join(" ") - .toLowerCase(); - return searchText.includes(normalizedQuery); - }) - .slice(0, PRODUCT_SEARCH_LIMIT); + const rawProducts = response?.data; + const productList = Array.isArray(rawProducts) + ? rawProducts + : rawProducts?.content || []; + return productList.map((product) => ({ + id: product.id, + name: product.name, + sku: product.sku, + barcode: product.barcode, + imageUrl: product.imageUrl, + })); +}; + +export const buildStockMovementProductSearchUrl = ( + query: string, +): string | null => { + const normalizedQuery = query.trim(); + if (normalizedQuery.length < 2) return null; + return `products/search?q=${encodeURIComponent(normalizedQuery)}`; }; export function useCreateStockMovementModel({ @@ -291,6 +284,8 @@ export function useCreateStockMovementModel({ const productSearchBlurTimeoutRef = useRef | null>(null); const inlineProductBarcodeRef = useRef(undefined); + const draftRevisionRef = useRef(0); + const persistQueueRef = useRef>(Promise.resolve()); const selectedMovementType = isManualMovementType(typeParam) ? typeParam : undefined; @@ -335,7 +330,8 @@ export function useCreateStockMovementModel({ const draft = await readStockMovementDraft(); if (!isMounted) return; - if (draft?.type === selectedMovementType) { + draftRevisionRef.current = draft?.revision ?? 0; + if (draft?.type === selectedMovementType && draft.warehouseId === warehouseId) { form.reset({ type: draft.type, notes: draft.notes, @@ -356,7 +352,7 @@ export function useCreateStockMovementModel({ return () => { isMounted = false; }; - }, [form, selectedMovementType]); + }, [form, selectedMovementType, warehouseId]); const { data: productsData, isLoading: isLoadingProducts } = useSWR("products", (url: string) => @@ -393,16 +389,15 @@ export function useCreateStockMovementModel({ !existingProductCostPriceSuggestion, ); - const rawProducts = productsData?.data; - const products = ( - Array.isArray(rawProducts) ? rawProducts : rawProducts?.content || [] - ).map((p) => ({ - id: p.id, - name: p.name, - sku: p.sku, - barcode: p.barcode, - imageUrl: p.imageUrl, - })); + const products = mapStockMovementProductOptions(productsData); + + const productSearchUrl = selectedProduct + ? null + : buildStockMovementProductSearchUrl(debouncedProductSearchQuery); + const { data: productSearchData, isLoading: isSearchingProducts } = + useSWR(productSearchUrl, (url: string) => + api.get(url).json(), + ); useEffect(() => { if (!selectedProductId || productSearchQuery || products.length === 0) return; @@ -419,6 +414,7 @@ export function useCreateStockMovementModel({ if (!selectedMovementType) return null; return { type: selectedMovementType, + warehouseId, notes: form.getValues("notes") || "", items: form.getValues("items"), selectedProductId, @@ -426,14 +422,23 @@ export function useCreateStockMovementModel({ inlineProductBarcode, }; }, - [form, itemQuantity, selectedMovementType, selectedProductId], + [form, itemQuantity, selectedMovementType, selectedProductId, warehouseId], ); const persistCurrentDraft = useCallback( - async (inlineProductBarcode = inlineProductBarcodeRef.current): Promise => { + (inlineProductBarcode = inlineProductBarcodeRef.current): Promise => { const draft = buildCurrentDraft(inlineProductBarcode); - if (!draft) return; - await writeStockMovementDraft(draft); + if (!draft) return Promise.resolve(); + + const queuedWrite = persistQueueRef.current.then(async () => { + const writeResult = await writeStockMovementDraft( + draft, + draftRevisionRef.current, + ); + draftRevisionRef.current = writeResult.revision; + }); + persistQueueRef.current = queuedWrite.catch(() => undefined); + return queuedWrite; }, [buildCurrentDraft], ); @@ -516,10 +521,12 @@ export function useCreateStockMovementModel({ return () => window.removeEventListener("scroll", handleScroll); }, []); - const productOptions = filterStockMovementProductOptions( - products, - debouncedProductSearchQuery, - ); + const productOptions = productSearchUrl + ? mapStockMovementProductOptions(productSearchData).slice( + 0, + PRODUCT_SEARCH_LIMIT, + ) + : []; const clearProductSearchBlurTimeout = () => { if (!productSearchBlurTimeoutRef.current) return; @@ -527,7 +534,23 @@ export function useCreateStockMovementModel({ productSearchBlurTimeoutRef.current = null; }; + const findScannedProductBarcodeConflict = ( + barcode: string | null | undefined, + ): string | null => { + return getPendingInlineProductBarcodeConflictError( + form.getValues("items"), + barcode, + ); + }; + const handleProductSelect = (product: StockMovementProductOption) => { + const barcodeConflictError = findScannedProductBarcodeConflict(product.barcode); + if (barcodeConflictError) { + setIsProductOptionsOpen(false); + setAddItemError(barcodeConflictError); + return; + } + setSelectedProduct(product); setSelectedProductId(product.id); setProductSearchQuery(formatStockMovementProductLabel(product)); @@ -597,6 +620,16 @@ export function useCreateStockMovementModel({ setExistingProductBatchForm(EMPTY_EXISTING_BATCH_FORM); }; + const buildBatchRepeatedProductWarning = ( + params: Pick, + ): string | null => { + if (params.editingIndex !== null) return null; + if (!hasExistingProductInItems(form.getValues("items"), params.productId)) { + return null; + } + return buildRepeatedProductBatchWarning(params.productName); + }; + const openExistingProductBatchForm = ( params: Omit, ): void => { @@ -604,6 +637,7 @@ export function useCreateStockMovementModel({ ...params, isOpen: true, error: null, + repeatedProductWarning: buildBatchRepeatedProductWarning(params), }); }; @@ -617,7 +651,7 @@ export function useCreateStockMovementModel({ }; const hasExistingProductAlreadyAdded = (productId: string): boolean => { - return form.getValues("items").some((item) => item.productId === productId); + return hasExistingProductInItems(form.getValues("items"), productId); }; const getSelectedProductQuantity = (): number | null => { @@ -636,6 +670,12 @@ export function useCreateStockMovementModel({ selectedProduct || products.find((p) => p.id === selectedProductId); const productName = product?.name || "Produto"; + const barcodeConflictError = findScannedProductBarcodeConflict(product?.barcode); + if (barcodeConflictError) { + setAddItemError(barcodeConflictError); + return; + } + if (isSelectedInMovement()) { openExistingProductBatchForm({ productId: selectedProductId, @@ -733,31 +773,11 @@ export function useCreateStockMovementModel({ }); const handleConfirmExistingProductBatchData = (): void => { - const quantity = Number(existingProductBatchForm.quantity); - if (!quantity || quantity <= 0) { - updateExistingProductBatchForm({ - error: "Informe uma quantidade válida para o lote.", - }); - return; - } - - if (existingProductBatchForm.costPrice === undefined || existingProductBatchForm.costPrice < 0) { - updateExistingProductBatchForm({ error: "Informe um preço de custo válido." }); - return; - } - - if (existingProductBatchForm.sellingPrice === undefined || existingProductBatchForm.sellingPrice < 0) { - updateExistingProductBatchForm({ error: "Informe um preço de venda válido." }); - return; - } - - if ( - isExistingProductBatchDateRangeInvalid( - existingProductBatchForm.manufacturedDate, - existingProductBatchForm.expirationDate, - ) - ) { - updateExistingProductBatchForm({ error: "A data de validade não pode ser anterior à data de fabricação." }); + const validationError = validateExistingProductBatchForm( + existingProductBatchForm, + ); + if (validationError) { + updateExistingProductBatchForm({ error: validationError }); return; } @@ -843,6 +863,12 @@ export function useCreateStockMovementModel({ }; const appendScannedProduct = (product: StockMovementProductOption) => { + const barcodeConflictError = findScannedProductBarcodeConflict(product.barcode); + if (barcodeConflictError) { + toast.error(barcodeConflictError); + return; + } + if (isSelectedInMovement()) { setIsScannerOpen(false); openExistingProductBatchForm({ @@ -878,14 +904,16 @@ export function useCreateStockMovementModel({ lastScannedBarcodeRef.current = null; }, 1500); - try { - const response = await api - .get(`products/barcode/${encodeURIComponent(barcode)}`) - .json(); - appendScannedProduct(response.data); - } catch { + const lookup = await lookupStockMovementProductByBarcode(barcode); + if (lookup.status === "found") { + appendScannedProduct(lookup.product); + return; + } + if (lookup.status === "not-found") { showMissingProductToast(barcode); + return; } + toast.error(lookup.message); }; const showMissingProductToast = (barcode: string) => { @@ -937,6 +965,7 @@ export function useCreateStockMovementModel({ setSubmittingStep("Finalizando…"); await clearStockMovementDraft(); + draftRevisionRef.current = 0; toast.success("Movimentação criada com sucesso!"); router.push("/stock-movements"); } catch (err: unknown) { @@ -960,8 +989,7 @@ export function useCreateStockMovementModel({ productSearchQuery, productOptions, isProductOptionsOpen, - isProductSearchLoading: - isLoadingProducts && debouncedProductSearchQuery.trim().length >= 2, + isProductSearchLoading: Boolean(productSearchUrl) && isSearchingProducts, itemQuantity, addItemError, isScannerOpen, diff --git a/app/(pages)/stock-movements/create/create-stock-movement.storage.test.ts b/app/(pages)/stock-movements/create/create-stock-movement.storage.test.ts index de44b7f..ee04521 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.storage.test.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.storage.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearStockMovementDraft, isStockMovementDraftRecoveredFromPreviousRuntime, + mutateStockMovementDraft, readStockMovementDraft, STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION, writeStockMovementDraft, @@ -39,35 +40,39 @@ const createMutableOpenRequest = (): MutableOpenRequest => ({ class FakeObjectStore { public constructor( private readonly values: Map, - private readonly completeTransaction: () => void, + private readonly registerRequest: () => void, + private readonly settleRequest: () => void, ) {} public get(key: IDBValidKey): IDBRequest { const request = createMutableRequest(undefined); + this.registerRequest(); queueMicrotask(() => { request.result = this.values.get(String(key)); request.onsuccess?.call(request as IDBRequest, new Event("success")); - this.completeTransaction(); + this.settleRequest(); }); return request as IDBRequest; } public put(value: unknown, key?: IDBValidKey): IDBRequest { const request = createMutableRequest(key ?? DRAFT_KEY); + this.registerRequest(); queueMicrotask(() => { this.values.set(String(key ?? DRAFT_KEY), value); request.onsuccess?.call(request as IDBRequest, new Event("success")); - this.completeTransaction(); + this.settleRequest(); }); return request as IDBRequest; } public delete(key: IDBValidKey): IDBRequest { const request = createMutableRequest(undefined); + this.registerRequest(); queueMicrotask(() => { this.values.delete(String(key)); request.onsuccess?.call(request as IDBRequest, new Event("success")); - this.completeTransaction(); + this.settleRequest(); }); return request as IDBRequest; } @@ -79,13 +84,24 @@ class FakeTransaction { null; public onabort: ((this: IDBTransaction, event: Event) => unknown) | null = null; + private pendingRequests = 0; + private hasCompleted = false; public constructor(private readonly values: Map) {} public objectStore(): IDBObjectStore { - return new FakeObjectStore(this.values, () => { - this.oncomplete?.call(this as unknown as IDBTransaction, new Event("complete")); - }) as unknown as IDBObjectStore; + return new FakeObjectStore( + this.values, + () => { + this.pendingRequests += 1; + }, + () => { + this.pendingRequests -= 1; + if (this.pendingRequests > 0 || this.hasCompleted) return; + this.hasCompleted = true; + this.oncomplete?.call(this as unknown as IDBTransaction, new Event("complete")); + }, + ) as unknown as IDBObjectStore; } } @@ -99,7 +115,7 @@ class FakeDatabase { public createObjectStore(storeName: string): IDBObjectStore { const values = new Map(); this.stores.set(storeName, values); - return new FakeObjectStore(values, () => {}) as unknown as IDBObjectStore; + return new FakeObjectStore(values, () => {}, () => {}) as unknown as IDBObjectStore; } public transaction(storeName: string): IDBTransaction { @@ -156,6 +172,7 @@ class FakeIndexedDb { const createDraft = (): WritableStockMovementDraft => ({ type: "PURCHASE_IN", + warehouseId: "wh-1", notes: "Observação", selectedProductId: "product-1", itemQuantity: "3", @@ -200,9 +217,11 @@ describe("create-stock-movement.storage", () => { expect(draft).toMatchObject({ schemaVersion: STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION, type: "PURCHASE_IN", + warehouseId: "wh-1", notes: "Observação", selectedProductId: "product-1", itemQuantity: "3", + revision: 1, }); expect(typeof draft?.updatedAt).toBe("string"); expect(typeof draft?.runtimeId).toBe("string"); @@ -211,6 +230,48 @@ describe("create-stock-movement.storage", () => { expect(await readStockMovementDraft()).toBeNull(); }); + it("incrementa a revisão a cada gravação", async () => { + const firstWrite = await writeStockMovementDraft(createDraft()); + const secondWrite = await writeStockMovementDraft(createDraft()); + + expect(firstWrite).toEqual({ status: "written", revision: 1 }); + expect(secondWrite).toEqual({ status: "written", revision: 2 }); + expect((await readStockMovementDraft())?.revision).toBe(2); + }); + + it("rejeita gravação com revisão esperada desatualizada", async () => { + await writeStockMovementDraft(createDraft()); + await writeStockMovementDraft({ ...createDraft(), notes: "Mais nova" }, 1); + + const staleWrite = await writeStockMovementDraft( + { ...createDraft(), notes: "Antiga" }, + 1, + ); + + expect(staleWrite).toEqual({ status: "conflict", revision: 2 }); + expect((await readStockMovementDraft())?.notes).toBe("Mais nova"); + }); + + it("aplica mutação atômica sobre o draft atual", async () => { + await writeStockMovementDraft(createDraft()); + + const mutatedDraft = await mutateStockMovementDraft((draft) => ({ + ...draft, + items: [...draft.items, { quantity: 1, productName: "Novo item" }], + })); + + expect(mutatedDraft?.items).toHaveLength(2); + expect(mutatedDraft?.revision).toBe(2); + expect((await readStockMovementDraft())?.items).toHaveLength(2); + }); + + it("não aplica mutação quando não há draft salvo", async () => { + const mutatedDraft = await mutateStockMovementDraft((draft) => draft); + + expect(mutatedDraft).toBeNull(); + expect(await readStockMovementDraft()).toBeNull(); + }); + it("ignora JSON inválido e remove o valor salvo", async () => { fakeIndexedDb.putStoredDraft("{invalid-json"); @@ -220,7 +281,7 @@ describe("create-stock-movement.storage", () => { it("ignora draft com schema incompatível", async () => { fakeIndexedDb.putStoredDraft({ - schemaVersion: 999, + schemaVersion: 1, type: "PURCHASE_IN", notes: "inválido", items: [], @@ -233,6 +294,21 @@ describe("create-stock-movement.storage", () => { expect(fakeIndexedDb.getStoredDraft()).toBeUndefined(); }); + it("ignora draft sem warehouseId ou sem revisão", async () => { + fakeIndexedDb.putStoredDraft({ + schemaVersion: STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION, + type: "PURCHASE_IN", + notes: "sem campos novos", + items: [], + selectedProductId: "", + itemQuantity: "", + updatedAt: "2026-01-20T10:00:00.000Z", + }); + + expect(await readStockMovementDraft()).toBeNull(); + expect(fakeIndexedDb.getStoredDraft()).toBeUndefined(); + }); + it("preserva imagem inline em dataUrl", async () => { await writeStockMovementDraft(createDraft()); @@ -249,10 +325,11 @@ describe("create-stock-movement.storage", () => { it("mantém fallback em memória quando IndexedDB falha", async () => { fakeIndexedDb.shouldFailOpen = true; - await writeStockMovementDraft(createDraft()); + const writeResult = await writeStockMovementDraft(createDraft()); const draft = await readStockMovementDraft(); + expect(writeResult).toEqual({ status: "written", revision: 1 }); expect(draft?.notes).toBe("Observação"); expect(console.error).toHaveBeenCalled(); }); @@ -278,10 +355,12 @@ describe("create-stock-movement.storage", () => { ).toBe(true); }); - it("mantém compatibilidade com rascunho legado sem runtimeId", async () => { + it("mantém compatibilidade com rascunho sem runtimeId", async () => { fakeIndexedDb.putStoredDraft({ schemaVersion: STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION, type: "PURCHASE_IN", + warehouseId: "wh-1", + revision: 1, notes: "legado", items: [], selectedProductId: "", diff --git a/app/(pages)/stock-movements/create/create-stock-movement.storage.ts b/app/(pages)/stock-movements/create/create-stock-movement.storage.ts index 1df1e78..2e6d4cd 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.storage.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.storage.ts @@ -9,7 +9,7 @@ const DRAFT_DATABASE_NAME = "stockshift"; const DRAFT_DATABASE_VERSION = 1; const DRAFT_STORE_NAME = "stockMovementDrafts"; const DRAFT_STORAGE_KEY = "current"; -export const STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION = 1; +export const STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION = 2; const createStockMovementDraftRuntimeId = (): string => { if (typeof globalThis.crypto?.randomUUID === "function") { @@ -25,7 +25,9 @@ const STOCK_MOVEMENT_DRAFT_RUNTIME_ID = createStockMovementDraftRuntimeId(); export interface StockMovementDraft { schemaVersion: typeof STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION; updatedAt: string; + revision: number; type: ManualMovementType; + warehouseId: string | null; notes: string; items: StockMovementDraftItem[]; selectedProductId: string; @@ -36,9 +38,14 @@ export interface StockMovementDraft { export type WritableStockMovementDraft = Omit< StockMovementDraft, - "schemaVersion" | "updatedAt" + "schemaVersion" | "updatedAt" | "revision" > & - Partial>; + Partial>; + +export interface StockMovementDraftWriteResult { + status: "written" | "conflict"; + revision: number; +} let fallbackDraft: StockMovementDraft | null = null; @@ -95,18 +102,27 @@ const readDraftFromIndexedDb = async (): Promise => { }); }; -const writeDraftToIndexedDb = async ( - draft: StockMovementDraft, -): Promise => { +const applyDraftTransaction = async ( + buildNextDraft: (storedDraft: StockMovementDraft | null) => StockMovementDraft | null, +): Promise => { const database = await openDraftDatabase(); return new Promise((resolve, reject) => { const transaction = database.transaction(DRAFT_STORE_NAME, "readwrite"); const store = transaction.objectStore(DRAFT_STORE_NAME); - const request = store.put(draft, DRAFT_STORAGE_KEY); - request.onerror = () => reject(createRequestError("gravar", request.error)); + const readRequest = store.get(DRAFT_STORAGE_KEY); + let nextDraft: StockMovementDraft | null = null; + readRequest.onerror = () => + reject(createRequestError("ler", readRequest.error)); + readRequest.onsuccess = () => { + nextDraft = buildNextDraft(normalizeStoredDraft(readRequest.result)); + if (!nextDraft) return; + const writeRequest = store.put(nextDraft, DRAFT_STORAGE_KEY); + writeRequest.onerror = () => + reject(createRequestError("gravar", writeRequest.error)); + }; transaction.oncomplete = () => { database.close(); - resolve(); + resolve(nextDraft); }; transaction.onabort = () => { database.close(); @@ -221,15 +237,23 @@ const normalizeStoredDraft = ( if (typeof parsedDraft.itemQuantity !== "string") return null; if (!isOptionalString(parsedDraft.inlineProductBarcode)) return null; if (!isOptionalString(parsedDraft.runtimeId)) return null; + if (typeof parsedDraft.revision !== "number" || !Number.isFinite(parsedDraft.revision)) { + return null; + } + if (parsedDraft.warehouseId !== null && typeof parsedDraft.warehouseId !== "string") { + return null; + } return parsedDraft as unknown as StockMovementDraft; }; const buildPersistedDraft = ( draft: WritableStockMovementDraft, + revision: number, ): StockMovementDraft => ({ ...draft, schemaVersion: STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION, updatedAt: new Date().toISOString(), + revision, runtimeId: STOCK_MOVEMENT_DRAFT_RUNTIME_ID, }); @@ -256,15 +280,67 @@ export const readStockMovementDraft = } }; +const writeDraftToMemoryFallback = ( + draft: WritableStockMovementDraft, + expectedRevision?: number, +): StockMovementDraftWriteResult => { + const storedRevision = fallbackDraft?.revision ?? 0; + if (expectedRevision !== undefined && storedRevision !== expectedRevision) { + return { status: "conflict", revision: storedRevision }; + } + fallbackDraft = buildPersistedDraft(draft, storedRevision + 1); + return { status: "written", revision: fallbackDraft.revision }; +}; + export const writeStockMovementDraft = async ( draft: WritableStockMovementDraft, -): Promise => { - const persistedDraft = buildPersistedDraft(draft); - fallbackDraft = persistedDraft; + expectedRevision?: number, +): Promise => { try { - await writeDraftToIndexedDb(persistedDraft); + let writeResult: StockMovementDraftWriteResult = { + status: "conflict", + revision: expectedRevision ?? 0, + }; + await applyDraftTransaction((storedDraft) => { + const storedRevision = storedDraft?.revision ?? 0; + if (expectedRevision !== undefined && storedRevision !== expectedRevision) { + writeResult = { status: "conflict", revision: storedRevision }; + return null; + } + const persistedDraft = buildPersistedDraft(draft, storedRevision + 1); + fallbackDraft = persistedDraft; + writeResult = { status: "written", revision: persistedDraft.revision }; + return persistedDraft; + }); + return writeResult; } catch (error) { logDraftStorageError("gravar", error); + return writeDraftToMemoryFallback(draft, expectedRevision); + } +}; + +export const mutateStockMovementDraft = async ( + buildNextDraft: (draft: StockMovementDraft) => WritableStockMovementDraft, +): Promise => { + try { + return await applyDraftTransaction((storedDraft) => { + const baseDraft = storedDraft ?? fallbackDraft; + if (!baseDraft) return null; + const persistedDraft = buildPersistedDraft( + buildNextDraft(baseDraft), + baseDraft.revision + 1, + ); + fallbackDraft = persistedDraft; + return persistedDraft; + }); + } catch (error) { + logDraftStorageError("gravar", error); + if (!fallbackDraft) return null; + fallbackDraft = buildPersistedDraft( + buildNextDraft(fallbackDraft), + fallbackDraft.revision + 1, + ); + return fallbackDraft; } }; diff --git a/app/(pages)/stock-movements/create/create-stock-movement.types.ts b/app/(pages)/stock-movements/create/create-stock-movement.types.ts index cf070c4..f32ed17 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.types.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.types.ts @@ -46,6 +46,7 @@ export interface ExistingProductBatchFormState { sellingPrice?: number; editingIndex: number | null; error: string | null; + repeatedProductWarning?: string | null; } export interface StockMovementProductBatchPriceSource { 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 654d0a8..e045acd 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 @@ -34,9 +34,11 @@ const brandsResponse = { }; const createDraft = (overrides?: Partial): StockMovementDraft => ({ - schemaVersion: 1, + schemaVersion: 2, updatedAt: "2026-01-20T09:00:00.000Z", + revision: 1, type: "PURCHASE_IN", + warehouseId: "wh-1", notes: "", items: [], selectedProductId: "", @@ -45,6 +47,9 @@ const createDraft = (overrides?: Partial): StockMovementDraf ...overrides, }); +const notFoundApiError = (): Error => + Object.assign(new Error("não encontrado"), { notFound: true }); + const buildFormData = ( overrides: Partial = {}, ): ProductCreateFormData => ({ @@ -80,6 +85,8 @@ vi.mock("@/lib/api", () => ({ api: { get: (...args: unknown[]) => mockGet(...args), }, + isApiNotFoundError: (error: unknown) => + Boolean((error as { notFound?: boolean })?.notFound), })); vi.mock("next/navigation", () => ({ @@ -121,9 +128,18 @@ vi.mock("../create-stock-movement.storage", () => ({ dataUrl: string; }) => new File(["x"], image.name, { type: image.type }), readStockMovementDraft: async () => currentDraft, - writeStockMovementDraft: async (draft: StockMovementDraft) => { - currentDraft = draft; - mockWriteDraft(draft); + mutateStockMovementDraft: async ( + buildNextDraft: (draft: StockMovementDraft) => StockMovementDraft, + ) => { + if (!currentDraft) return null; + const nextRevision = currentDraft.revision + 1; + currentDraft = { + ...currentDraft, + ...buildNextDraft(currentDraft), + revision: nextRevision, + }; + mockWriteDraft(currentDraft); + return currentDraft; }, })); @@ -508,11 +524,15 @@ describe("useNewProductInlineModel", () => { it("alterna estado do scanner e preenche barcode quando produto não existe na API", async () => { mockGet.mockReset(); mockGet.mockImplementation(() => ({ - json: vi.fn(async () => { throw new Error("não encontrado"); }), + json: vi.fn(async () => { throw notFoundApiError(); }), })); const { result } = renderHook(() => useNewProductInlineModel({ movementType, editItem: editItemQuery })); + await waitFor(() => { + expect(result.current.form.getValues("barcode")).toBe("7891000000001"); + }); + expect(result.current.isScannerOpen).toBe(false); act(() => { result.current.openScanner(); @@ -525,6 +545,8 @@ describe("useNewProductInlineModel", () => { expect(mockGet).toHaveBeenCalledWith("products/barcode/123456789"); expect(result.current.scannedExistingProduct).toBeNull(); + expect(result.current.form.getValues("barcode")).toBe("123456789"); + expect(toastError).not.toHaveBeenCalled(); act(() => { result.current.closeScanner(); @@ -532,6 +554,27 @@ describe("useNewProductInlineModel", () => { expect(result.current.isScannerOpen).toBe(false); }); + it("mostra erro e não preenche barcode quando a consulta do scanner falha", async () => { + mockGet.mockReset(); + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => { throw new Error("timeout"); }), + })); + + const { result } = renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await act(async () => { + await result.current.handleBarcodeScan("123456789"); + }); + + expect(toastError).toHaveBeenCalledWith( + "Não foi possível consultar o código 123456789 (timeout). Verifique a conexão e tente novamente.", + ); + expect(result.current.form.getValues("barcode")).not.toBe("123456789"); + expect(result.current.scannedExistingProduct).toBeNull(); + }); + it("abre modal de produto existente quando barcode escaneado encontra produto", async () => { mockGet.mockImplementation(() => ({ json: vi.fn(async () => ({ @@ -568,14 +611,15 @@ describe("useNewProductInlineModel", () => { await result.current.handleBarcodeScan("987654321"); }); - act(() => { - result.current.onCreateBatchForExistingProduct?.(); + await act(async () => { + await result.current.onCreateBatchForExistingProduct?.(); }); expect(result.current.scannedExistingProduct).toBeNull(); expect(result.current.batchForm?.isOpen).toBe(true); expect(result.current.batchForm?.productId).toBe("p-123"); expect(result.current.batchForm?.productName).toBe("Produto Existente"); + expect(result.current.batchForm?.repeatedProductWarning).toBeNull(); act(() => { result.current.onBatchQuantityChange?.("5"); @@ -593,6 +637,226 @@ describe("useNewProductInlineModel", () => { expect(mockWriteDraft).toHaveBeenCalled(); }); + it("redireciona quando o draft é de outro tipo de movimentação", async () => { + currentDraft = createDraft({ type: "ADJUSTMENT_IN" }); + movementType = "PURCHASE_IN"; + + renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith("/stock-movements"); + }); + }); + + it("redireciona quando o draft pertence a outro warehouse", async () => { + currentDraft = createDraft({ warehouseId: "wh-2" }); + + renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith("/stock-movements"); + }); + }); + + it("abre modal de produto existente quando barcode digitado já está cadastrado", async () => { + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => ({ + success: true, + data: { id: "p-9", name: "Produto Cadastrado", barcode: "789000111" }, + })), + })); + + const { result } = renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await act(async () => { + await result.current.onSubmit( + buildFormData({ name: "Produto Manual", barcode: "789000111" }), + ); + }); + + expect(result.current.scannedExistingProduct).toEqual({ + id: "p-9", + name: "Produto Cadastrado", + barcode: "789000111", + }); + expect(mockWriteDraft).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(false); + }); + + it("salva produto quando barcode digitado não existe na API", async () => { + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => { throw notFoundApiError(); }), + })); + + const { result } = renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await act(async () => { + await result.current.onSubmit( + buildFormData({ name: "Produto Manual", barcode: "789000111" }), + ); + }); + + expect(mockWriteDraft).toHaveBeenCalledTimes(1); + expect(result.current.scannedExistingProduct).toBeNull(); + }); + + it("bloqueia envio quando a consulta do barcode digitado falha", async () => { + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => { throw new Error("erro 500"); }), + })); + + const { result } = renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await act(async () => { + await result.current.onSubmit( + buildFormData({ name: "Produto Manual", barcode: "789000111" }), + ); + }); + + expect(toastError).toHaveBeenCalledWith( + "Não foi possível consultar o código 789000111 (erro 500). Verifique a conexão e tente novamente.", + ); + expect(mockWriteDraft).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(false); + }); + + it("recusa produto novo com barcode já usado por outro produto novo do draft", async () => { + currentDraft = createDraft({ + items: [ + { + quantity: 1, + productName: "Produto A", + newProductData: { name: "Produto A", barcode: "789000111" }, + }, + ], + }); + + const { result } = renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await act(async () => { + await result.current.onSubmit( + buildFormData({ name: "Produto B", barcode: "789000111" }), + ); + }); + + expect(toastError).toHaveBeenCalledWith( + 'O código 789000111 já está em uso pelo produto "Produto A" nesta movimentação.', + ); + expect(mockWriteDraft).not.toHaveBeenCalled(); + }); + + it("bloqueia lote de produto existente quando há produto novo pendente com o mesmo barcode", async () => { + currentDraft = createDraft({ + items: [ + { + quantity: 1, + productName: "Produto Pendente", + newProductData: { name: "Produto Pendente", barcode: "987654321" }, + }, + ], + }); + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => ({ + success: true, + data: { id: "p-123", name: "Produto Existente", barcode: "987654321" }, + })), + })); + + const { result } = renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await act(async () => { + await result.current.handleBarcodeScan("987654321"); + }); + await act(async () => { + await result.current.onCreateBatchForExistingProduct?.(); + }); + + expect(toastError).toHaveBeenCalledWith( + 'O código 987654321 já pertence ao produto novo "Produto Pendente" nesta movimentação. Remova-o antes de adicionar o produto existente.', + ); + expect(result.current.batchForm?.isOpen).toBe(false); + expect(result.current.scannedExistingProduct).toBeNull(); + }); + + it("avisa quando o produto existente já está na movimentação ao abrir o lote", async () => { + currentDraft = createDraft({ + items: [{ productId: "p-123", quantity: 2, productName: "Produto Existente" }], + }); + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => ({ + success: true, + data: { id: "p-123", name: "Produto Existente", barcode: "987654321" }, + })), + })); + + const { result } = renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await act(async () => { + await result.current.handleBarcodeScan("987654321"); + }); + await act(async () => { + await result.current.onCreateBatchForExistingProduct?.(); + }); + + expect(result.current.batchForm?.isOpen).toBe(true); + expect(result.current.batchForm?.repeatedProductWarning).toBe( + "Produto Existente já está na movimentação. Este lote será adicionado como um novo item.", + ); + }); + + it("recusa lote de produto existente com validade anterior à fabricação", async () => { + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => ({ + success: true, + data: { id: "p-123", name: "Produto Existente", barcode: "987654321" }, + })), + })); + + const { result } = renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await act(async () => { + await result.current.handleBarcodeScan("987654321"); + }); + await act(async () => { + await result.current.onCreateBatchForExistingProduct?.(); + }); + + act(() => { + result.current.onBatchQuantityChange?.("5"); + result.current.onBatchCostPriceChange?.(1000); + result.current.onBatchSellingPriceChange?.(1500); + result.current.onBatchManufacturedDateChange?.("2026-06-01"); + result.current.onBatchExpirationDateChange?.("2026-01-01"); + }); + + await act(async () => { + await result.current.onConfirmBatch?.(); + }); + + expect(result.current.batchForm?.error).toBe( + "A data de validade não pode ser anterior à data de fabricação.", + ); + expect(mockWriteDraft).not.toHaveBeenCalled(); + }); + it("controla quantidade com incremento e decremento sem ficar negativa", () => { const { result } = renderHook(() => useNewProductInlineModel({ movementType, editItem: editItemQuery })); 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 0bca05e..e5e3387 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 @@ -25,14 +25,13 @@ import type { StockMovementDraftItem, ExistingProductBatchFormState, StockMovementProductBatchesResponse, - StockMovementProductOption, } from "../create-stock-movement.types"; import { isManualMovementType } from "../../stock-movements.constants"; import { fileToInlineProductImage, inlineProductImageToFile, + mutateStockMovementDraft, readStockMovementDraft, - writeStockMovementDraft, } from "../create-stock-movement.storage"; import type { StockMovementDraft } from "../create-stock-movement.storage"; import { @@ -42,6 +41,14 @@ import { buildExistingProductCostPriceSuggestion, findMostRecentWarehouseProductBatch, } from "../stock-movement-batch-pricing.model"; +import { validateExistingProductBatchForm } from "../stock-movement-batch-form-validation"; +import { + buildRepeatedProductBatchWarning, + findDuplicateInlineProductError, + getPendingInlineProductBarcodeConflictError, + hasExistingProductInItems, +} from "../stock-movement-draft-guards"; +import { lookupStockMovementProductByBarcode } from "../stock-movement-product-lookup"; const buildReturnHref = (type: string | null): string => { if (!isManualMovementType(type)) return "/stock-movements/create"; @@ -97,19 +104,6 @@ const buildInlineProductData = async ( image: image ? await fileToInlineProductImage(image) : undefined, }); -const hasDuplicateInlineProductName = ( - productName: string, - draft: StockMovementDraft | null, - ignoredIndex: number | null, -): boolean => { - const normalizedName = productName.toLowerCase(); - const duplicateInDraft = draft?.items.some((item, index) => { - if (index === ignoredIndex) return false; - return item.newProductData?.name.toLowerCase() === normalizedName; - }); - return Boolean(duplicateInDraft); -}; - const buildInlineMovementItem = ( product: InlineProductData, quantity: number, @@ -147,20 +141,21 @@ const EMPTY_BATCH_FORM: ExistingProductBatchFormState = { const isInlineProductRouteReady = ( movementType: string | null, initialDraft: StockMovementDraft | null, + warehouseId: string | null, editItemIndex: number | null, isEditingInlineProduct: boolean, ): boolean => { - const hasValidEditItem = editItemIndex === null || isEditingInlineProduct; - return Boolean( - isManualMovementType(movementType) && initialDraft && hasValidEditItem, - ); + if (!isManualMovementType(movementType)) return false; + if (!initialDraft || initialDraft.type !== movementType) return false; + if (initialDraft.warehouseId !== warehouseId) return false; + return editItemIndex === null || isEditingInlineProduct; }; -const appendProductToMovementDraft = ( +const appendProductToMovementDraft = async ( product: InlineProductData, quantity: number, ): Promise => { - return updateMovementDraft((draft) => ({ + await mutateStockMovementDraft((draft) => ({ ...draft, items: [...draft.items, buildInlineMovementItem(product, quantity)], selectedProductId: "", @@ -169,10 +164,10 @@ const appendProductToMovementDraft = ( })); }; -const appendExistingProductBatchToDraft = ( +const appendExistingProductBatchToDraft = async ( item: StockMovementDraftItem, ): Promise => { - return updateMovementDraft((draft) => ({ + await mutateStockMovementDraft((draft) => ({ ...draft, items: [...draft.items, item], selectedProductId: "", @@ -181,20 +176,12 @@ const appendExistingProductBatchToDraft = ( })); }; -const updateMovementDraft = async ( - buildNextDraft: (draft: StockMovementDraft) => StockMovementDraft, -): Promise => { - const draft = await readStockMovementDraft(); - if (!draft) return; - await writeStockMovementDraft(buildNextDraft(draft)); -}; - -const updateProductInMovementDraft = ( +const updateProductInMovementDraft = async ( index: number, product: InlineProductData, quantity: number, ): Promise => { - return updateMovementDraft((draft) => ({ + await mutateStockMovementDraft((draft) => ({ ...draft, items: draft.items.map((item, itemIndex) => { return itemIndex === index @@ -322,6 +309,7 @@ export const useNewProductInlineModel = ({ isInlineProductRouteReady( movementType, initialDraft, + warehouseId, editItemIndex, isEditingInlineProduct, ) @@ -336,6 +324,7 @@ export const useNewProductInlineModel = ({ isEditingInlineProduct, movementType, router, + warehouseId, ]); useEffect(() => { @@ -344,6 +333,7 @@ export const useNewProductInlineModel = ({ !isInlineProductRouteReady( movementType, initialDraft, + warehouseId, editItemIndex, isEditingInlineProduct, ) @@ -382,6 +372,7 @@ export const useNewProductInlineModel = ({ isDraftLoaded, isEditingInlineProduct, movementType, + warehouseId, ]); const addCustomAttribute = (): void => { @@ -465,18 +456,20 @@ export const useNewProductInlineModel = ({ }; const handleBarcodeScan = async (barcode: string): Promise => { - try { - const response = await api - .get(`products/barcode/${encodeURIComponent(barcode)}`) - .json<{ success: boolean; data: StockMovementProductOption }>(); + const lookup = await lookupStockMovementProductByBarcode(barcode); + if (lookup.status === "found") { setScannedExistingProduct({ - id: response.data.id, - name: response.data.name, + id: lookup.product.id, + name: lookup.product.name, barcode, }); - } catch { + return; + } + if (lookup.status === "not-found") { form.setValue("barcode", barcode); + return; } + toast.error(lookup.message); }; const handleExistingProductModalOpenChange = (open: boolean): void => { @@ -485,13 +478,32 @@ export const useNewProductInlineModel = ({ } }; - const handleCreateBatchForExistingProduct = (): void => { + const handleCreateBatchForExistingProduct = async (): Promise => { if (!scannedExistingProduct) return; + const draft = await readStockMovementDraft(); + const draftItems = draft?.items ?? []; + + const barcodeConflictError = getPendingInlineProductBarcodeConflictError( + draftItems, + scannedExistingProduct.barcode, + ); + if (barcodeConflictError) { + toast.error(barcodeConflictError); + setScannedExistingProduct(null); + return; + } + setExistingProductBatchForm({ ...EMPTY_BATCH_FORM, isOpen: true, productId: scannedExistingProduct.id, productName: scannedExistingProduct.name, + repeatedProductWarning: hasExistingProductInItems( + draftItems, + scannedExistingProduct.id, + ) + ? buildRepeatedProductBatchWarning(scannedExistingProduct.name) + : null, }); setScannedExistingProduct(null); }; @@ -513,19 +525,11 @@ export const useNewProductInlineModel = ({ }; const handleConfirmBatch = async (): Promise => { - const quantity = Number(existingProductBatchForm.quantity); - if (!quantity || quantity <= 0) { - updateBatchForm({ error: "Informe uma quantidade válida para o lote." }); - return; - } - - if (existingProductBatchForm.costPrice === undefined || existingProductBatchForm.costPrice < 0) { - updateBatchForm({ error: "Informe um preço de custo válido." }); - return; - } - - if (existingProductBatchForm.sellingPrice === undefined || existingProductBatchForm.sellingPrice < 0) { - updateBatchForm({ error: "Informe um preço de venda válido." }); + const validationError = validateExistingProductBatchForm( + existingProductBatchForm, + ); + if (validationError) { + updateBatchForm({ error: validationError }); return; } @@ -577,50 +581,75 @@ export const useNewProductInlineModel = ({ updateInlineQuantity((form.getValues("quantity") || 0) - 1); }; - const onSubmit = async (data: ProductCreateFormData): Promise => { - if (!validateCustomAttributes()) return; + const ensureInlineBarcodeIsAvailable = async ( + barcode: string, + ): Promise => { + const lookup = await lookupStockMovementProductByBarcode(barcode); + if (lookup.status === "not-found") return true; + if (lookup.status === "error") { + toast.error(lookup.message); + return false; + } + setScannedExistingProduct({ + id: lookup.product.id, + name: lookup.product.name, + barcode, + }); + return false; + }; - const currentDraft = await readStockMovementDraft(); - if (!currentDraft) { - router.replace("/stock-movements"); + const saveInlineProductToDraft = async ( + data: ProductCreateFormData, + ): Promise => { + const product = await buildInlineProductData( + data, + mergeAttributes(data), + productImage, + ); + if (isEditingInlineProduct && editItemIndex !== null) { + await updateProductInMovementDraft(editItemIndex, product, data.quantity); + toast.success(`${data.name} foi atualizado na movimentação.`); + router.push(cancelHref); return; } - if ( - hasDuplicateInlineProductName( - data.name, - currentDraft, - editItemIndex, - ) - ) { - toast.error(`O produto "${data.name}" já foi adicionado nesta movimentação.`); + await appendProductToMovementDraft(product, data.quantity); + if (data.continuousMode) { + toast.success( + `${data.name} já está na movimentação. Continue adicionando novos produtos.`, + ); + resetInlineFormForNextProduct(); return; } + router.push(cancelHref); + }; + + const onSubmit = async (data: ProductCreateFormData): Promise => { + if (!validateCustomAttributes()) return; + setIsSubmitting(true); try { - const product = await buildInlineProductData( - data, - mergeAttributes(data), - productImage, - ); - if (isEditingInlineProduct && editItemIndex !== null) { - await updateProductInMovementDraft(editItemIndex, product, data.quantity); - toast.success(`${data.name} foi atualizado na movimentação.`); - router.push(cancelHref); + const currentDraft = await readStockMovementDraft(); + if (!currentDraft) { + router.replace("/stock-movements"); return; } - await appendProductToMovementDraft(product, data.quantity); - if (data.continuousMode) { - toast.success( - `${data.name} já está na movimentação. Continue adicionando novos produtos.`, - ); - resetInlineFormForNextProduct(); + const duplicateError = findDuplicateInlineProductError( + { name: data.name, barcode: data.barcode }, + currentDraft.items, + editItemIndex, + ); + if (duplicateError) { + toast.error(duplicateError); return; } - router.push(cancelHref); + const barcode = data.barcode?.trim(); + if (barcode && !(await ensureInlineBarcodeIsAvailable(barcode))) return; + + await saveInlineProductToDraft(data); } catch { toast.error("Não foi possível preparar a imagem do produto."); } finally { diff --git a/app/(pages)/stock-movements/create/stock-movement-batch-data-modal.view.tsx b/app/(pages)/stock-movements/create/stock-movement-batch-data-modal.view.tsx index 42439a5..fb25422 100644 --- a/app/(pages)/stock-movements/create/stock-movement-batch-data-modal.view.tsx +++ b/app/(pages)/stock-movements/create/stock-movement-batch-data-modal.view.tsx @@ -227,6 +227,12 @@ export function StockMovementBatchDataModal({
+ {form.repeatedProductWarning && ( +
+ {form.repeatedProductWarning} +
+ )} + {form.error && (
{form.error} diff --git a/app/(pages)/stock-movements/create/stock-movement-batch-form-validation.test.ts b/app/(pages)/stock-movements/create/stock-movement-batch-form-validation.test.ts new file mode 100644 index 0000000..5a25cbb --- /dev/null +++ b/app/(pages)/stock-movements/create/stock-movement-batch-form-validation.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + getOptionalText, + validateExistingProductBatchForm, +} from "./stock-movement-batch-form-validation"; +import type { ExistingProductBatchFormState } from "./create-stock-movement.types"; + +const createBatchForm = ( + overrides: Partial = {}, +): ExistingProductBatchFormState => ({ + isOpen: true, + productId: "p-1", + productName: "Café Torrado", + quantity: "2", + manufacturedDate: "", + expirationDate: "", + costPrice: 1290, + sellingPrice: 2490, + editingIndex: null, + error: null, + ...overrides, +}); + +describe("getOptionalText", () => { + it("retorna undefined para texto vazio ou só com espaços", () => { + expect(getOptionalText(undefined)).toBeUndefined(); + expect(getOptionalText("")).toBeUndefined(); + expect(getOptionalText(" ")).toBeUndefined(); + }); + + it("retorna o texto aparado quando preenchido", () => { + expect(getOptionalText(" 2026-01-01 ")).toBe("2026-01-01"); + }); +}); + +describe("validateExistingProductBatchForm", () => { + it("aceita formulário completo sem datas", () => { + expect(validateExistingProductBatchForm(createBatchForm())).toBeNull(); + }); + + it("exige quantidade positiva", () => { + expect( + validateExistingProductBatchForm(createBatchForm({ quantity: "0" })), + ).toBe("Informe uma quantidade válida para o lote."); + expect( + validateExistingProductBatchForm(createBatchForm({ quantity: "" })), + ).toBe("Informe uma quantidade válida para o lote."); + }); + + it("exige preço de custo válido", () => { + expect( + validateExistingProductBatchForm(createBatchForm({ costPrice: undefined })), + ).toBe("Informe um preço de custo válido."); + expect( + validateExistingProductBatchForm(createBatchForm({ costPrice: -1 })), + ).toBe("Informe um preço de custo válido."); + }); + + it("exige preço de venda válido", () => { + expect( + validateExistingProductBatchForm( + createBatchForm({ sellingPrice: undefined }), + ), + ).toBe("Informe um preço de venda válido."); + }); + + it("rejeita validade anterior à fabricação", () => { + expect( + validateExistingProductBatchForm( + createBatchForm({ + manufacturedDate: "2026-06-01", + expirationDate: "2026-01-01", + }), + ), + ).toBe("A data de validade não pode ser anterior à data de fabricação."); + }); + + it("aceita intervalo de datas válido", () => { + expect( + validateExistingProductBatchForm( + createBatchForm({ + manufacturedDate: "2026-01-01", + expirationDate: "2026-06-01", + }), + ), + ).toBeNull(); + }); +}); diff --git a/app/(pages)/stock-movements/create/stock-movement-batch-form-validation.ts b/app/(pages)/stock-movements/create/stock-movement-batch-form-validation.ts new file mode 100644 index 0000000..ccd874e --- /dev/null +++ b/app/(pages)/stock-movements/create/stock-movement-batch-form-validation.ts @@ -0,0 +1,35 @@ +import type { ExistingProductBatchFormState } from "./create-stock-movement.types"; + +export const getOptionalText = (value: string | undefined): string | undefined => { + const trimmedValue = value?.trim(); + return trimmedValue || undefined; +}; + +const isBatchDateRangeInvalid = ( + manufacturedDate: string, + expirationDate: string, +): boolean => { + const optionalManufacturedDate = getOptionalText(manufacturedDate); + const optionalExpirationDate = getOptionalText(expirationDate); + if (!optionalManufacturedDate || !optionalExpirationDate) return false; + return new Date(optionalExpirationDate) < new Date(optionalManufacturedDate); +}; + +export const validateExistingProductBatchForm = ( + form: ExistingProductBatchFormState, +): string | null => { + const quantity = Number(form.quantity); + if (!quantity || quantity <= 0) { + return "Informe uma quantidade válida para o lote."; + } + if (form.costPrice === undefined || form.costPrice < 0) { + return "Informe um preço de custo válido."; + } + if (form.sellingPrice === undefined || form.sellingPrice < 0) { + return "Informe um preço de venda válido."; + } + if (isBatchDateRangeInvalid(form.manufacturedDate, form.expirationDate)) { + return "A data de validade não pode ser anterior à data de fabricação."; + } + return null; +}; diff --git a/app/(pages)/stock-movements/create/stock-movement-draft-guards.test.ts b/app/(pages)/stock-movements/create/stock-movement-draft-guards.test.ts new file mode 100644 index 0000000..6dd4f29 --- /dev/null +++ b/app/(pages)/stock-movements/create/stock-movement-draft-guards.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + buildRepeatedProductBatchWarning, + findDuplicateInlineProductError, + getPendingInlineProductBarcodeConflictError, + hasExistingProductInItems, +} from "./stock-movement-draft-guards"; +import type { StockMovementDraftItem } from "./create-stock-movement.types"; + +const draftItems: StockMovementDraftItem[] = [ + { + quantity: 2, + productName: "Produto Novo A", + newProductData: { name: "Produto Novo A", barcode: "789100000001" }, + }, + { + productId: "p-1", + quantity: 1, + productName: "Produto Existente", + }, +]; + +describe("findDuplicateInlineProductError", () => { + it("acusa nome duplicado ignorando caixa", () => { + expect( + findDuplicateInlineProductError( + { name: "produto novo a" }, + draftItems, + null, + ), + ).toBe('O produto "produto novo a" já foi adicionado nesta movimentação.'); + }); + + it("acusa barcode duplicado entre produtos novos", () => { + expect( + findDuplicateInlineProductError( + { name: "Outro Produto", barcode: "789100000001" }, + draftItems, + null, + ), + ).toBe( + 'O código 789100000001 já está em uso pelo produto "Produto Novo A" nesta movimentação.', + ); + }); + + it("ignora o item que está sendo editado", () => { + expect( + findDuplicateInlineProductError( + { name: "Produto Novo A", barcode: "789100000001" }, + draftItems, + 0, + ), + ).toBeNull(); + }); + + it("aceita produto sem conflito", () => { + expect( + findDuplicateInlineProductError( + { name: "Produto Inédito", barcode: "789100000999" }, + draftItems, + null, + ), + ).toBeNull(); + }); +}); + +describe("getPendingInlineProductBarcodeConflictError", () => { + it("acusa conflito com produto novo pendente de mesmo barcode", () => { + expect( + getPendingInlineProductBarcodeConflictError(draftItems, "789100000001"), + ).toBe( + 'O código 789100000001 já pertence ao produto novo "Produto Novo A" nesta movimentação. Remova-o antes de adicionar o produto existente.', + ); + }); + + it("retorna null sem barcode ou sem conflito", () => { + expect(getPendingInlineProductBarcodeConflictError(draftItems, null)).toBeNull(); + expect(getPendingInlineProductBarcodeConflictError(draftItems, "")).toBeNull(); + expect( + getPendingInlineProductBarcodeConflictError(draftItems, "789100000999"), + ).toBeNull(); + }); +}); + +describe("hasExistingProductInItems", () => { + it("encontra item pelo productId", () => { + expect(hasExistingProductInItems(draftItems, "p-1")).toBe(true); + expect(hasExistingProductInItems(draftItems, "p-2")).toBe(false); + }); +}); + +describe("buildRepeatedProductBatchWarning", () => { + it("monta aviso com o nome do produto", () => { + expect(buildRepeatedProductBatchWarning("Café Torrado")).toBe( + "Café Torrado já está na movimentação. Este lote será adicionado como um novo item.", + ); + }); +}); diff --git a/app/(pages)/stock-movements/create/stock-movement-draft-guards.ts b/app/(pages)/stock-movements/create/stock-movement-draft-guards.ts new file mode 100644 index 0000000..36b66d6 --- /dev/null +++ b/app/(pages)/stock-movements/create/stock-movement-draft-guards.ts @@ -0,0 +1,60 @@ +import type { StockMovementDraftItem } from "./create-stock-movement.types"; + +export interface InlineProductIdentity { + name: string; + barcode?: string; +} + +const withoutIgnoredIndex = ( + items: StockMovementDraftItem[], + ignoredIndex: number | null, +): StockMovementDraftItem[] => { + return items.filter((_, index) => index !== ignoredIndex); +}; + +export const findDuplicateInlineProductError = ( + product: InlineProductIdentity, + items: StockMovementDraftItem[], + ignoredIndex: number | null, +): string | null => { + const normalizedName = product.name.trim().toLowerCase(); + const normalizedBarcode = product.barcode?.trim(); + const consideredItems = withoutIgnoredIndex(items, ignoredIndex); + + const hasDuplicateName = consideredItems.some((item) => { + return item.newProductData?.name.toLowerCase() === normalizedName; + }); + if (hasDuplicateName) { + return `O produto "${product.name}" já foi adicionado nesta movimentação.`; + } + + if (!normalizedBarcode) return null; + const barcodeConflictItem = consideredItems.find((item) => { + return item.newProductData?.barcode === normalizedBarcode; + }); + if (!barcodeConflictItem) return null; + return `O código ${normalizedBarcode} já está em uso pelo produto "${barcodeConflictItem.productName}" nesta movimentação.`; +}; + +export const getPendingInlineProductBarcodeConflictError = ( + items: StockMovementDraftItem[], + barcode: string | null | undefined, +): string | null => { + if (!barcode) return null; + const conflictingItem = items.find((item) => { + return item.newProductData?.barcode === barcode; + }); + if (!conflictingItem) return null; + return `O código ${barcode} já pertence ao produto novo "${conflictingItem.productName}" nesta movimentação. Remova-o antes de adicionar o produto existente.`; +}; + +export const hasExistingProductInItems = ( + items: StockMovementDraftItem[], + productId: string, +): boolean => { + return items.some((item) => item.productId === productId); +}; + +export const buildRepeatedProductBatchWarning = (productName: string): string => { + return `${productName} já está na movimentação. Este lote será adicionado como um novo item.`; +}; diff --git a/app/(pages)/stock-movements/create/stock-movement-product-lookup.test.ts b/app/(pages)/stock-movements/create/stock-movement-product-lookup.test.ts new file mode 100644 index 0000000..69c0b41 --- /dev/null +++ b/app/(pages)/stock-movements/create/stock-movement-product-lookup.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { lookupStockMovementProductByBarcode } from "./stock-movement-product-lookup"; + +const mockGet = vi.fn(); +const mockIsApiNotFoundError = vi.fn(); + +vi.mock("@/lib/api", () => ({ + api: { + get: (...args: unknown[]) => mockGet(...args), + }, + isApiNotFoundError: (error: unknown) => mockIsApiNotFoundError(error), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("lookupStockMovementProductByBarcode", () => { + it("retorna produto encontrado", async () => { + mockGet.mockReturnValue({ + json: vi.fn(async () => ({ + success: true, + data: { id: "p-1", name: "Café Torrado", barcode: "789" }, + })), + }); + + const lookup = await lookupStockMovementProductByBarcode("789"); + + expect(mockGet).toHaveBeenCalledWith("products/barcode/789"); + expect(lookup).toEqual({ + status: "found", + product: { id: "p-1", name: "Café Torrado", barcode: "789" }, + }); + }); + + it("retorna not-found quando a API responde 404", async () => { + mockGet.mockReturnValue({ + json: vi.fn(async () => { + throw new Error("não encontrado"); + }), + }); + mockIsApiNotFoundError.mockReturnValue(true); + + const lookup = await lookupStockMovementProductByBarcode("789"); + + expect(lookup).toEqual({ status: "not-found" }); + }); + + it("retorna erro com mensagem para falhas que não são 404", async () => { + mockGet.mockReturnValue({ + json: vi.fn(async () => { + throw new Error("timeout"); + }), + }); + mockIsApiNotFoundError.mockReturnValue(false); + + const lookup = await lookupStockMovementProductByBarcode("789"); + + expect(lookup).toEqual({ + status: "error", + message: + "Não foi possível consultar o código 789 (timeout). Verifique a conexão e tente novamente.", + }); + }); + + it("escapa o barcode na URL", async () => { + mockGet.mockReturnValue({ + json: vi.fn(async () => ({ success: true, data: { id: "p", name: "x" } })), + }); + + await lookupStockMovementProductByBarcode("a/b c"); + + expect(mockGet).toHaveBeenCalledWith("products/barcode/a%2Fb%20c"); + }); +}); diff --git a/app/(pages)/stock-movements/create/stock-movement-product-lookup.ts b/app/(pages)/stock-movements/create/stock-movement-product-lookup.ts new file mode 100644 index 0000000..f69e11f --- /dev/null +++ b/app/(pages)/stock-movements/create/stock-movement-product-lookup.ts @@ -0,0 +1,37 @@ +import { api, isApiNotFoundError } from "@/lib/api"; +import type { StockMovementProductOption } from "./create-stock-movement.types"; + +interface StockMovementProductByBarcodeResponse { + success: boolean; + data: StockMovementProductOption; +} + +export type StockMovementBarcodeLookup = + | { status: "found"; product: StockMovementProductOption } + | { status: "not-found" } + | { status: "error"; message: string }; + +const buildBarcodeLookupErrorMessage = ( + barcode: string, + error: unknown, +): string => { + const detail = error instanceof Error && error.message ? ` (${error.message})` : ""; + return `Não foi possível consultar o código ${barcode}${detail}. Verifique a conexão e tente novamente.`; +}; + +export const lookupStockMovementProductByBarcode = async ( + barcode: string, +): Promise => { + try { + const response = await api + .get(`products/barcode/${encodeURIComponent(barcode)}`) + .json(); + return { status: "found", product: response.data }; + } catch (error) { + if (isApiNotFoundError(error)) return { status: "not-found" }; + return { + status: "error", + message: buildBarcodeLookupErrorMessage(barcode, error), + }; + } +}; diff --git a/lib/api.ts b/lib/api.ts index dde22c0..b208f51 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,4 +1,8 @@ -import ky from "ky"; +import ky, { HTTPError } from "ky"; + +export const isApiNotFoundError = (error: unknown): boolean => { + return error instanceof HTTPError && error.response.status === 404; +}; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, ""); From a402988a7a5798c8adf55a019e8bf880602e022a Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Wed, 10 Jun 2026 14:51:45 -0300 Subject: [PATCH 10/32] fix(product-form): make scannedExistingProduct optional and fix all tsc errors - Make scannedExistingProduct prop optional in ProductFormProps - Fix !== null to != null in ExistingProductFoundModal guard - Recipe A: cast NormalizedOptions in HTTPError constructors - Recipe B: add error/isValidating to SWR mock returns - Recipe C: use vi.mocked() for mockClear on plain function types - Recipe D: narrow Zod safeParse results before helper calls - Recipe E: add missing view-prop mocks (onNewTransfer, as const, ProductSearchOption) - Recipe F: align test signatures with current function signatures - Add 'typecheck' script to package.json - Add Typecheck step to CI quality workflow --- .github/workflows/quality.yml | 3 +++ app/(pages)/batches/batches.view.test.tsx | 2 +- .../batches/create/batches-create.schema.test.ts | 4 +++- .../batches/create/batches-create.view.test.tsx | 6 +++++- app/(pages)/categories/categories.model.test.ts | 2 +- app/(pages)/layout.test.tsx | 10 +++------- .../products/components/product-form.types.ts | 2 +- .../products/components/product-form.view.tsx | 2 +- .../stock-movements/stock-movements.model.test.ts | 4 ++-- .../transfers/[id]/transfer-detail.model.test.ts | 4 ++-- .../transfers/new/new-transfer.schema.test.ts | 5 ++++- app/(pages)/transfers/transfers.model.test.ts | 2 +- app/(pages)/transfers/transfers.view.test.tsx | 1 + app/change-password/change-password.model.test.ts | 5 +++-- app/login/login.model.test.ts | 15 ++++++++------- app/register/register.model.test.ts | 3 ++- lib/contexts/auth-context.test.tsx | 8 ++++++++ package.json | 1 + 18 files changed, 50 insertions(+), 29 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7c6e3bd..d2c5dc1 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -35,6 +35,9 @@ jobs: - name: Lint run: pnpm lint + - name: Typecheck + run: pnpm typecheck + - name: Unit tests run: pnpm exec vitest run diff --git a/app/(pages)/batches/batches.view.test.tsx b/app/(pages)/batches/batches.view.test.tsx index 1c15e5b..1b4cb30 100644 --- a/app/(pages)/batches/batches.view.test.tsx +++ b/app/(pages)/batches/batches.view.test.tsx @@ -32,7 +32,7 @@ const baseProps = { status: "all" as const, lowStockThreshold: 10, }, - sortConfig: { key: "createdAt", direction: "desc" as const }, + sortConfig: { key: "createdAt" as const, direction: "desc" as const }, isGroupedByProduct: false, isMobileFiltersOpen: false, mobileFiltersDraft: { diff --git a/app/(pages)/batches/create/batches-create.schema.test.ts b/app/(pages)/batches/create/batches-create.schema.test.ts index 1aa28e6..5a33fbd 100644 --- a/app/(pages)/batches/create/batches-create.schema.test.ts +++ b/app/(pages)/batches/create/batches-create.schema.test.ts @@ -10,7 +10,7 @@ const baseData = { }; const expectErrorPath = ( - result: { success: false; error: { issues: { path: (string | number)[]; message: string }[] } }, + result: { success: false; error: { issues: { path: PropertyKey[]; message: string }[] } }, path: string, ): void => { if (result.success) { @@ -52,6 +52,7 @@ describe("batchCreateSchema", () => { productId: "", }); expect(result.success).toBe(false); + if (result.success) throw new Error("expected parse failure"); expectErrorPath(result, "productId"); }); @@ -61,6 +62,7 @@ describe("batchCreateSchema", () => { quantity: 0, }); expect(result.success).toBe(false); + if (result.success) throw new Error("expected parse failure"); expectErrorPath(result, "quantity"); }); diff --git a/app/(pages)/batches/create/batches-create.view.test.tsx b/app/(pages)/batches/create/batches-create.view.test.tsx index eaedfe8..264ba11 100644 --- a/app/(pages)/batches/create/batches-create.view.test.tsx +++ b/app/(pages)/batches/create/batches-create.view.test.tsx @@ -67,7 +67,11 @@ const baseProps = { selectedWarehouseId: "wh-1", onQuantityIncrement: vi.fn(), onQuantityDecrement: vi.fn(), - selectedProduct: { hasExpiration: false }, + selectedProduct: { id: "prod-1", name: "Produto A", hasExpiration: false }, + latestBatchPriceSuggestion: null, + isLatestBatchPriceLoading: false, + onApplyLatestCostPrice: vi.fn(), + onApplyLatestSellingPrice: vi.fn(), }; const Wrapper = ({ diff --git a/app/(pages)/categories/categories.model.test.ts b/app/(pages)/categories/categories.model.test.ts index 3b47561..b659d2c 100644 --- a/app/(pages)/categories/categories.model.test.ts +++ b/app/(pages)/categories/categories.model.test.ts @@ -80,7 +80,7 @@ const fakeSWR = vi.hoisted(() => { public reset(): void { this.responses.clear(); - this.defaultState.mutate.mockClear(); + vi.mocked(this.defaultState.mutate).mockClear(); this.hook.mockClear(); } } diff --git a/app/(pages)/layout.test.tsx b/app/(pages)/layout.test.tsx index daddf5e..fc4c82f 100644 --- a/app/(pages)/layout.test.tsx +++ b/app/(pages)/layout.test.tsx @@ -38,13 +38,9 @@ vi.mock("@/lib/contexts/warehouse-context", () => ({ }), })); -vi.mock( - "@/components/layout/mobile-menu-context", - () => ({ - useMobileMenu: () => mobileMenuMock, - }), - { virtual: true } -); +vi.mock("@/components/layout/mobile-menu-context", () => ({ + useMobileMenu: () => mobileMenuMock, +})); const getClassTokens = (element: Element | null): string[] => element?.getAttribute("class")?.split(/\s+/) ?? []; diff --git a/app/(pages)/products/components/product-form.types.ts b/app/(pages)/products/components/product-form.types.ts index a4a875c..a910da8 100644 --- a/app/(pages)/products/components/product-form.types.ts +++ b/app/(pages)/products/components/product-form.types.ts @@ -127,7 +127,7 @@ export interface ProductFormProps { handleBarcodeScan: (barcode: string) => void | Promise; // Existing product modal (new-product page) - scannedExistingProduct: ExistingProductInfo | null; + scannedExistingProduct?: ExistingProductInfo | null; onExistingProductModalOpenChange?: (open: boolean) => void; onCreateBatchForExistingProduct?: () => void | Promise; diff --git a/app/(pages)/products/components/product-form.view.tsx b/app/(pages)/products/components/product-form.view.tsx index 5d36e47..dac9d51 100644 --- a/app/(pages)/products/components/product-form.view.tsx +++ b/app/(pages)/products/components/product-form.view.tsx @@ -243,7 +243,7 @@ const ExistingProductFoundModal = ({ return ( { public reset(): void { this.states.clear(); - this.defaultState.mutate.mockClear(); + vi.mocked(this.defaultState.mutate).mockClear(); this.hook.mockClear(); } } @@ -66,7 +66,7 @@ const fakeWarehouse = vi.hoisted(() => { }); vi.mock("swr", () => ({ - default: (...args: unknown[]) => fakeSWR.hook(...args), + default: (...args: [string | null, unknown?]) => fakeSWR.hook(...args), })); vi.mock("@/lib/api", () => ({ diff --git a/app/(pages)/transfers/[id]/transfer-detail.model.test.ts b/app/(pages)/transfers/[id]/transfer-detail.model.test.ts index b776e74..188d457 100644 --- a/app/(pages)/transfers/[id]/transfer-detail.model.test.ts +++ b/app/(pages)/transfers/[id]/transfer-detail.model.test.ts @@ -30,7 +30,7 @@ const fakeSWR = vi.hoisted(() => { public reset(): void { this.responses.clear(); - this.defaultState.mutate.mockClear(); + vi.mocked(this.defaultState.mutate).mockClear(); this.hook.mockClear(); } } @@ -131,7 +131,7 @@ const baseTransfer: Transfer = { destinationWarehouseId: "warehouse-destination", destinationWarehouseName: "Loja Centro", status: TransferStatus.DRAFT, - notes: null, + notes: undefined, items: [], createdAt: "2026-01-01T09:00:00.000Z", }; diff --git a/app/(pages)/transfers/new/new-transfer.schema.test.ts b/app/(pages)/transfers/new/new-transfer.schema.test.ts index c772221..386f5eb 100644 --- a/app/(pages)/transfers/new/new-transfer.schema.test.ts +++ b/app/(pages)/transfers/new/new-transfer.schema.test.ts @@ -17,7 +17,7 @@ const baseTransferPayload: NewTransferSchema = { ], }; -const expectErrorPath = (result: { success: false; error: { issues: { path: (string | number)[] }[] } }, path: string): void => { +const expectErrorPath = (result: { success: false; error: { issues: { path: PropertyKey[] }[] } }, path: string): void => { if (result.success) { throw new Error("Parsing deveria falhar"); } @@ -40,6 +40,7 @@ describe("newTransferSchema", () => { }); expect(result.success).toBe(false); + if (result.success) throw new Error("expected parse failure"); expectErrorPath(result, "destinationWarehouseId"); }); @@ -50,6 +51,7 @@ describe("newTransferSchema", () => { }); expect(result.success).toBe(false); + if (result.success) throw new Error("expected parse failure"); expectErrorPath(result, "items"); }); @@ -65,6 +67,7 @@ describe("newTransferSchema", () => { }); expect(result.success).toBe(false); + if (result.success) throw new Error("expected parse failure"); expectErrorPath(result, "items"); }); diff --git a/app/(pages)/transfers/transfers.model.test.ts b/app/(pages)/transfers/transfers.model.test.ts index 410759d..7a92376 100644 --- a/app/(pages)/transfers/transfers.model.test.ts +++ b/app/(pages)/transfers/transfers.model.test.ts @@ -30,7 +30,7 @@ const fakeSWR = vi.hoisted(() => { public reset(): void { this.responses.clear(); - this.defaultState.mutate.mockClear(); + vi.mocked(this.defaultState.mutate).mockClear(); this.hook.mockClear(); } } diff --git a/app/(pages)/transfers/transfers.view.test.tsx b/app/(pages)/transfers/transfers.view.test.tsx index c38f1e7..ce0fb99 100644 --- a/app/(pages)/transfers/transfers.view.test.tsx +++ b/app/(pages)/transfers/transfers.view.test.tsx @@ -82,6 +82,7 @@ describe("TransfersView", () => { onTabChange: vi.fn(), stats: { total: 1, inTransit: 0, pending: 1, completed: 0 }, onRetry: vi.fn(), + onNewTransfer: vi.fn(), }; it("renders the header and tab buttons", () => { diff --git a/app/change-password/change-password.model.test.ts b/app/change-password/change-password.model.test.ts index d016c22..1833a44 100644 --- a/app/change-password/change-password.model.test.ts +++ b/app/change-password/change-password.model.test.ts @@ -1,5 +1,6 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NormalizedOptions } from "ky"; import { useChangePasswordModel } from "./change-password.model"; import { changePasswordSchema, type ChangePasswordFormData } from "./change-password.schema"; @@ -195,7 +196,7 @@ describe("useChangePasswordModel", () => { const response = new Response(JSON.stringify({ message: "Senha incorreta" }), { status: 400, }); - const httpError = new HTTPError(response, new Request("http://localhost")); + const httpError = new HTTPError(response, new Request("http://localhost"), {} as unknown as NormalizedOptions); fakeApi.post.mockReturnValue( new FakeApiJsonResponse({ @@ -219,7 +220,7 @@ describe("useChangePasswordModel", () => { it("falls back to generic message when HTTP error has no message", async () => { const { HTTPError } = await import("ky"); const response = new Response("not-json", { status: 400 }); - const httpError = new HTTPError(response, new Request("http://localhost")); + const httpError = new HTTPError(response, new Request("http://localhost"), {} as unknown as NormalizedOptions); fakeApi.post.mockReturnValue( new FakeApiJsonResponse({ diff --git a/app/login/login.model.test.ts b/app/login/login.model.test.ts index 65ae29a..a899c9b 100644 --- a/app/login/login.model.test.ts +++ b/app/login/login.model.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NormalizedOptions } from "ky"; import { renderHook, act, waitFor } from "@testing-library/react"; import { useLoginModel } from "./login.model"; import { loginSchema } from "./login.schema"; @@ -197,7 +198,7 @@ describe("useLoginModel", () => { const httpError = new HTTPError( new Response(JSON.stringify(errorData), { status: 401 }), new Request("http://test.com"), - {} + {} as unknown as NormalizedOptions ); mockApiPost.mockReturnValue({ @@ -228,7 +229,7 @@ describe("useLoginModel", () => { const httpError = new HTTPError( new Response(JSON.stringify(errorData), { status: 400 }), new Request("http://test.com"), - {} + {} as unknown as NormalizedOptions ); mockApiPost.mockReturnValue({ @@ -258,7 +259,7 @@ describe("useLoginModel", () => { const httpError = new HTTPError( new Response(JSON.stringify(errorData), { status: 400 }), new Request("http://test.com"), - {} + {} as unknown as NormalizedOptions ); mockApiPost.mockReturnValue({ @@ -298,7 +299,7 @@ describe("useLoginModel", () => { const httpError = new HTTPError( new Response(JSON.stringify(errorData), { status: 400 }), new Request("http://test.com"), - {} + {} as unknown as NormalizedOptions ); mockApiPost.mockReturnValue({ @@ -354,7 +355,7 @@ describe("useLoginModel", () => { const httpError = new HTTPError( new Response(JSON.stringify(errorData), { status: 400 }), new Request("http://test.com"), - {} + {} as unknown as NormalizedOptions ); mockApiPost.mockReturnValue({ @@ -402,7 +403,7 @@ describe("useLoginModel", () => { const httpError = new HTTPError( new Response("not json", { status: 500 }), new Request("http://test.com"), - {} + {} as unknown as NormalizedOptions ); mockApiPost.mockReturnValue({ @@ -474,7 +475,7 @@ describe("useLoginModel", () => { const httpError = new HTTPError( new Response(JSON.stringify(errorData), { status: 400 }), new Request("http://test.com"), - {} + {} as unknown as NormalizedOptions ); mockApiPost.mockReturnValue({ diff --git a/app/register/register.model.test.ts b/app/register/register.model.test.ts index b45821d..aadaf21 100644 --- a/app/register/register.model.test.ts +++ b/app/register/register.model.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NormalizedOptions } from "ky"; import { renderHook, act, waitFor } from "@testing-library/react"; import { useRegisterModel } from "./register.model"; import { registerSchema } from "./register.schema"; @@ -163,7 +164,7 @@ describe("useRegisterModel", () => { const httpError = new HTTPError( new Response(JSON.stringify(errorResponse), { status: 400 }), new Request("http://test.com"), - {} + {} as unknown as NormalizedOptions ); mockApiPost.mockReturnValue({ diff --git a/lib/contexts/auth-context.test.tsx b/lib/contexts/auth-context.test.tsx index e1a62ee..ac3aea6 100644 --- a/lib/contexts/auth-context.test.tsx +++ b/lib/contexts/auth-context.test.tsx @@ -129,6 +129,8 @@ describe("useAuth", () => { }, isLoading: false, mutate: vi.fn(), + error: undefined, + isValidating: false, } as ReturnType); const { result } = renderHook(() => useAuth(), { wrapper }); @@ -149,6 +151,8 @@ describe("useAuth", () => { data: mockMeData, isLoading: false, mutate: vi.fn(), + error: undefined, + isValidating: false, } as ReturnType); const { result } = renderHook(() => useAuth(), { wrapper }); @@ -179,6 +183,8 @@ describe("useAuth", () => { data: mockMeData, isLoading: false, mutate: vi.fn(), + error: undefined, + isValidating: false, } as ReturnType); const { result } = renderHook(() => useAuth(), { wrapper }); @@ -203,6 +209,8 @@ describe("useAuth", () => { }, isLoading: false, mutate: vi.fn(), + error: undefined, + isValidating: false, } as ReturnType); const { result } = renderHook(() => useAuth(), { wrapper }); diff --git a/package.json b/package.json index 3651d6b..a065669 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "next build --turbopack", "start": "next start", "lint": "eslint", + "typecheck": "tsc --noEmit", "test": "vitest", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", From 53dff8e1d314b291abe8bc19324d093359eeee4e Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Wed, 10 Jun 2026 15:09:47 -0300 Subject: [PATCH 11/32] fix(api): preserve client config on post-refresh retries --- lib/api.test.ts | 46 ++++++++++++++++++++++++++++++++++++++- lib/api.ts | 57 +++++++++++++++++++++++++++---------------------- 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/lib/api.test.ts b/lib/api.test.ts index acdeb77..5da693a 100644 --- a/lib/api.test.ts +++ b/lib/api.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { shouldRefreshAccessToken } from "./api"; +import { HTTPError } from "ky"; +import type { NormalizedOptions } from "ky"; +import { shouldRefreshAccessToken, attachApiErrorMessage } from "./api"; + +const stubOptions = {} as unknown as NormalizedOptions; describe("shouldRefreshAccessToken", () => { it("refreshes on unauthorized responses", async () => { @@ -22,3 +26,43 @@ describe("shouldRefreshAccessToken", () => { await expect(shouldRefreshAccessToken(response)).resolves.toBe(true); }); }); + +describe("attachApiErrorMessage", () => { + it("extracts message from API error body", async () => { + const response = new Response( + JSON.stringify({ message: "Estoque insuficiente" }), + { status: 422 }, + ); + const request = new Request("https://example.test/api/x"); + const error = new HTTPError(response, request, stubOptions); + + const result = await attachApiErrorMessage(error); + + expect(result.message).toBe("Estoque insuficiente"); + }); + + it("leaves 403 responses untouched", async () => { + const response = new Response( + JSON.stringify({ message: "Forbidden" }), + { status: 403 }, + ); + const request = new Request("https://example.test/api/x"); + const error = new HTTPError(response, request, stubOptions); + const originalMessage = error.message; + + const result = await attachApiErrorMessage(error); + + expect(result.message).toBe(originalMessage); + }); + + it("keeps original message for non-JSON body", async () => { + const response = new Response("not-json", { status: 500 }); + const request = new Request("https://example.test/api/x"); + const error = new HTTPError(response, request, stubOptions); + const originalMessage = error.message; + + const result = await attachApiErrorMessage(error); + + expect(result.message).toBe(originalMessage); + }); +}); diff --git a/lib/api.ts b/lib/api.ts index b208f51..ab2803d 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -51,6 +51,25 @@ export const shouldRefreshAccessToken = async (response: Response) => { } }; +export const attachApiErrorMessage = async ( + error: HTTPError, +): Promise => { + const { response } = error; + + if (isRedirectingToLogin) return error; + + if (response && response.status !== 403) { + try { + const body = (await response.json()) as { message?: string }; + error.message = body.message || error.message; + } catch { + // Se não conseguir parsear, mantém mensagem original + } + } + + return error; +}; + const redirectToLogin = () => { if (typeof window === "undefined" || isRedirectingToLogin) return; isRedirectingToLogin = true; @@ -59,32 +78,22 @@ const redirectToLogin = () => { window.location.href = "/login"; }; +const retryAfterRefreshClient = ky.create({ + credentials: "include", + timeout: 30000, + retry: 0, + hooks: { + beforeError: [attachApiErrorMessage], + }, +}); + export const api = ky.create({ prefixUrl: `${API_BASE_URL}/api`, credentials: "include", timeout: 30000, retry: 0, hooks: { - beforeError: [ - async (error) => { - const { response } = error; - - // Não tenta processar se já estamos redirecionando - if (isRedirectingToLogin) return error; - - // Para erros que não são 403, tenta extrair mensagem da API - if (response && response.status !== 403) { - try { - const body = (await response.json()) as { message?: string }; - error.message = body.message || error.message; - } catch { - // Se não conseguir parsear, mantém mensagem original - } - } - - return error; - }, - ], + beforeError: [attachApiErrorMessage], afterResponse: [ async (request, _options, response) => { // Se já estamos redirecionando, não processa mais nada @@ -103,9 +112,7 @@ export const api = ky.create({ isRefreshing = false; // Retry da requisição original - return ky(request.clone(), { - credentials: "include", - }); + return retryAfterRefreshClient(request.clone()); } catch (refreshError) { processQueue(refreshError as Error); isRefreshing = false; @@ -125,9 +132,7 @@ export const api = ky.create({ if (isRedirectingToLogin) return response; // Retry da requisição original após refresh - return ky(request.clone(), { - credentials: "include", - }); + return retryAfterRefreshClient(request.clone()); } catch { // Refresh falhou enquanto esperava na fila return response; From 30db4b55c819c040559efc51acee34a491fc665c Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Wed, 10 Jun 2026 15:24:44 -0300 Subject: [PATCH 12/32] refactor(stock-movements): store draft images as blobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace base64 dataUrl strings with native Blob objects in the InlineProductImageData interface. IndexedDB stores Blobs natively via structured clone, so the base64 round-trip was unnecessary and cost ~33% size inflation plus main-thread encode/decode on every draft write. - Change InlineProductImageData.dataUrl (string) to .blob (Blob) - Make fileToInlineProductImage synchronous (File IS a Blob) - Simplify inlineProductImageToFile to wrap blob in File constructor - Bump STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION 2→3 (old drafts discarded) - Update Zod schema to validate blob with z.instanceof(Blob) - Remove window.atob and FileReader usage - Update all test fixtures and mocks - Add regression test for malformed blob draft rejection --- .../create-stock-movement.model.test.ts | 22 +++++----- .../create-stock-movement.schema.test.ts | 4 +- .../create/create-stock-movement.schema.ts | 2 +- .../create-stock-movement.storage.test.ts | 43 ++++++++++++++++--- .../create/create-stock-movement.storage.ts | 34 ++++----------- .../create/create-stock-movement.types.ts | 2 +- .../new-product-inline.model.test.ts | 16 ++++--- .../new-product/new-product-inline.model.ts | 12 +++--- 8 files changed, 74 insertions(+), 61 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 3f54b10..3b09a78 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 @@ -113,6 +113,7 @@ const fakeToast = vi.hoisted(() => { class FakeToast { public readonly success = vi.fn<(message: string) => void>(); public readonly error = vi.fn<(message: string, options?: unknown) => void>(); + public readonly warning = vi.fn<(message: string) => void>(); } return new FakeToast(); @@ -143,7 +144,7 @@ const fakeStorage = vi.hoisted(() => { } this.draftState = { ...draft, - schemaVersion: 2, + schemaVersion: 3, updatedAt: "2026-01-20T10:00:00.000Z", revision: storedRevision + 1, runtimeId: this.currentRuntimeId, @@ -233,17 +234,16 @@ vi.mock("./create-stock-movement.storage", () => ({ inlineProductImageToFile: (image: { name: string; type: string; - dataUrl: string; + blob: Blob; }): File => - new File(["img"], image.name, { + new File([image.blob], image.name, { type: image.type, }), - fileToInlineProductImage: (file: File) => - Promise.resolve({ - name: file.name, - type: file.type, - dataUrl: "data:image/png;base64,YQ==", - }), + fileToInlineProductImage: (file: File) => ({ + name: file.name, + type: file.type, + blob: file, + }), })); const movementProducts: StockMovementProductOption[] = [ @@ -319,7 +319,7 @@ const barcodeIndex: Record = { const createDraftState = ( overrides?: Partial, ): StockMovementDraft => ({ - schemaVersion: 2, + schemaVersion: 3, updatedAt: "2026-01-20T09:00:00.000Z", revision: 1, type: "PURCHASE_IN", @@ -384,7 +384,7 @@ const createInlineSubmitPayload = (): CreateStockMovementSchema => ({ image: { name: "inline.png", type: "image/png", - dataUrl: "data:image/png;base64,ZmFrZQ==", + blob: new Blob(["fake"], { type: "image/png" }), }, }, }, diff --git a/app/(pages)/stock-movements/create/create-stock-movement.schema.test.ts b/app/(pages)/stock-movements/create/create-stock-movement.schema.test.ts index a7faef7..d39f9cf 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.schema.test.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.schema.test.ts @@ -51,7 +51,7 @@ describe("createStockMovementSchema", () => { image: { name: "foto.png", type: "image/png", - dataUrl: "data:image/png;base64,YQ==", + blob: new Blob(["a"], { type: "image/png" }), }, }, }, @@ -140,7 +140,7 @@ describe("createStockMovementSchema", () => { image: { name: "", type: "", - dataUrl: "", + blob: "not-a-blob", }, }, }, diff --git a/app/(pages)/stock-movements/create/create-stock-movement.schema.ts b/app/(pages)/stock-movements/create/create-stock-movement.schema.ts index 91545c1..56d9e6c 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.schema.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.schema.ts @@ -4,7 +4,7 @@ import { MANUAL_MOVEMENT_TYPES } from "../stock-movements.constants"; const inlineProductImageSchema = z.object({ name: z.string().min(1), type: z.string().min(1), - dataUrl: z.string().min(1), + blob: z.instanceof(Blob), }); const inlineProductSchema = z.object({ diff --git a/app/(pages)/stock-movements/create/create-stock-movement.storage.test.ts b/app/(pages)/stock-movements/create/create-stock-movement.storage.test.ts index ee04521..b9e9bd3 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.storage.test.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.storage.test.ts @@ -185,7 +185,7 @@ const createDraft = (): WritableStockMovementDraft => ({ image: { name: "produto.png", type: "image/png", - dataUrl: "data:image/png;base64,YQ==", + blob: new Blob(["a"], { type: "image/png" }), }, }, }, @@ -309,17 +309,46 @@ describe("create-stock-movement.storage", () => { expect(fakeIndexedDb.getStoredDraft()).toBeUndefined(); }); - it("preserva imagem inline em dataUrl", async () => { + it("preserva imagem inline em blob", async () => { await writeStockMovementDraft(createDraft()); const draft = await readStockMovementDraft(); - const image = draft?.items[0].newProductData?.image; + const savedImage = draft?.items[0].newProductData?.image; - expect(image).toEqual({ - name: "produto.png", - type: "image/png", - dataUrl: "data:image/png;base64,YQ==", + expect(savedImage).toBeDefined(); + expect(savedImage!.name).toBe("produto.png"); + expect(savedImage!.type).toBe("image/png"); + expect(savedImage!.blob).toBeInstanceOf(Blob); + }); + + it("rejeita draft com imagem cujo blob não é Blob", async () => { + fakeIndexedDb.putStoredDraft({ + schemaVersion: STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION, + type: "PURCHASE_IN", + warehouseId: "wh-1", + revision: 1, + notes: "draft inválido", + items: [ + { + quantity: 1, + productName: "Produto quebrado", + newProductData: { + name: "Produto quebrado", + image: { + name: "foto.png", + type: "image/png", + blob: "not-a-blob", + }, + }, + }, + ], + selectedProductId: "", + itemQuantity: "", + updatedAt: "2026-01-20T10:00:00.000Z", }); + + expect(await readStockMovementDraft()).toBeNull(); + expect(fakeIndexedDb.getStoredDraft()).toBeUndefined(); }); it("mantém fallback em memória quando IndexedDB falha", async () => { diff --git a/app/(pages)/stock-movements/create/create-stock-movement.storage.ts b/app/(pages)/stock-movements/create/create-stock-movement.storage.ts index 2e6d4cd..113bb41 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.storage.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.storage.ts @@ -9,7 +9,7 @@ const DRAFT_DATABASE_NAME = "stockshift"; const DRAFT_DATABASE_VERSION = 1; const DRAFT_STORE_NAME = "stockMovementDrafts"; const DRAFT_STORAGE_KEY = "current"; -export const STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION = 2; +export const STOCK_MOVEMENT_DRAFT_SCHEMA_VERSION = 3; const createStockMovementDraftRuntimeId = (): string => { if (typeof globalThis.crypto?.randomUUID === "function") { @@ -172,7 +172,7 @@ const isInlineProductImageData = ( isRecord(value) && typeof value.name === "string" && typeof value.type === "string" && - typeof value.dataUrl === "string" + value.blob instanceof Blob ); }; @@ -355,30 +355,12 @@ export const clearStockMovementDraft = async (): Promise => { export const fileToInlineProductImage = ( file: File, -): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(new Error(`Falha ao ler a imagem ${file.name}`)); - reader.onload = () => { - resolve({ - name: file.name, - type: file.type, - dataUrl: String(reader.result), - }); - }; - reader.readAsDataURL(file); - }); -}; +): InlineProductImageData => ({ + name: file.name, + type: file.type, + blob: file, +}); export const inlineProductImageToFile = ( image: InlineProductImageData, -): File => { - const [metadata, base64] = image.dataUrl.split(","); - const mime = metadata.match(/data:(.*);base64/)?.[1] || image.type; - const bytes = window.atob(base64); - const buffer = new Uint8Array(bytes.length); - for (let index = 0; index < bytes.length; index += 1) { - buffer[index] = bytes.charCodeAt(index); - } - return new File([buffer], image.name, { type: mime }); -}; +): File => new File([image.blob], image.name, { type: image.type }); diff --git a/app/(pages)/stock-movements/create/create-stock-movement.types.ts b/app/(pages)/stock-movements/create/create-stock-movement.types.ts index f32ed17..e7e8ba1 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.types.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.types.ts @@ -21,7 +21,7 @@ export interface InlineProductData { export interface InlineProductImageData { name: string; type: string; - dataUrl: string; + blob: Blob; } export interface StockMovementDraftItem { 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 e045acd..d6e8730 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 @@ -14,6 +14,7 @@ const mockRedirect = vi.fn((url: string): never => { const mockWriteDraft = vi.fn(); const toastSuccess = vi.fn(); const toastError = vi.fn(); +const toastWarning = vi.fn(); let movementType: string | null = "PURCHASE_IN"; let editItemQuery: string | null = null; @@ -34,7 +35,7 @@ const brandsResponse = { }; const createDraft = (overrides?: Partial): StockMovementDraft => ({ - schemaVersion: 2, + schemaVersion: 3, updatedAt: "2026-01-20T09:00:00.000Z", revision: 1, type: "PURCHASE_IN", @@ -105,6 +106,7 @@ vi.mock("sonner", () => ({ toast: { success: (...args: unknown[]) => toastSuccess(...args), error: (...args: unknown[]) => toastError(...args), + warning: (...args: unknown[]) => toastWarning(...args), }, })); @@ -117,16 +119,16 @@ vi.mock("@/hooks/use-selected-warehouse", () => ({ })); vi.mock("../create-stock-movement.storage", () => ({ - fileToInlineProductImage: async (file: File) => ({ + fileToInlineProductImage: (file: File) => ({ name: file.name, type: file.type, - dataUrl: "data:application/octet-stream;base64,Yg==", + blob: file, }), inlineProductImageToFile: (image: { name: string; type: string; - dataUrl: string; - }) => new File(["x"], image.name, { type: image.type }), + blob: Blob; + }) => new File([image.blob], image.name, { type: image.type }), readStockMovementDraft: async () => currentDraft, mutateStockMovementDraft: async ( buildNextDraft: (draft: StockMovementDraft) => StockMovementDraft, @@ -260,7 +262,7 @@ describe("useNewProductInlineModel", () => { image: { name: "inline.png", type: "image/png", - dataUrl: "data:image/png;base64,YQ==", + blob: new Blob(["a"], { type: "image/png" }), }, }, }, @@ -316,7 +318,7 @@ describe("useNewProductInlineModel", () => { await result.current.onSubmit(buildFormData()); }); - expect(toastError).toHaveBeenCalledWith("Atributo 1: Nome e valor são obrigatórios"); + expect(toastWarning).toHaveBeenCalledWith("Atributo 1: Nome e valor são obrigatórios"); expect(mockWriteDraft).not.toHaveBeenCalled(); expect(result.current.isSubmitting).toBe(false); }); 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 e5e3387..5e32d3c 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 @@ -83,11 +83,11 @@ const resolveInitialProductImage = ( return product?.image ? inlineProductImageToFile(product.image) : null; }; -const buildInlineProductData = async ( +const buildInlineProductData = ( data: ProductCreateFormData, attributes: Record | undefined, image: File | null, -): Promise => ({ +): InlineProductData => ({ name: data.name, description: data.description || undefined, barcode: data.barcode || undefined, @@ -101,7 +101,7 @@ const buildInlineProductData = async ( expirationDate: data.expirationDate || undefined, costPrice: data.costPrice, sellingPrice: data.sellingPrice, - image: image ? await fileToInlineProductImage(image) : undefined, + image: image ? fileToInlineProductImage(image) : undefined, }); const buildInlineMovementItem = ( @@ -407,14 +407,14 @@ export const useNewProductInlineModel = ({ return !attr.key.trim() || !attr.value.trim(); }); if (invalidIndex >= 0) { - toast.error(`Atributo ${invalidIndex + 1}: Nome e valor são obrigatórios`); + toast.warning(`Atributo ${invalidIndex + 1}: Nome e valor são obrigatórios`); return false; } const keys = customAttributes.map((attr) => attr.key.trim().toLowerCase()); const duplicate = keys.find((key, index) => keys.indexOf(key) !== index); if (duplicate) { - toast.error(`Já existe um atributo com o nome "${duplicate}"`); + toast.warning(`Já existe um atributo com o nome "${duplicate}"`); return false; } @@ -601,7 +601,7 @@ export const useNewProductInlineModel = ({ const saveInlineProductToDraft = async ( data: ProductCreateFormData, ): Promise => { - const product = await buildInlineProductData( + const product = buildInlineProductData( data, mergeAttributes(data), productImage, From d6dadb74a3eafad7dde5707b25f6882f014769e6 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Wed, 10 Jun 2026 15:31:33 -0300 Subject: [PATCH 13/32] refactor(stock-movements): extract payload builders from model --- .../create-stock-movement.model.test.ts | 2 +- .../create/create-stock-movement.model.ts | 153 +----------------- .../create/create-stock-movement.payload.ts | 144 +++++++++++++++++ 3 files changed, 153 insertions(+), 146 deletions(-) create mode 100644 app/(pages)/stock-movements/create/create-stock-movement.payload.ts 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 3b09a78..a138eb9 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 @@ -2,12 +2,12 @@ import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useCreateStockMovementModel } from "./create-stock-movement.model"; import { - buildMovementPayload, buildStockMovementProductSearchUrl, formatStockMovementProductLabel, mapStockMovementProductOptions, shouldShowStockMovementFooter, } from "./create-stock-movement.model"; +import { buildMovementPayload } from "./create-stock-movement.payload"; import type { CreateStockMovementSchema } from "./create-stock-movement.schema"; import type { StockMovementDraft, 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 c9de2af..95beb29 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.model.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.model.ts @@ -24,12 +24,16 @@ import { } from "../stock-movements.constants"; import { clearStockMovementDraft, - inlineProductImageToFile, isStockMovementDraftRecoveredFromPreviousRuntime, readStockMovementDraft, writeStockMovementDraft, } from "./create-stock-movement.storage"; import type { WritableStockMovementDraft } from "./create-stock-movement.storage"; +import { + buildMovementPayload, + resolveExistingProductBatchQuantity, + uploadInlineProductImages, +} from "./create-stock-movement.payload"; import { buildExistingProductBatchesUrl, buildExistingProductProfitSummary, @@ -48,147 +52,6 @@ import { } from "./stock-movement-draft-guards"; import { lookupStockMovementProductByBarcode } from "./stock-movement-product-lookup"; -const resolveExistingProductBatchQuantity = (value: string): number => { - const quantity = Number(value); - return Number.isFinite(quantity) && quantity > 0 ? quantity : 0; -}; - -const buildExistingProductItemPayload = ( - item: CreateStockMovementSchema["items"][number], -): ExistingProductMovementPayload => { - const payload: { - productId: string | undefined; - quantity: number; - manufacturedDate?: string; - expirationDate?: string; - costPrice?: number; - sellingPrice?: number; - } = { productId: item.productId, quantity: item.quantity }; - - const manufacturedDate = getOptionalText(item.manufacturedDate); - const expirationDate = getOptionalText(item.expirationDate); - if (manufacturedDate) payload.manufacturedDate = manufacturedDate; - if (expirationDate) payload.expirationDate = expirationDate; - if (item.costPrice !== undefined) payload.costPrice = item.costPrice; - if (item.sellingPrice !== undefined) payload.sellingPrice = item.sellingPrice; - return payload; -}; - -interface ExistingProductMovementPayload { - productId: string | undefined; - quantity: number; - manufacturedDate?: string; - expirationDate?: string; - costPrice?: number; - sellingPrice?: number; -} - -interface NewProductMovementPayload { - quantity: number; - newProduct: { - name: string; - description?: string; - barcode?: string; - categoryId?: string; - brandId?: string; - isKit?: boolean; - hasExpiration: boolean; - active?: boolean; - attributes?: Record; - }; - manufacturedDate?: string; - expirationDate?: string; - costPrice?: number; - sellingPrice?: number; - imageUploadId?: string; -} - -type MovementItemPayload = - | ExistingProductMovementPayload - | NewProductMovementPayload; - -interface StockMovementPayload { - type: NonNullable; - notes?: string; - items: MovementItemPayload[]; -} - -const buildMovementItemPayload = ( - item: CreateStockMovementSchema["items"][number], -): MovementItemPayload => { - if (!item.newProductData) return buildExistingProductItemPayload(item); - const newProduct = { - name: item.newProductData.name, - description: item.newProductData.description, - barcode: item.newProductData.barcode, - categoryId: item.newProductData.categoryId, - brandId: item.newProductData.brandId, - isKit: item.newProductData.isKit, - hasExpiration: Boolean(item.newProductData.expirationDate), - active: item.newProductData.active, - attributes: item.newProductData.attributes, - }; - return { - quantity: item.quantity, - newProduct, - manufacturedDate: getOptionalText(item.newProductData.manufacturedDate), - expirationDate: getOptionalText(item.newProductData.expirationDate), - costPrice: item.newProductData.costPrice, - sellingPrice: item.newProductData.sellingPrice, - }; -}; - -export const buildMovementPayload = ( - selectedMovementType: NonNullable, - data: CreateStockMovementSchema, -): StockMovementPayload => ({ - type: selectedMovementType, - notes: data.notes || undefined, - items: data.items.map(buildMovementItemPayload), -}); - -interface TemporaryProductImageUploadResponse { - success: boolean; - data: { - uploadId: string; - fileName: string; - contentType: string; - sizeBytes: number; - }; -} - -const uploadTemporaryInlineProductImage = async ( - image: NonNullable< - NonNullable["image"] - >, -): Promise => { - const formData = new FormData(); - formData.append("image", inlineProductImageToFile(image)); - const response = await api - .post("uploads/product-images/temp", { body: formData }) - .json(); - return response.data.uploadId; -}; - -const uploadInlineProductImages = async ( - payload: StockMovementPayload, - items: CreateStockMovementSchema["items"], -): Promise => { - const movementItems = [...payload.items]; - for (const [index, formItem] of items.entries()) { - if (!formItem.newProductData?.image) continue; - const itemPayload = movementItems[index]; - if (!("newProduct" in itemPayload)) continue; - movementItems[index] = { - ...itemPayload, - imageUploadId: await uploadTemporaryInlineProductImage( - formItem.newProductData.image, - ), - }; - } - return { ...payload, items: movementItems }; -}; - interface ProductListResponse { success: boolean; data: @@ -795,7 +658,7 @@ export function useCreateStockMovementModel({ setAddItemError(null); if (!selectedMovementType) { - toast.error("Selecione o tipo de movimentação antes de continuar."); + toast.warning("Selecione o tipo de movimentação antes de continuar."); router.replace("/stock-movements"); return; } @@ -885,7 +748,7 @@ export function useCreateStockMovementModel({ } if (hasExistingProductAlreadyAdded(product.id)) { - toast.error(`${product.name} já está na movimentação.`); + toast.warning(`${product.name} já está na movimentação.`); return; } @@ -947,7 +810,7 @@ export function useCreateStockMovementModel({ const onSubmit = async (data: CreateStockMovementSchema) => { if (!selectedMovementType) { - toast.error("Selecione o tipo de movimentação antes de continuar."); + toast.warning("Selecione o tipo de movimentação antes de continuar."); router.replace("/stock-movements"); return; } diff --git a/app/(pages)/stock-movements/create/create-stock-movement.payload.ts b/app/(pages)/stock-movements/create/create-stock-movement.payload.ts new file mode 100644 index 0000000..e54d2aa --- /dev/null +++ b/app/(pages)/stock-movements/create/create-stock-movement.payload.ts @@ -0,0 +1,144 @@ +import { api } from "@/lib/api"; +import type { CreateStockMovementSchema } from "./create-stock-movement.schema"; +import { getOptionalText } from "./stock-movement-batch-form-validation"; +import { inlineProductImageToFile } from "./create-stock-movement.storage"; +import type { InlineProductImageData } from "./create-stock-movement.types"; + +interface ExistingProductMovementPayload { + productId: string | undefined; + quantity: number; + manufacturedDate?: string; + expirationDate?: string; + costPrice?: number; + sellingPrice?: number; +} + +interface NewProductMovementPayload { + quantity: number; + newProduct: { + name: string; + description?: string; + barcode?: string; + categoryId?: string; + brandId?: string; + isKit?: boolean; + hasExpiration: boolean; + active?: boolean; + attributes?: Record; + }; + manufacturedDate?: string; + expirationDate?: string; + costPrice?: number; + sellingPrice?: number; + imageUploadId?: string; +} + +type MovementItemPayload = + | ExistingProductMovementPayload + | NewProductMovementPayload; + +interface StockMovementPayload { + type: NonNullable; + notes?: string; + items: MovementItemPayload[]; +} + +export const resolveExistingProductBatchQuantity = (value: string): number => { + const quantity = Number(value); + return Number.isFinite(quantity) && quantity > 0 ? quantity : 0; +}; + +const buildExistingProductItemPayload = ( + item: CreateStockMovementSchema["items"][number], +): ExistingProductMovementPayload => { + const payload: { + productId: string | undefined; + quantity: number; + manufacturedDate?: string; + expirationDate?: string; + costPrice?: number; + sellingPrice?: number; + } = { productId: item.productId, quantity: item.quantity }; + + const manufacturedDate = getOptionalText(item.manufacturedDate); + const expirationDate = getOptionalText(item.expirationDate); + if (manufacturedDate) payload.manufacturedDate = manufacturedDate; + if (expirationDate) payload.expirationDate = expirationDate; + if (item.costPrice !== undefined) payload.costPrice = item.costPrice; + if (item.sellingPrice !== undefined) payload.sellingPrice = item.sellingPrice; + return payload; +}; + +const buildMovementItemPayload = ( + item: CreateStockMovementSchema["items"][number], +): MovementItemPayload => { + if (!item.newProductData) return buildExistingProductItemPayload(item); + const newProduct = { + name: item.newProductData.name, + description: item.newProductData.description, + barcode: item.newProductData.barcode, + categoryId: item.newProductData.categoryId, + brandId: item.newProductData.brandId, + isKit: item.newProductData.isKit, + hasExpiration: Boolean(item.newProductData.expirationDate), + active: item.newProductData.active, + attributes: item.newProductData.attributes, + }; + return { + quantity: item.quantity, + newProduct, + manufacturedDate: getOptionalText(item.newProductData.manufacturedDate), + expirationDate: getOptionalText(item.newProductData.expirationDate), + costPrice: item.newProductData.costPrice, + sellingPrice: item.newProductData.sellingPrice, + }; +}; + +export const buildMovementPayload = ( + selectedMovementType: NonNullable, + data: CreateStockMovementSchema, +): StockMovementPayload => ({ + type: selectedMovementType, + notes: data.notes || undefined, + items: data.items.map(buildMovementItemPayload), +}); + +interface TemporaryProductImageUploadResponse { + success: boolean; + data: { + uploadId: string; + fileName: string; + contentType: string; + sizeBytes: number; + }; +} + +const uploadTemporaryInlineProductImage = async ( + image: InlineProductImageData, +): Promise => { + const formData = new FormData(); + formData.append("image", inlineProductImageToFile(image)); + const response = await api + .post("uploads/product-images/temp", { body: formData }) + .json(); + return response.data.uploadId; +}; + +export const uploadInlineProductImages = async ( + payload: StockMovementPayload, + items: CreateStockMovementSchema["items"], +): Promise => { + const movementItems = [...payload.items]; + for (const [index, formItem] of items.entries()) { + if (!formItem.newProductData?.image) continue; + const itemPayload = movementItems[index]; + if (!("newProduct" in itemPayload)) continue; + movementItems[index] = { + ...itemPayload, + imageUploadId: await uploadTemporaryInlineProductImage( + formItem.newProductData.image, + ), + }; + } + return { ...payload, items: movementItems }; +}; From 2fd4e6925a2c1180646db78d1f69af319f532e3c Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Wed, 10 Jun 2026 15:33:04 -0300 Subject: [PATCH 14/32] refactor(stock-movements): extract product options and footer helpers from model --- .../create-stock-movement.model.test.ts | 4 +- .../create/create-stock-movement.model.ts | 62 +++---------------- .../create/stock-movement-product-options.ts | 55 ++++++++++++++++ 3 files changed, 65 insertions(+), 56 deletions(-) create mode 100644 app/(pages)/stock-movements/create/stock-movement-product-options.ts 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 a138eb9..a1480c9 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 @@ -1,13 +1,13 @@ import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useCreateStockMovementModel } from "./create-stock-movement.model"; +import { buildMovementPayload } from "./create-stock-movement.payload"; import { buildStockMovementProductSearchUrl, formatStockMovementProductLabel, mapStockMovementProductOptions, shouldShowStockMovementFooter, -} from "./create-stock-movement.model"; -import { buildMovementPayload } from "./create-stock-movement.payload"; +} from "./stock-movement-product-options"; import type { CreateStockMovementSchema } from "./create-stock-movement.schema"; import type { StockMovementDraft, 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 95beb29..a259114 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.model.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.model.ts @@ -51,20 +51,19 @@ import { hasExistingProductInItems, } from "./stock-movement-draft-guards"; import { lookupStockMovementProductByBarcode } from "./stock-movement-product-lookup"; - -interface ProductListResponse { - success: boolean; - data: - | { content: StockMovementProductOption[] } - | StockMovementProductOption[]; -} +import { + buildStockMovementProductSearchUrl, + formatStockMovementProductLabel, + mapStockMovementProductOptions, + PRODUCT_SEARCH_LIMIT, + shouldShowStockMovementFooter, + type ProductListResponse, +} from "./stock-movement-product-options"; interface CreateStockMovementModelParams { typeParam?: string | null; } -const PRODUCT_SEARCH_LIMIT = 5; - const EMPTY_EXISTING_BATCH_FORM: ExistingProductBatchFormState = { isOpen: false, productId: "", @@ -76,51 +75,6 @@ const EMPTY_EXISTING_BATCH_FORM: ExistingProductBatchFormState = { error: null, }; -interface FooterVisibilityParams { - currentScrollY: number; - lastScrollY: number; - maxScrollY: number; -} - -export const shouldShowStockMovementFooter = ({ - currentScrollY, - lastScrollY, - maxScrollY, -}: FooterVisibilityParams): boolean => { - const isShortPage = maxScrollY <= 8; - const isAtPageEnd = currentScrollY >= maxScrollY - 8; - const isScrollingUp = currentScrollY < lastScrollY; - return isShortPage || isAtPageEnd || isScrollingUp; -}; - -export const formatStockMovementProductLabel = ( - product: StockMovementProductOption, -): string => (product.sku ? `${product.name} (${product.sku})` : product.name); - -export const mapStockMovementProductOptions = ( - response: ProductListResponse | null | undefined, -): StockMovementProductOption[] => { - const rawProducts = response?.data; - const productList = Array.isArray(rawProducts) - ? rawProducts - : rawProducts?.content || []; - return productList.map((product) => ({ - id: product.id, - name: product.name, - sku: product.sku, - barcode: product.barcode, - imageUrl: product.imageUrl, - })); -}; - -export const buildStockMovementProductSearchUrl = ( - query: string, -): string | null => { - const normalizedQuery = query.trim(); - if (normalizedQuery.length < 2) return null; - return `products/search?q=${encodeURIComponent(normalizedQuery)}`; -}; - export function useCreateStockMovementModel({ typeParam = null, }: CreateStockMovementModelParams = {}): CreateStockMovementViewProps { diff --git a/app/(pages)/stock-movements/create/stock-movement-product-options.ts b/app/(pages)/stock-movements/create/stock-movement-product-options.ts new file mode 100644 index 0000000..4cb4764 --- /dev/null +++ b/app/(pages)/stock-movements/create/stock-movement-product-options.ts @@ -0,0 +1,55 @@ +import type { StockMovementProductOption } from "./create-stock-movement.types"; + +export interface ProductListResponse { + success: boolean; + data: + | { content: StockMovementProductOption[] } + | StockMovementProductOption[]; +} + +export const PRODUCT_SEARCH_LIMIT = 5; + +interface FooterVisibilityParams { + currentScrollY: number; + lastScrollY: number; + maxScrollY: number; +} + +export const shouldShowStockMovementFooter = ({ + currentScrollY, + lastScrollY, + maxScrollY, +}: FooterVisibilityParams): boolean => { + const isShortPage = maxScrollY <= 8; + const isAtPageEnd = currentScrollY >= maxScrollY - 8; + const isScrollingUp = currentScrollY < lastScrollY; + return isShortPage || isAtPageEnd || isScrollingUp; +}; + +export const formatStockMovementProductLabel = ( + product: StockMovementProductOption, +): string => (product.sku ? `${product.name} (${product.sku})` : product.name); + +export const mapStockMovementProductOptions = ( + response: ProductListResponse | null | undefined, +): StockMovementProductOption[] => { + const rawProducts = response?.data; + const productList = Array.isArray(rawProducts) + ? rawProducts + : rawProducts?.content || []; + return productList.map((product) => ({ + id: product.id, + name: product.name, + sku: product.sku, + barcode: product.barcode, + imageUrl: product.imageUrl, + })); +}; + +export const buildStockMovementProductSearchUrl = ( + query: string, +): string | null => { + const normalizedQuery = query.trim(); + if (normalizedQuery.length < 2) return null; + return `products/search?q=${encodeURIComponent(normalizedQuery)}`; +}; From ad11fd754732ee5d6f62ab9b08f9783baf046823 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Wed, 10 Jun 2026 15:38:34 -0300 Subject: [PATCH 15/32] refactor(stock-movements): extract draft persistence hook from model --- .../create/create-stock-movement.model.ts | 144 +++------------ ...-stock-movement-draft-persistence.model.ts | 174 ++++++++++++++++++ 2 files changed, 196 insertions(+), 122 deletions(-) create mode 100644 app/(pages)/stock-movements/create/use-stock-movement-draft-persistence.model.ts 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 a259114..f2aa736 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.model.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.model.ts @@ -24,11 +24,7 @@ import { } from "../stock-movements.constants"; import { clearStockMovementDraft, - isStockMovementDraftRecoveredFromPreviousRuntime, - readStockMovementDraft, - writeStockMovementDraft, } from "./create-stock-movement.storage"; -import type { WritableStockMovementDraft } from "./create-stock-movement.storage"; import { buildMovementPayload, resolveExistingProductBatchQuantity, @@ -59,6 +55,7 @@ import { shouldShowStockMovementFooter, type ProductListResponse, } from "./stock-movement-product-options"; +import { useStockMovementDraftPersistence } from "./use-stock-movement-draft-persistence.model"; interface CreateStockMovementModelParams { typeParam?: string | null; @@ -100,9 +97,6 @@ export function useCreateStockMovementModel({ const lastScrollYRef = useRef(0); const productSearchBlurTimeoutRef = useRef | null>(null); - const inlineProductBarcodeRef = useRef(undefined); - const draftRevisionRef = useRef(0); - const persistQueueRef = useRef>(Promise.resolve()); const selectedMovementType = isManualMovementType(typeParam) ? typeParam : undefined; @@ -133,43 +127,27 @@ export function useCreateStockMovementModel({ name: "items", }); - const [isDraftHydrated, setIsDraftHydrated] = useState(false); - - useEffect(() => { - let isMounted = true; - - const hydrateDraft = async (): Promise => { - if (!selectedMovementType) { - setIsDraftHydrated(true); - return; - } - - const draft = await readStockMovementDraft(); - if (!isMounted) return; - - draftRevisionRef.current = draft?.revision ?? 0; - if (draft?.type === selectedMovementType && draft.warehouseId === warehouseId) { - form.reset({ - type: draft.type, - notes: draft.notes, - items: draft.items, - }); - setSelectedProductId(draft.selectedProductId); - setItemQuantity(draft.itemQuantity); - inlineProductBarcodeRef.current = draft.inlineProductBarcode; - if (isStockMovementDraftRecoveredFromPreviousRuntime(draft)) { - toast.success("Rascunho da movimentação restaurado."); - } - } - - setIsDraftHydrated(true); - }; + const handleDraftRestored = useCallback( + ({ selectedProductId: restoredId, itemQuantity: restoredQty }: { selectedProductId: string; itemQuantity: string }) => { + setSelectedProductId(restoredId); + setItemQuantity(restoredQty); + }, + [], + ); - void hydrateDraft(); - return () => { - isMounted = false; - }; - }, [form, selectedMovementType, warehouseId]); + const { + isDraftHydrated, + persistCurrentDraft, + inlineProductBarcodeRef, + resetDraftRevision, + } = useStockMovementDraftPersistence({ + form, + selectedMovementType, + warehouseId, + selectedProductId, + itemQuantity, + onDraftRestored: handleDraftRestored, + }); const { data: productsData, isLoading: isLoadingProducts } = useSWR("products", (url: string) => @@ -226,84 +204,6 @@ export function useCreateStockMovementModel({ setProductSearchQuery(formatStockMovementProductLabel(restoredProduct)); }, [productSearchQuery, products, selectedProductId]); - const buildCurrentDraft = useCallback( - (inlineProductBarcode = inlineProductBarcodeRef.current): WritableStockMovementDraft | null => { - if (!selectedMovementType) return null; - return { - type: selectedMovementType, - warehouseId, - notes: form.getValues("notes") || "", - items: form.getValues("items"), - selectedProductId, - itemQuantity, - inlineProductBarcode, - }; - }, - [form, itemQuantity, selectedMovementType, selectedProductId, warehouseId], - ); - - const persistCurrentDraft = useCallback( - (inlineProductBarcode = inlineProductBarcodeRef.current): Promise => { - const draft = buildCurrentDraft(inlineProductBarcode); - if (!draft) return Promise.resolve(); - - const queuedWrite = persistQueueRef.current.then(async () => { - const writeResult = await writeStockMovementDraft( - draft, - draftRevisionRef.current, - ); - draftRevisionRef.current = writeResult.revision; - }); - persistQueueRef.current = queuedWrite.catch(() => undefined); - return queuedWrite; - }, - [buildCurrentDraft], - ); - - useEffect(() => { - if (!isDraftHydrated || !selectedMovementType) return; - void persistCurrentDraft(); - }, [ - isDraftHydrated, - itemQuantity, - persistCurrentDraft, - selectedMovementType, - selectedProductId, - ]); - - useEffect(() => { - if (!isDraftHydrated || !selectedMovementType) return; - - const subscription = form.watch((_value, { name }) => { - if (!name || (name !== "notes" && !name.startsWith("items"))) return; - void persistCurrentDraft(); - }); - - return () => subscription.unsubscribe(); - }, [form, isDraftHydrated, persistCurrentDraft, selectedMovementType]); - - useEffect(() => { - if (!isDraftHydrated || !selectedMovementType) return; - - const persistBeforePageHide = (): void => { - void persistCurrentDraft(); - }; - const persistBeforeVisibilityLoss = (): void => { - if (document.visibilityState !== "hidden") return; - void persistCurrentDraft(); - }; - - window.addEventListener("pagehide", persistBeforePageHide); - document.addEventListener("visibilitychange", persistBeforeVisibilityLoss); - return () => { - window.removeEventListener("pagehide", persistBeforePageHide); - document.removeEventListener( - "visibilitychange", - persistBeforeVisibilityLoss, - ); - }; - }, [isDraftHydrated, persistCurrentDraft, selectedMovementType]); - useEffect(() => { const timeoutId = setTimeout(() => { setDebouncedProductSearchQuery(productSearchQuery); @@ -782,7 +682,7 @@ export function useCreateStockMovementModel({ setSubmittingStep("Finalizando…"); await clearStockMovementDraft(); - draftRevisionRef.current = 0; + resetDraftRevision(); toast.success("Movimentação criada com sucesso!"); router.push("/stock-movements"); } catch (err: unknown) { diff --git a/app/(pages)/stock-movements/create/use-stock-movement-draft-persistence.model.ts b/app/(pages)/stock-movements/create/use-stock-movement-draft-persistence.model.ts new file mode 100644 index 0000000..c1970e4 --- /dev/null +++ b/app/(pages)/stock-movements/create/use-stock-movement-draft-persistence.model.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { toast } from "sonner"; + +import type { CreateStockMovementSchema } from "./create-stock-movement.schema"; +import type { ManualMovementType } from "../stock-movements.constants"; +import { + isStockMovementDraftRecoveredFromPreviousRuntime, + readStockMovementDraft, + writeStockMovementDraft, +} from "./create-stock-movement.storage"; +import type { WritableStockMovementDraft } from "./create-stock-movement.storage"; + +interface StockMovementDraftRestoredData { + selectedProductId: string; + itemQuantity: string; +} + +interface StockMovementDraftPersistenceParams { + form: UseFormReturn; + selectedMovementType: ManualMovementType | undefined; + warehouseId: string | null; + selectedProductId: string; + itemQuantity: string; + onDraftRestored: (data: StockMovementDraftRestoredData) => void; +} + +interface StockMovementDraftPersistence { + isDraftHydrated: boolean; + persistCurrentDraft: (inlineProductBarcode?: string) => Promise; + inlineProductBarcodeRef: React.MutableRefObject; + resetDraftRevision: () => void; +} + +export function useStockMovementDraftPersistence({ + form, + selectedMovementType, + warehouseId, + selectedProductId, + itemQuantity, + onDraftRestored, +}: StockMovementDraftPersistenceParams): StockMovementDraftPersistence { + const [isDraftHydrated, setIsDraftHydrated] = useState(false); + const inlineProductBarcodeRef = useRef(undefined); + const draftRevisionRef = useRef(0); + const persistQueueRef = useRef>(Promise.resolve()); + + useEffect(() => { + let isMounted = true; + + const hydrateDraft = async (): Promise => { + if (!selectedMovementType) { + setIsDraftHydrated(true); + return; + } + + const draft = await readStockMovementDraft(); + if (!isMounted) return; + + draftRevisionRef.current = draft?.revision ?? 0; + if (draft?.type === selectedMovementType && draft.warehouseId === warehouseId) { + form.reset({ + type: draft.type, + notes: draft.notes, + items: draft.items, + }); + onDraftRestored({ + selectedProductId: draft.selectedProductId, + itemQuantity: draft.itemQuantity, + }); + inlineProductBarcodeRef.current = draft.inlineProductBarcode; + if (isStockMovementDraftRecoveredFromPreviousRuntime(draft)) { + toast.success("Rascunho da movimentação restaurado."); + } + } + + setIsDraftHydrated(true); + }; + + void hydrateDraft(); + return () => { + isMounted = false; + }; + }, [form, selectedMovementType, warehouseId, onDraftRestored]); + + const buildCurrentDraft = useCallback( + (inlineProductBarcode = inlineProductBarcodeRef.current): WritableStockMovementDraft | null => { + if (!selectedMovementType) return null; + return { + type: selectedMovementType, + warehouseId, + notes: form.getValues("notes") || "", + items: form.getValues("items"), + selectedProductId, + itemQuantity, + inlineProductBarcode, + }; + }, + [form, itemQuantity, selectedMovementType, selectedProductId, warehouseId], + ); + + const persistCurrentDraft = useCallback( + (inlineProductBarcode = inlineProductBarcodeRef.current): Promise => { + const draft = buildCurrentDraft(inlineProductBarcode); + if (!draft) return Promise.resolve(); + + const queuedWrite = persistQueueRef.current.then(async () => { + const writeResult = await writeStockMovementDraft( + draft, + draftRevisionRef.current, + ); + draftRevisionRef.current = writeResult.revision; + }); + persistQueueRef.current = queuedWrite.catch(() => undefined); + return queuedWrite; + }, + [buildCurrentDraft], + ); + + useEffect(() => { + if (!isDraftHydrated || !selectedMovementType) return; + void persistCurrentDraft(); + }, [ + isDraftHydrated, + itemQuantity, + persistCurrentDraft, + selectedMovementType, + selectedProductId, + ]); + + useEffect(() => { + if (!isDraftHydrated || !selectedMovementType) return; + + const subscription = form.watch((_value, { name }) => { + if (!name || (name !== "notes" && !name.startsWith("items"))) return; + void persistCurrentDraft(); + }); + + return () => subscription.unsubscribe(); + }, [form, isDraftHydrated, persistCurrentDraft, selectedMovementType]); + + useEffect(() => { + if (!isDraftHydrated || !selectedMovementType) return; + + const persistBeforePageHide = (): void => { + void persistCurrentDraft(); + }; + const persistBeforeVisibilityLoss = (): void => { + if (document.visibilityState !== "hidden") return; + void persistCurrentDraft(); + }; + + window.addEventListener("pagehide", persistBeforePageHide); + document.addEventListener("visibilitychange", persistBeforeVisibilityLoss); + return () => { + window.removeEventListener("pagehide", persistBeforePageHide); + document.removeEventListener( + "visibilitychange", + persistBeforeVisibilityLoss, + ); + }; + }, [isDraftHydrated, persistCurrentDraft, selectedMovementType]); + + const resetDraftRevision = useCallback((): void => { + draftRevisionRef.current = 0; + }, []); + + return { + isDraftHydrated, + persistCurrentDraft, + inlineProductBarcodeRef, + resetDraftRevision, + }; +} From 949c74a7e05cf592bd90f1c2da170d8214a00ba0 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Wed, 10 Jun 2026 15:55:03 -0300 Subject: [PATCH 16/32] refactor(stock-movements): extract batch form and scanner hooks from model --- .../create/create-stock-movement.model.ts | 427 ++++-------------- .../use-existing-product-batch-form.model.ts | 219 +++++++++ .../use-stock-movement-scanner.model.ts | 246 ++++++++++ 3 files changed, 549 insertions(+), 343 deletions(-) create mode 100644 app/(pages)/stock-movements/create/use-existing-product-batch-form.model.ts create mode 100644 app/(pages)/stock-movements/create/use-stock-movement-scanner.model.ts 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 f2aa736..71faf73 100644 --- a/app/(pages)/stock-movements/create/create-stock-movement.model.ts +++ b/app/(pages)/stock-movements/create/create-stock-movement.model.ts @@ -12,7 +12,6 @@ import { } from "./create-stock-movement.schema"; import { CreateStockMovementViewProps, - ExistingProductBatchFormState, StockMovementProductBatchesResponse, StockMovementProductOption, } from "./create-stock-movement.types"; @@ -20,14 +19,12 @@ import { useBreadcrumb } from "@/components/breadcrumb"; import { useSelectedWarehouse } from "@/hooks/use-selected-warehouse"; import { isManualMovementType, - MANUAL_IN_MOVEMENT_TYPES, } from "../stock-movements.constants"; import { clearStockMovementDraft, } from "./create-stock-movement.storage"; import { buildMovementPayload, - resolveExistingProductBatchQuantity, uploadInlineProductImages, } from "./create-stock-movement.payload"; import { @@ -38,15 +35,9 @@ import { findMostRecentWarehouseProductBatch, } from "./stock-movement-batch-pricing.model"; import { - getOptionalText, - validateExistingProductBatchForm, -} from "./stock-movement-batch-form-validation"; -import { - buildRepeatedProductBatchWarning, getPendingInlineProductBarcodeConflictError, hasExistingProductInItems, } from "./stock-movement-draft-guards"; -import { lookupStockMovementProductByBarcode } from "./stock-movement-product-lookup"; import { buildStockMovementProductSearchUrl, formatStockMovementProductLabel, @@ -56,22 +47,13 @@ import { type ProductListResponse, } from "./stock-movement-product-options"; import { useStockMovementDraftPersistence } from "./use-stock-movement-draft-persistence.model"; +import { useExistingProductBatchForm } from "./use-existing-product-batch-form.model"; +import { isSelectedInMovement, useStockMovementScanner } from "./use-stock-movement-scanner.model"; interface CreateStockMovementModelParams { typeParam?: string | null; } -const EMPTY_EXISTING_BATCH_FORM: ExistingProductBatchFormState = { - isOpen: false, - productId: "", - productName: "", - quantity: "", - manufacturedDate: "", - expirationDate: "", - editingIndex: null, - error: null, -}; - export function useCreateStockMovementModel({ typeParam = null, }: CreateStockMovementModelParams = {}): CreateStockMovementViewProps { @@ -88,12 +70,7 @@ export function useCreateStockMovementModel({ const [isProductOptionsOpen, setIsProductOptionsOpen] = useState(false); const [itemQuantity, setItemQuantity] = useState(""); const [addItemError, setAddItemError] = useState(null); - const [isScannerOpen, setIsScannerOpen] = useState(false); const [isFooterVisible, setIsFooterVisible] = useState(false); - const [missingProductBarcode, setMissingProductBarcode] = useState(null); - const [existingProductBatchForm, setExistingProductBatchForm] = - useState(EMPTY_EXISTING_BATCH_FORM); - const lastScannedBarcodeRef = useRef(null); const lastScrollYRef = useRef(0); const productSearchBlurTimeoutRef = useRef | null>(null); @@ -136,7 +113,6 @@ export function useCreateStockMovementModel({ ); const { - isDraftHydrated, persistCurrentDraft, inlineProductBarcodeRef, resetDraftRevision, @@ -154,6 +130,61 @@ export function useCreateStockMovementModel({ api.get(url).json(), ); + const resetProductBuilder = (): void => { + setSelectedProductId(""); + setSelectedProduct(null); + setProductSearchQuery(""); + setItemQuantity(""); + }; + + const salePriceSuggestionRef = useRef<{ priceCents: number } | undefined>(undefined); + const costPriceSuggestionRef = useRef<{ priceCents: number } | undefined>(undefined); + + const { + existingProductBatchForm, + onExistingProductBatchOpenChange, + onExistingProductBatchQuantityChange, + onExistingProductBatchQuantityIncrement, + onExistingProductBatchQuantityDecrement, + onExistingProductBatchManufacturedDateChange, + onExistingProductBatchExpirationDateChange, + onExistingProductBatchCostPriceChange, + onExistingProductBatchSellingPriceChange, + onApplyExistingProductCostPriceSuggestion, + onApplyExistingProductSalePriceSuggestion, + onConfirmExistingProductBatchData, + openExistingProductBatchForm, + } = useExistingProductBatchForm({ + formItems: fields, + append, + update, + onItemConfirmed: resetProductBuilder, + salePriceSuggestionRef, + costPriceSuggestionRef, + }); + + const { + isScannerOpen, + setScannerOpen, + onBarcodeScan, + missingProductBarcode, + onMissingProductModalOpenChange, + onCreateProductFromMissingModal, + onCreateNewProduct, + onEditNewProductItem, + onEditExistingProductBatchData, + } = useStockMovementScanner({ + selectedMovementType, + router, + form, + append, + persistCurrentDraft, + inlineProductBarcodeRef, + itemQuantity, + openExistingProductBatchForm, + setAddItemError, + }); + const productBatchesUrl = buildExistingProductBatchesUrl( warehouseId, existingProductBatchForm.isOpen ? existingProductBatchForm.productId : null, @@ -184,6 +215,9 @@ export function useCreateStockMovementModel({ !existingProductCostPriceSuggestion, ); + salePriceSuggestionRef.current = existingProductSalePriceSuggestion ?? undefined; + costPriceSuggestionRef.current = existingProductCostPriceSuggestion ?? undefined; + const products = mapStockMovementProductOptions(productsData); const productSearchUrl = selectedProduct @@ -251,17 +285,11 @@ export function useCreateStockMovementModel({ productSearchBlurTimeoutRef.current = null; }; - const findScannedProductBarcodeConflict = ( - barcode: string | null | undefined, - ): string | null => { - return getPendingInlineProductBarcodeConflictError( - form.getValues("items"), - barcode, - ); - }; + const findBarcodeConflict = (barcode: string | null | undefined): string | null => + getPendingInlineProductBarcodeConflictError(form.getValues("items"), barcode); const handleProductSelect = (product: StockMovementProductOption) => { - const barcodeConflictError = findScannedProductBarcodeConflict(product.barcode); + const barcodeConflictError = findBarcodeConflict(product.barcode); if (barcodeConflictError) { setIsProductOptionsOpen(false); setAddItemError(barcodeConflictError); @@ -274,7 +302,7 @@ export function useCreateStockMovementModel({ setIsProductOptionsOpen(false); setAddItemError(null); - if (isSelectedInMovement()) { + if (isSelectedInMovement(selectedMovementType)) { openExistingProductBatchForm({ productId: product.id, productName: product.name, @@ -319,45 +347,6 @@ export function useCreateStockMovementModel({ setAddItemError(null); }; - const isSelectedInMovement = (): boolean => { - if (!selectedMovementType) return false; - return MANUAL_IN_MOVEMENT_TYPES.includes( - selectedMovementType as (typeof MANUAL_IN_MOVEMENT_TYPES)[number], - ); - }; - - const resetProductBuilder = (): void => { - setSelectedProductId(""); - setSelectedProduct(null); - setProductSearchQuery(""); - setItemQuantity(""); - }; - - const closeExistingProductBatchForm = (): void => { - setExistingProductBatchForm(EMPTY_EXISTING_BATCH_FORM); - }; - - const buildBatchRepeatedProductWarning = ( - params: Pick, - ): string | null => { - if (params.editingIndex !== null) return null; - if (!hasExistingProductInItems(form.getValues("items"), params.productId)) { - return null; - } - return buildRepeatedProductBatchWarning(params.productName); - }; - - const openExistingProductBatchForm = ( - params: Omit, - ): void => { - setExistingProductBatchForm({ - ...params, - isOpen: true, - error: null, - repeatedProductWarning: buildBatchRepeatedProductWarning(params), - }); - }; - const appendExistingProductItem = ( productId: string, productName: string, @@ -387,13 +376,13 @@ export function useCreateStockMovementModel({ selectedProduct || products.find((p) => p.id === selectedProductId); const productName = product?.name || "Produto"; - const barcodeConflictError = findScannedProductBarcodeConflict(product?.barcode); + const barcodeConflictError = findBarcodeConflict(product?.barcode); if (barcodeConflictError) { setAddItemError(barcodeConflictError); return; } - if (isSelectedInMovement()) { + if (isSelectedInMovement(selectedMovementType)) { openExistingProductBatchForm({ productId: selectedProductId, productName, @@ -423,245 +412,6 @@ export function useCreateStockMovementModel({ appendExistingProductItem(selectedProductId, productName, quantity); }; - const handleExistingProductBatchOpenChange = (open: boolean): void => { - if (open) { - setExistingProductBatchForm((current) => ({ ...current, isOpen: true })); - return; - } - closeExistingProductBatchForm(); - }; - - const updateExistingProductBatchForm = ( - patch: Partial, - ): void => { - setExistingProductBatchForm((current) => ({ - ...current, - ...patch, - error: patch.error ?? null, - })); - }; - - const updateExistingProductBatchQuantity = ( - calculateNextQuantity: (quantity: number) => number, - ): void => { - setExistingProductBatchForm((current) => { - const currentQuantity = resolveExistingProductBatchQuantity( - current.quantity, - ); - const nextQuantity = Math.max(calculateNextQuantity(currentQuantity), 0); - return { - ...current, - quantity: nextQuantity > 0 ? String(nextQuantity) : "", - error: null, - }; - }); - }; - - const handleExistingProductBatchQuantityIncrement = (): void => { - updateExistingProductBatchQuantity((quantity) => quantity + 1); - }; - - const handleExistingProductBatchQuantityDecrement = (): void => { - updateExistingProductBatchQuantity((quantity) => quantity - 1); - }; - - const handleApplyExistingProductSalePriceSuggestion = (): void => { - if (!existingProductSalePriceSuggestion) return; - updateExistingProductBatchForm({ - sellingPrice: existingProductSalePriceSuggestion.priceCents, - }); - }; - - const handleApplyExistingProductCostPriceSuggestion = (): void => { - if (!existingProductCostPriceSuggestion) return; - updateExistingProductBatchForm({ - costPrice: existingProductCostPriceSuggestion.priceCents, - }); - }; - - const buildExistingProductBatchItem = (): CreateStockMovementSchema["items"][number] => ({ - productId: existingProductBatchForm.productId, - productName: existingProductBatchForm.productName, - quantity: Number(existingProductBatchForm.quantity), - manufacturedDate: getOptionalText(existingProductBatchForm.manufacturedDate), - expirationDate: getOptionalText(existingProductBatchForm.expirationDate), - costPrice: existingProductBatchForm.costPrice, - sellingPrice: existingProductBatchForm.sellingPrice, - }); - - const handleConfirmExistingProductBatchData = (): void => { - const validationError = validateExistingProductBatchForm( - existingProductBatchForm, - ); - if (validationError) { - updateExistingProductBatchForm({ error: validationError }); - return; - } - - const item = buildExistingProductBatchItem(); - if (existingProductBatchForm.editingIndex !== null) { - update(existingProductBatchForm.editingIndex, item); - } else { - append(item); - resetProductBuilder(); - } - closeExistingProductBatchForm(); - }; - - const handleCreateNewProduct = async (): Promise => { - setAddItemError(null); - - if (!selectedMovementType) { - toast.warning("Selecione o tipo de movimentação antes de continuar."); - router.replace("/stock-movements"); - return; - } - - if ( - !MANUAL_IN_MOVEMENT_TYPES.includes( - selectedMovementType as (typeof MANUAL_IN_MOVEMENT_TYPES)[number], - ) - ) { - setAddItemError( - "Novos produtos só podem ser criados em movimentações de entrada.", - ); - return; - } - - inlineProductBarcodeRef.current = undefined; - await persistCurrentDraft(undefined); - router.push(`/stock-movements/create/new-product?type=${selectedMovementType}`); - }; - - const navigateToInlineProductWithBarcode = async ( - barcode: string, - ): Promise => { - if (!selectedMovementType) return; - - inlineProductBarcodeRef.current = barcode; - await persistCurrentDraft(barcode); - setIsScannerOpen(false); - router.push(`/stock-movements/create/new-product?type=${selectedMovementType}`); - }; - - const handleEditNewProductItem = async (index: number): Promise => { - if (!selectedMovementType) return; - - const item = form.getValues("items")[index]; - if (!item?.newProductData) return; - - inlineProductBarcodeRef.current = undefined; - await persistCurrentDraft(undefined); - router.push( - `/stock-movements/create/new-product?type=${selectedMovementType}&editItem=${index}`, - ); - }; - - const handleEditExistingProductBatchData = (index: number): void => { - if (!isSelectedInMovement()) return; - const item = form.getValues("items")[index]; - if (!item?.productId || item.newProductData) return; - - openExistingProductBatchForm({ - productId: item.productId, - productName: item.productName || "Produto", - quantity: String(item.quantity), - manufacturedDate: item.manufacturedDate || "", - expirationDate: item.expirationDate || "", - costPrice: item.costPrice, - sellingPrice: item.sellingPrice, - editingIndex: index, - }); - }; - - const resolveScannerQuantity = (): number => { - const qty = Number(itemQuantity); - return qty > 0 ? qty : 1; - }; - - const appendScannedProduct = (product: StockMovementProductOption) => { - const barcodeConflictError = findScannedProductBarcodeConflict(product.barcode); - if (barcodeConflictError) { - toast.error(barcodeConflictError); - return; - } - - if (isSelectedInMovement()) { - setIsScannerOpen(false); - openExistingProductBatchForm({ - productId: product.id, - productName: product.name, - quantity: "", - manufacturedDate: "", - expirationDate: "", - costPrice: undefined, - sellingPrice: undefined, - editingIndex: null, - }); - return; - } - - if (hasExistingProductAlreadyAdded(product.id)) { - toast.warning(`${product.name} já está na movimentação.`); - return; - } - - append({ - productId: product.id, - productName: product.name, - quantity: resolveScannerQuantity(), - }); - toast.success(`${product.name} foi adicionado.`); - }; - - const handleBarcodeScan = async (barcode: string) => { - if (lastScannedBarcodeRef.current === barcode) return; - lastScannedBarcodeRef.current = barcode; - window.setTimeout(() => { - lastScannedBarcodeRef.current = null; - }, 1500); - - const lookup = await lookupStockMovementProductByBarcode(barcode); - if (lookup.status === "found") { - appendScannedProduct(lookup.product); - return; - } - if (lookup.status === "not-found") { - showMissingProductToast(barcode); - return; - } - toast.error(lookup.message); - }; - - const showMissingProductToast = (barcode: string) => { - if (!selectedMovementType) { - toast.error(`Produto com código ${barcode} não existe.`); - return; - } - - const canCreateInline = MANUAL_IN_MOVEMENT_TYPES.includes( - selectedMovementType as (typeof MANUAL_IN_MOVEMENT_TYPES)[number], - ); - if (!canCreateInline) { - toast.error(`Produto com código ${barcode} não existe.`); - return; - } - - setMissingProductBarcode(barcode); - }; - - const handleMissingProductModalOpenChange = (open: boolean): void => { - if (!open) { - setMissingProductBarcode(null); - } - }; - - const handleCreateProductFromMissingModal = async (): Promise => { - if (!missingProductBarcode) return; - await navigateToInlineProductWithBarcode(missingProductBarcode); - setMissingProductBarcode(null); - }; - const onSubmit = async (data: CreateStockMovementSchema) => { if (!selectedMovementType) { toast.warning("Selecione o tipo de movimentação antes de continuar."); @@ -717,33 +467,24 @@ export function useCreateStockMovementModel({ onProductClear: handleProductClear, onQuantityChange: setItemQuantity, onAddItem: handleAddItem, - onCreateNewProduct: handleCreateNewProduct, - onEditNewProductItem: handleEditNewProductItem, - onEditExistingProductBatchData: handleEditExistingProductBatchData, - onScannerOpenChange: setIsScannerOpen, - onBarcodeScan: handleBarcodeScan, + onCreateNewProduct, + onEditNewProductItem, + onEditExistingProductBatchData, + onScannerOpenChange: setScannerOpen, + onBarcodeScan, onRemoveItem: remove, existingProductBatchForm, - onExistingProductBatchOpenChange: handleExistingProductBatchOpenChange, - onExistingProductBatchQuantityChange: (quantity: string) => - updateExistingProductBatchForm({ quantity }), - onExistingProductBatchQuantityIncrement: - handleExistingProductBatchQuantityIncrement, - onExistingProductBatchQuantityDecrement: - handleExistingProductBatchQuantityDecrement, - onExistingProductBatchManufacturedDateChange: (manufacturedDate: string) => - updateExistingProductBatchForm({ manufacturedDate }), - onExistingProductBatchExpirationDateChange: (expirationDate: string) => - updateExistingProductBatchForm({ expirationDate }), - onExistingProductBatchCostPriceChange: (costPrice?: number) => - updateExistingProductBatchForm({ costPrice }), - onExistingProductBatchSellingPriceChange: (sellingPrice?: number) => - updateExistingProductBatchForm({ sellingPrice }), - onApplyExistingProductCostPriceSuggestion: - handleApplyExistingProductCostPriceSuggestion, - onApplyExistingProductSalePriceSuggestion: - handleApplyExistingProductSalePriceSuggestion, - onConfirmExistingProductBatchData: handleConfirmExistingProductBatchData, + onExistingProductBatchOpenChange, + onExistingProductBatchQuantityChange, + onExistingProductBatchQuantityIncrement, + onExistingProductBatchQuantityDecrement, + onExistingProductBatchManufacturedDateChange, + onExistingProductBatchExpirationDateChange, + onExistingProductBatchCostPriceChange, + onExistingProductBatchSellingPriceChange, + onApplyExistingProductCostPriceSuggestion, + onApplyExistingProductSalePriceSuggestion, + onConfirmExistingProductBatchData, existingProductCostPriceSuggestion, existingProductSalePriceSuggestion, isExistingProductPriceSuggestionLoading: isLoadingProductBatches, @@ -752,7 +493,7 @@ export function useCreateStockMovementModel({ existingProductProfitSummary, items: fields, missingProductBarcode, - onMissingProductModalOpenChange: handleMissingProductModalOpenChange, - onCreateProductFromMissingModal: handleCreateProductFromMissingModal, + onMissingProductModalOpenChange, + onCreateProductFromMissingModal, }; } diff --git a/app/(pages)/stock-movements/create/use-existing-product-batch-form.model.ts b/app/(pages)/stock-movements/create/use-existing-product-batch-form.model.ts new file mode 100644 index 0000000..425919d --- /dev/null +++ b/app/(pages)/stock-movements/create/use-existing-product-batch-form.model.ts @@ -0,0 +1,219 @@ +import { useCallback, useState } from "react"; +import type { MutableRefObject } from "react"; + +import type { + CreateStockMovementSchema, +} from "./create-stock-movement.schema"; +import type { + ExistingProductBatchFormState, +} from "./create-stock-movement.types"; +import { resolveExistingProductBatchQuantity } from "./create-stock-movement.payload"; +import { getOptionalText, validateExistingProductBatchForm } from "./stock-movement-batch-form-validation"; +import { + buildRepeatedProductBatchWarning, + hasExistingProductInItems, +} from "./stock-movement-draft-guards"; + +const EMPTY_EXISTING_BATCH_FORM: ExistingProductBatchFormState = { + isOpen: false, + productId: "", + productName: "", + quantity: "", + manufacturedDate: "", + expirationDate: "", + editingIndex: null, + error: null, +}; + +interface PriceSuggestion { + priceCents: number; +} + +interface UseExistingProductBatchFormParams { + formItems: CreateStockMovementSchema["items"]; + append: (item: CreateStockMovementSchema["items"][number]) => void; + update: (index: number, item: CreateStockMovementSchema["items"][number]) => void; + onItemConfirmed: () => void; + salePriceSuggestionRef: MutableRefObject; + costPriceSuggestionRef: MutableRefObject; +} + +interface ExistingProductBatchFormReturn { + existingProductBatchForm: ExistingProductBatchFormState; + onExistingProductBatchOpenChange: (open: boolean) => void; + onExistingProductBatchQuantityChange: (quantity: string) => void; + onExistingProductBatchQuantityIncrement: () => void; + onExistingProductBatchQuantityDecrement: () => void; + onExistingProductBatchManufacturedDateChange: (manufacturedDate: string) => void; + onExistingProductBatchExpirationDateChange: (expirationDate: string) => void; + onExistingProductBatchCostPriceChange: (costPrice?: number) => void; + onExistingProductBatchSellingPriceChange: (sellingPrice?: number) => void; + onApplyExistingProductCostPriceSuggestion: () => void; + onApplyExistingProductSalePriceSuggestion: () => void; + onConfirmExistingProductBatchData: () => void; + openExistingProductBatchForm: (params: Omit) => void; + closeExistingProductBatchForm: () => void; +} + +export function useExistingProductBatchForm({ + formItems, + append, + update, + onItemConfirmed, + salePriceSuggestionRef, + costPriceSuggestionRef, +}: UseExistingProductBatchFormParams): ExistingProductBatchFormReturn { + const [existingProductBatchForm, setExistingProductBatchForm] = + useState(EMPTY_EXISTING_BATCH_FORM); + + const buildBatchRepeatedProductWarning = useCallback( + ( + params: Pick, + ): string | null => { + if (params.editingIndex !== null) return null; + if (!hasExistingProductInItems(formItems, params.productId)) { + return null; + } + return buildRepeatedProductBatchWarning(params.productName); + }, + [formItems], + ); + + const closeExistingProductBatchForm = useCallback((): void => { + setExistingProductBatchForm(EMPTY_EXISTING_BATCH_FORM); + }, []); + + const openExistingProductBatchForm = useCallback( + (params: Omit): void => { + setExistingProductBatchForm({ + ...params, + isOpen: true, + error: null, + repeatedProductWarning: buildBatchRepeatedProductWarning(params), + }); + }, + [buildBatchRepeatedProductWarning], + ); + + const updateExistingProductBatchForm = useCallback( + (patch: Partial): void => { + setExistingProductBatchForm((current) => ({ + ...current, + ...patch, + error: patch.error ?? null, + })); + }, + [], + ); + + const updateExistingProductBatchQuantity = useCallback( + (calculateNextQuantity: (quantity: number) => number): void => { + setExistingProductBatchForm((current) => { + const currentQuantity = resolveExistingProductBatchQuantity( + current.quantity, + ); + const nextQuantity = Math.max(calculateNextQuantity(currentQuantity), 0); + return { + ...current, + quantity: nextQuantity > 0 ? String(nextQuantity) : "", + error: null, + }; + }); + }, + [], + ); + + const handleExistingProductBatchQuantityIncrement = useCallback((): void => { + updateExistingProductBatchQuantity((quantity) => quantity + 1); + }, [updateExistingProductBatchQuantity]); + + const handleExistingProductBatchQuantityDecrement = useCallback((): void => { + updateExistingProductBatchQuantity((quantity) => quantity - 1); + }, [updateExistingProductBatchQuantity]); + + const handleApplyExistingProductSalePriceSuggestion = useCallback((): void => { + const suggestion = salePriceSuggestionRef.current; + if (!suggestion) return; + updateExistingProductBatchForm({ + sellingPrice: suggestion.priceCents, + }); + }, [salePriceSuggestionRef, updateExistingProductBatchForm]); + + const handleApplyExistingProductCostPriceSuggestion = useCallback((): void => { + const suggestion = costPriceSuggestionRef.current; + if (!suggestion) return; + updateExistingProductBatchForm({ + costPrice: suggestion.priceCents, + }); + }, [costPriceSuggestionRef, updateExistingProductBatchForm]); + + const buildExistingProductBatchItem = useCallback( + (): CreateStockMovementSchema["items"][number] => ({ + productId: existingProductBatchForm.productId, + productName: existingProductBatchForm.productName, + quantity: Number(existingProductBatchForm.quantity), + manufacturedDate: getOptionalText(existingProductBatchForm.manufacturedDate), + expirationDate: getOptionalText(existingProductBatchForm.expirationDate), + costPrice: existingProductBatchForm.costPrice, + sellingPrice: existingProductBatchForm.sellingPrice, + }), + [existingProductBatchForm], + ); + + const handleConfirmExistingProductBatchData = useCallback((): void => { + const validationError = validateExistingProductBatchForm( + existingProductBatchForm, + ); + if (validationError) { + updateExistingProductBatchForm({ error: validationError }); + return; + } + + const item = buildExistingProductBatchItem(); + if (existingProductBatchForm.editingIndex !== null) { + update(existingProductBatchForm.editingIndex, item); + } else { + append(item); + onItemConfirmed(); + } + closeExistingProductBatchForm(); + }, [ + existingProductBatchForm, + updateExistingProductBatchForm, + buildExistingProductBatchItem, + update, + append, + onItemConfirmed, + closeExistingProductBatchForm, + ]); + + const handleExistingProductBatchOpenChange = useCallback((open: boolean): void => { + if (open) { + setExistingProductBatchForm((current) => ({ ...current, isOpen: true })); + return; + } + closeExistingProductBatchForm(); + }, [closeExistingProductBatchForm]); + + return { + existingProductBatchForm, + onExistingProductBatchOpenChange: handleExistingProductBatchOpenChange, + onExistingProductBatchQuantityChange: (quantity: string) => + updateExistingProductBatchForm({ quantity }), + onExistingProductBatchQuantityIncrement: handleExistingProductBatchQuantityIncrement, + onExistingProductBatchQuantityDecrement: handleExistingProductBatchQuantityDecrement, + onExistingProductBatchManufacturedDateChange: (manufacturedDate: string) => + updateExistingProductBatchForm({ manufacturedDate }), + onExistingProductBatchExpirationDateChange: (expirationDate: string) => + updateExistingProductBatchForm({ expirationDate }), + onExistingProductBatchCostPriceChange: (costPrice?: number) => + updateExistingProductBatchForm({ costPrice }), + onExistingProductBatchSellingPriceChange: (sellingPrice?: number) => + updateExistingProductBatchForm({ sellingPrice }), + onApplyExistingProductCostPriceSuggestion: handleApplyExistingProductCostPriceSuggestion, + onApplyExistingProductSalePriceSuggestion: handleApplyExistingProductSalePriceSuggestion, + onConfirmExistingProductBatchData: handleConfirmExistingProductBatchData, + openExistingProductBatchForm, + closeExistingProductBatchForm, + }; +} diff --git a/app/(pages)/stock-movements/create/use-stock-movement-scanner.model.ts b/app/(pages)/stock-movements/create/use-stock-movement-scanner.model.ts new file mode 100644 index 0000000..1da022f --- /dev/null +++ b/app/(pages)/stock-movements/create/use-stock-movement-scanner.model.ts @@ -0,0 +1,246 @@ +import { useRef, useState } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { toast } from "sonner"; +import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; + +import type { CreateStockMovementSchema } from "./create-stock-movement.schema"; +import type { StockMovementProductOption } from "./create-stock-movement.types"; +import type { ManualMovementType } from "../stock-movements.constants"; +import { MANUAL_IN_MOVEMENT_TYPES } from "../stock-movements.constants"; +import { getPendingInlineProductBarcodeConflictError, hasExistingProductInItems } from "./stock-movement-draft-guards"; +import { lookupStockMovementProductByBarcode } from "./stock-movement-product-lookup"; + +type OpenBatchFormFn = (params: { + productId: string; + productName: string; + quantity: string; + manufacturedDate: string; + expirationDate: string; + costPrice: number | undefined; + sellingPrice: number | undefined; + editingIndex: number | null; +}) => void; + +interface UseStockMovementScannerParams { + selectedMovementType: ManualMovementType | undefined; + router: AppRouterInstance; + form: UseFormReturn; + append: (item: CreateStockMovementSchema["items"][number]) => void; + persistCurrentDraft: (inlineProductBarcode?: string) => Promise; + inlineProductBarcodeRef: React.MutableRefObject; + itemQuantity: string; + openExistingProductBatchForm: OpenBatchFormFn; + setAddItemError: (error: string | null) => void; +} + +interface StockMovementScannerReturn { + isScannerOpen: boolean; + setScannerOpen: (open: boolean) => void; + onBarcodeScan: (barcode: string) => Promise; + missingProductBarcode: string | null; + onMissingProductModalOpenChange: (open: boolean) => void; + onCreateProductFromMissingModal: () => Promise; + onCreateNewProduct: () => Promise; + onEditNewProductItem: (index: number) => Promise; + onEditExistingProductBatchData: (index: number) => void; + appendScannedProduct: (product: StockMovementProductOption) => void; +} + +export const isSelectedInMovement = (type: ManualMovementType | undefined): boolean => { + if (!type) return false; + return MANUAL_IN_MOVEMENT_TYPES.includes( + type as (typeof MANUAL_IN_MOVEMENT_TYPES)[number], + ); +}; + +export function useStockMovementScanner({ + selectedMovementType, + router, + form, + append, + persistCurrentDraft, + inlineProductBarcodeRef, + itemQuantity, + openExistingProductBatchForm, + setAddItemError, +}: UseStockMovementScannerParams): StockMovementScannerReturn { + const lastScannedBarcodeRef = useRef(null); + const [isScannerOpen, setIsScannerOpen] = useState(false); + const [missingProductBarcode, setMissingProductBarcode] = useState(null); + + const findScannedProductBarcodeConflict = ( + barcode: string | null | undefined, + ): string | null => { + return getPendingInlineProductBarcodeConflictError( + form.getValues("items"), + barcode, + ); + }; + + const resolveScannerQuantity = (): number => { + const qty = Number(itemQuantity); + return qty > 0 ? qty : 1; + }; + + const navigateToInlineProductWithBarcode = async ( + barcode: string, + ): Promise => { + if (!selectedMovementType) return; + + inlineProductBarcodeRef.current = barcode; + await persistCurrentDraft(barcode); + setIsScannerOpen(false); + router.push(`/stock-movements/create/new-product?type=${selectedMovementType}`); + }; + + const appendScannedProduct = (product: StockMovementProductOption) => { + const barcodeConflictError = findScannedProductBarcodeConflict(product.barcode); + if (barcodeConflictError) { + toast.error(barcodeConflictError); + return; + } + + if (isSelectedInMovement(selectedMovementType)) { + setIsScannerOpen(false); + openExistingProductBatchForm({ + productId: product.id, + productName: product.name, + quantity: "", + manufacturedDate: "", + expirationDate: "", + costPrice: undefined, + sellingPrice: undefined, + editingIndex: null, + }); + return; + } + + if (hasExistingProductInItems(form.getValues("items"), product.id)) { + toast.warning(`${product.name} já está na movimentação.`); + return; + } + + append({ + productId: product.id, + productName: product.name, + quantity: resolveScannerQuantity(), + }); + toast.success(`${product.name} foi adicionado.`); + }; + + const showMissingProductToast = (barcode: string) => { + if (!selectedMovementType) { + toast.error(`Produto com código ${barcode} não existe.`); + return; + } + + const canCreateInline = MANUAL_IN_MOVEMENT_TYPES.includes( + selectedMovementType as (typeof MANUAL_IN_MOVEMENT_TYPES)[number], + ); + if (!canCreateInline) { + toast.error(`Produto com código ${barcode} não existe.`); + return; + } + + setMissingProductBarcode(barcode); + }; + + const handleBarcodeScan = async (barcode: string) => { + if (lastScannedBarcodeRef.current === barcode) return; + lastScannedBarcodeRef.current = barcode; + window.setTimeout(() => { + lastScannedBarcodeRef.current = null; + }, 1500); + + const lookup = await lookupStockMovementProductByBarcode(barcode); + if (lookup.status === "found") { + appendScannedProduct(lookup.product); + return; + } + if (lookup.status === "not-found") { + showMissingProductToast(barcode); + return; + } + toast.error(lookup.message); + }; + + const handleMissingProductModalOpenChange = (open: boolean): void => { + if (!open) { + setMissingProductBarcode(null); + } + }; + + const handleCreateProductFromMissingModal = async (): Promise => { + if (!missingProductBarcode) return; + await navigateToInlineProductWithBarcode(missingProductBarcode); + setMissingProductBarcode(null); + }; + + const handleCreateNewProduct = async (): Promise => { + setAddItemError(null); + + if (!selectedMovementType) { + toast.warning("Selecione o tipo de movimentação antes de continuar."); + router.replace("/stock-movements"); + return; + } + + if ( + !MANUAL_IN_MOVEMENT_TYPES.includes( + selectedMovementType as (typeof MANUAL_IN_MOVEMENT_TYPES)[number], + ) + ) { + setAddItemError( + "Novos produtos só podem ser criados em movimentações de entrada.", + ); + return; + } + + inlineProductBarcodeRef.current = undefined; + await persistCurrentDraft(undefined); + router.push(`/stock-movements/create/new-product?type=${selectedMovementType}`); + }; + + const handleEditNewProductItem = async (index: number): Promise => { + if (!selectedMovementType) return; + + const item = form.getValues("items")[index]; + if (!item?.newProductData) return; + + inlineProductBarcodeRef.current = undefined; + await persistCurrentDraft(undefined); + router.push( + `/stock-movements/create/new-product?type=${selectedMovementType}&editItem=${index}`, + ); + }; + + const handleEditExistingProductBatchData = (index: number): void => { + if (!isSelectedInMovement(selectedMovementType)) return; + const item = form.getValues("items")[index]; + if (!item?.productId || item.newProductData) return; + + openExistingProductBatchForm({ + productId: item.productId, + productName: item.productName || "Produto", + quantity: String(item.quantity), + manufacturedDate: item.manufacturedDate || "", + expirationDate: item.expirationDate || "", + costPrice: item.costPrice, + sellingPrice: item.sellingPrice, + editingIndex: index, + }); + }; + + return { + isScannerOpen, + setScannerOpen: setIsScannerOpen, + onBarcodeScan: handleBarcodeScan, + missingProductBarcode, + onMissingProductModalOpenChange: handleMissingProductModalOpenChange, + onCreateProductFromMissingModal: handleCreateProductFromMissingModal, + onCreateNewProduct: handleCreateNewProduct, + onEditNewProductItem: handleEditNewProductItem, + onEditExistingProductBatchData: handleEditExistingProductBatchData, + appendScannedProduct, + }; +} From ce4fe4de0391561212c4f98df49a5e4d60e694a9 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Thu, 11 Jun 2026 16:24:35 -0300 Subject: [PATCH 17/32] fix(stock-movement): recognize inline product barcode in scanner When scanning a barcode that returns 404 from the product lookup API, check if the barcode already exists in a pending inline product within the movement draft before opening the 'missing product' modal. Previously the scanner only queried the remote API and ignored locally created inline products, causing a false 'product not found' dialog even when the product had just been created inline. --- .../create-stock-movement.model.test.ts | 29 +++++++++++++++++++ .../use-stock-movement-scanner.model.ts | 13 +++++++++ 2 files changed, 42 insertions(+) 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 a1480c9..c609900 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 @@ -1170,6 +1170,35 @@ describe("useCreateStockMovementModel", () => { expect(fakeToast.error).not.toHaveBeenCalled(); }); + it("não abre modal de produto não encontrado quando barcode pertence a produto inline pendente", async () => { + const { result } = renderHook(() => + useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") }), + ); + + act(() => { + result.current.form.setValue("items", [ + { + quantity: 1, + productName: "Produto Inline", + newProductData: { + name: "Produto Inline", + barcode: "7891009999999", + }, + }, + ]); + }); + + await act(async () => { + await result.current.onBarcodeScan("7891009999999"); + }); + + expect(result.current.missingProductBarcode).toBeNull(); + expect(fakeToast.warning).toHaveBeenCalledWith( + "Produto Inline já está na movimentação como produto novo.", + ); + expect(fakeToast.error).not.toHaveBeenCalled(); + }); + it("mostra aviso sem ação para tipo de saída", async () => { fakeSearchParams.setType("USAGE"); const { result } = renderHook(() => useCreateStockMovementModel({ typeParam: fakeSearchParams.get("type") })); diff --git a/app/(pages)/stock-movements/create/use-stock-movement-scanner.model.ts b/app/(pages)/stock-movements/create/use-stock-movement-scanner.model.ts index 1da022f..89abb1c 100644 --- a/app/(pages)/stock-movements/create/use-stock-movement-scanner.model.ts +++ b/app/(pages)/stock-movements/create/use-stock-movement-scanner.model.ts @@ -145,6 +145,18 @@ export function useStockMovementScanner({ setMissingProductBarcode(barcode); }; + const showPendingInlineProductToast = (barcode: string): boolean => { + const inlineItem = form.getValues("items").find((item) => { + return item.newProductData?.barcode === barcode; + }); + if (!inlineItem) return false; + + const productName = + inlineItem.productName || inlineItem.newProductData?.name || "Produto"; + toast.warning(`${productName} já está na movimentação como produto novo.`); + return true; + }; + const handleBarcodeScan = async (barcode: string) => { if (lastScannedBarcodeRef.current === barcode) return; lastScannedBarcodeRef.current = barcode; @@ -158,6 +170,7 @@ export function useStockMovementScanner({ return; } if (lookup.status === "not-found") { + if (showPendingInlineProductToast(barcode)) return; showMissingProductToast(barcode); return; } From 9006ec795ede3b6f6bdf7dcbce720422ce995a4b Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Thu, 11 Jun 2026 16:25:31 -0300 Subject: [PATCH 18/32] fix(i18n): translate toast messages and use correct severity levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate English terms to pt-BR across all modules: - 'batch' -> 'lote' in success/error messages - 'warehouse/armazém' -> 'estoque' in user-facing messages - 'Warehouse created/deleted' -> 'Estoque criado/deletado' Use toast.warning instead of toast.error for non-blocking validation: - missing warehouse selection - incomplete custom attributes - image still processing - empty cart on checkout - unsupported browser share --- .../batches/[id]/edit/batches-edit.model.test.ts | 8 ++++---- .../batches/[id]/edit/batches-edit.model.ts | 4 ++-- .../batches/create/batches-create.model.test.ts | 9 +++++---- .../batches/create/batches-create.model.ts | 6 +++--- .../[id]/edit/products-edit.model.test.ts | 10 +++++----- .../products/[id]/edit/products-edit.model.ts | 12 ++++++------ .../[id]/prompts/product-prompts.model.ts | 4 ++-- .../create/products-create.model.test.ts | 9 +++++---- .../products/create/products-create.model.ts | 8 ++++---- app/(pages)/products/products.model.ts | 2 +- app/(pages)/sales/pdv/pdv.model.test.ts | 6 ++++-- app/(pages)/sales/pdv/pdv.model.ts | 4 ++-- .../transfers/new/new-transfer.model.test.ts | 3 ++- app/(pages)/transfers/new/new-transfer.model.ts | 2 +- app/warehouses/warehouses.model.test.ts | 12 ++++++------ app/warehouses/warehouses.model.ts | 16 ++++++++-------- 16 files changed, 60 insertions(+), 55 deletions(-) diff --git a/app/(pages)/batches/[id]/edit/batches-edit.model.test.ts b/app/(pages)/batches/[id]/edit/batches-edit.model.test.ts index 1bf74d3..d71b130 100644 --- a/app/(pages)/batches/[id]/edit/batches-edit.model.test.ts +++ b/app/(pages)/batches/[id]/edit/batches-edit.model.test.ts @@ -242,7 +242,7 @@ describe("useBatchEditModel", () => { expect(fakeApi.put).toHaveBeenCalledWith("batches/batch-1", { json: values, }); - expect(fakeToast.success).toHaveBeenCalledWith("Batch atualizado"); + expect(fakeToast.success).toHaveBeenCalledWith("Lote atualizado"); expect(fakeRouter.push).toHaveBeenCalledWith("/batches/batch-1"); }); @@ -258,11 +258,11 @@ describe("useBatchEditModel", () => { await result.current.onSubmit(baseFormValues); }); - expect(fakeToast.error).toHaveBeenCalledWith("Erro ao atualizar batch"); + expect(fakeToast.error).toHaveBeenCalledWith("Erro ao atualizar lote"); }); it("exibe mensagem de erro retornada pela API na atualização", async () => { - const updateError = new Error("Falha ao atualizar batch"); + const updateError = new Error("Falha ao atualizar lote"); fakeApi.put.mockReturnValueOnce({ json: vi.fn(async () => { throw updateError; @@ -274,6 +274,6 @@ describe("useBatchEditModel", () => { await result.current.onSubmit(baseFormValues); }); - expect(fakeToast.error).toHaveBeenCalledWith("Falha ao atualizar batch"); + expect(fakeToast.error).toHaveBeenCalledWith("Falha ao atualizar lote"); }); }); diff --git a/app/(pages)/batches/[id]/edit/batches-edit.model.ts b/app/(pages)/batches/[id]/edit/batches-edit.model.ts index b27bf41..7b37237 100644 --- a/app/(pages)/batches/[id]/edit/batches-edit.model.ts +++ b/app/(pages)/batches/[id]/edit/batches-edit.model.ts @@ -65,10 +65,10 @@ export const useBatchEditModel = (batchId: string) => { try { const { api } = await import("@/lib/api"); await api.put(`batches/${batchId}`, { json: values }).json(); - toast.success("Batch atualizado"); + toast.success("Lote atualizado"); router.push(`/batches/${batchId}`); } catch (err) { - const message = err instanceof Error ? err.message : "Erro ao atualizar batch"; + const message = err instanceof Error ? err.message : "Erro ao atualizar lote"; toast.error(message); } }; diff --git a/app/(pages)/batches/create/batches-create.model.test.ts b/app/(pages)/batches/create/batches-create.model.test.ts index 7e6df4f..faceaad 100644 --- a/app/(pages)/batches/create/batches-create.model.test.ts +++ b/app/(pages)/batches/create/batches-create.model.test.ts @@ -105,6 +105,7 @@ const fakeToast = vi.hoisted(() => { class FakeBatchCreateToast { public readonly success = vi.fn<(message: string) => void>(); public readonly error = vi.fn<(message: string) => void>(); + public readonly warning = vi.fn<(message: string) => void>(); } return new FakeBatchCreateToast(); @@ -543,8 +544,8 @@ describe("useBatchCreateModel", () => { await result.current.onSubmit(validSubmitData); }); - expect(fakeToast.error).toHaveBeenCalledWith( - "Selecione um warehouse ativo para criar o batch", + expect(fakeToast.warning).toHaveBeenCalledWith( + "Selecione um estoque ativo para criar o lote", ); expect(fakeApi.post).not.toHaveBeenCalled(); }); @@ -596,7 +597,7 @@ describe("useBatchCreateModel", () => { expect(fakeApi.post).toHaveBeenCalledWith("batches", { json: buildBatchPayload(validSubmitData, "wh-1"), }); - expect(fakeToast.success).toHaveBeenCalledWith("Batch criado com sucesso"); + expect(fakeToast.success).toHaveBeenCalledWith("Lote criado com sucesso"); expect(fakeRouter.push).toHaveBeenCalledWith("/batches/batch-2026"); }); @@ -627,6 +628,6 @@ describe("useBatchCreateModel", () => { await result.current.onSubmit(validSubmitData); }); - expect(fakeToast.error).toHaveBeenCalledWith("Erro ao criar batch"); + expect(fakeToast.error).toHaveBeenCalledWith("Erro ao criar lote"); }); }); diff --git a/app/(pages)/batches/create/batches-create.model.ts b/app/(pages)/batches/create/batches-create.model.ts index 34a8e36..5beed8f 100644 --- a/app/(pages)/batches/create/batches-create.model.ts +++ b/app/(pages)/batches/create/batches-create.model.ts @@ -308,7 +308,7 @@ export const useBatchCreateModel = () => { const onSubmit = async (data: BatchCreateFormData) => { if (!warehouseId) { - toast.error("Selecione um warehouse ativo para criar o batch"); + toast.warning("Selecione um estoque ativo para criar o lote"); return; } @@ -320,11 +320,11 @@ export const useBatchCreateModel = () => { .json(); if (response.success) { - toast.success("Batch criado com sucesso"); + toast.success("Lote criado com sucesso"); router.push(`/batches/${response.data.id}`); } } catch (err) { - const message = err instanceof Error ? err.message : "Erro ao criar batch"; + const message = err instanceof Error ? err.message : "Erro ao criar lote"; toast.error(message); } }; diff --git a/app/(pages)/products/[id]/edit/products-edit.model.test.ts b/app/(pages)/products/[id]/edit/products-edit.model.test.ts index b73a5a2..63d2629 100644 --- a/app/(pages)/products/[id]/edit/products-edit.model.test.ts +++ b/app/(pages)/products/[id]/edit/products-edit.model.test.ts @@ -31,7 +31,7 @@ vi.mock("next/navigation", () => ({ })); vi.mock("sonner", () => ({ - toast: { success: vi.fn(), error: vi.fn() }, + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() }, })); vi.mock("@/components/breadcrumb", () => ({ @@ -328,7 +328,7 @@ describe("useProductEditModel batches drawer", () => { }); }); - expect(toast.error).toHaveBeenCalledWith("Atributo 1: Nome e valor são obrigatórios"); + expect(toast.warning).toHaveBeenCalledWith("Atributo 1: Nome e valor são obrigatórios"); expect(api.put).not.toHaveBeenCalledWith("products/prod-1", expect.anything()); }); @@ -361,7 +361,7 @@ describe("useProductEditModel batches drawer", () => { }); expect(api.put).not.toHaveBeenCalledWith("products/prod-1", expect.anything()); - expect(toast.error).toHaveBeenCalledWith( + expect(toast.warning).toHaveBeenCalledWith( "Aguarde a imagem terminar de processar antes de salvar.", ); }); @@ -462,13 +462,13 @@ describe("useProductEditModel batches drawer", () => { }); vi.mocked(api.put).mockImplementationOnce(() => { - throw new Error("Batch falhou"); + throw new Error("Lote falhou"); }); await act(async () => { await result.current.batchesDrawer.onSave(0); }); - expect(toast.error).toHaveBeenCalledWith("Batch falhou"); + expect(toast.error).toHaveBeenCalledWith("Lote falhou"); }); }); diff --git a/app/(pages)/products/[id]/edit/products-edit.model.ts b/app/(pages)/products/[id]/edit/products-edit.model.ts index 4030bb5..766acda 100644 --- a/app/(pages)/products/[id]/edit/products-edit.model.ts +++ b/app/(pages)/products/[id]/edit/products-edit.model.ts @@ -354,7 +354,7 @@ export const useProductEditModel = (productId: string) => { for (let i = 0; i < customAttributes.length; i++) { const attr = customAttributes[i]; if (!attr.key.trim() || !attr.value.trim()) { - toast.error(`Atributo ${i + 1}: Nome e valor são obrigatórios`); + toast.warning(`Atributo ${i + 1}: Nome e valor são obrigatórios`); return false; } } @@ -362,7 +362,7 @@ export const useProductEditModel = (productId: string) => { const keys = customAttributes.map((a) => a.key.trim().toLowerCase()); const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index); if (duplicates.length > 0) { - toast.error(`Já existe um atributo com o nome "${duplicates[0]}"`); + toast.warning(`Já existe um atributo com o nome "${duplicates[0]}"`); return false; } @@ -394,7 +394,7 @@ export const useProductEditModel = (productId: string) => { } if (isImageProcessing) { - toast.error("Aguarde a imagem terminar de processar antes de salvar."); + toast.warning("Aguarde a imagem terminar de processar antes de salvar."); return; } @@ -478,13 +478,13 @@ export const useProductEditModel = (productId: string) => { await api.put(`batches/${batch.id}`, { json: payload }).json(); mutate(`batches/product/${productId}`); - toast.success("Batch atualizado com sucesso!"); + toast.success("Lote atualizado com sucesso!"); } catch (error) { console.error("Erro ao atualizar batch:", error); if (error instanceof Error) { - toast.error(error.message || "Erro ao atualizar batch"); + toast.error(error.message || "Erro ao atualizar lote"); } else { - toast.error("Erro ao atualizar batch"); + toast.error("Erro ao atualizar lote"); } } finally { setUpdatingBatchId(null); diff --git a/app/(pages)/products/[id]/prompts/product-prompts.model.ts b/app/(pages)/products/[id]/prompts/product-prompts.model.ts index 8960aba..bc1424c 100644 --- a/app/(pages)/products/[id]/prompts/product-prompts.model.ts +++ b/app/(pages)/products/[id]/prompts/product-prompts.model.ts @@ -397,10 +397,10 @@ function notifyProductPromptUnsupportedShare( copyResult: ProductPromptTextCopyResult ): void { if (copyResult === "text") { - toast.error("Prompt copiado, mas este navegador não permite compartilhar imagens."); + toast.warning("Prompt copiado, mas este navegador não permite compartilhar imagens."); return; } - toast.error("Este navegador não permite compartilhar imagens automaticamente."); + toast.warning("Este navegador não permite compartilhar imagens automaticamente."); } function notifyProductPromptShareFailure(copyResult: ProductPromptTextCopyResult): void { diff --git a/app/(pages)/products/create/products-create.model.test.ts b/app/(pages)/products/create/products-create.model.test.ts index b2a5b5a..2279b8d 100644 --- a/app/(pages)/products/create/products-create.model.test.ts +++ b/app/(pages)/products/create/products-create.model.test.ts @@ -40,6 +40,7 @@ vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), + warning: vi.fn(), }, })); @@ -143,7 +144,7 @@ describe("useProductCreateModel - multipart submit", () => { }); expect(mockPost).not.toHaveBeenCalled(); - expect(toast.error).toHaveBeenCalledWith( + expect(toast.warning).toHaveBeenCalledWith( "Aguarde a imagem terminar de processar antes de salvar.", ); }); @@ -189,7 +190,7 @@ describe("useProductCreateModel - multipart submit", () => { }); expect(mockPost).not.toHaveBeenCalled(); - expect(toast.error).toHaveBeenCalledWith("Selecione um warehouse para criar o produto"); + expect(toast.warning).toHaveBeenCalledWith("Selecione um estoque para criar o produto"); }); it("blocks submit when custom attribute is incomplete or duplicated", async () => { @@ -203,7 +204,7 @@ describe("useProductCreateModel - multipart submit", () => { await result.current.onSubmit(baseFormData); }); - expect(toast.error).toHaveBeenCalledWith("Atributo 1: Nome e valor são obrigatórios"); + expect(toast.warning).toHaveBeenCalledWith("Atributo 1: Nome e valor são obrigatórios"); act(() => { result.current.updateCustomAttribute(0, "key", "color"); @@ -220,7 +221,7 @@ describe("useProductCreateModel - multipart submit", () => { await result.current.onSubmit(baseFormData); }); - expect(toast.error).toHaveBeenCalledWith('Já existe um atributo com o nome "color"'); + expect(toast.warning).toHaveBeenCalledWith('Já existe um atributo com o nome "color"'); expect(mockPost).not.toHaveBeenCalled(); }); diff --git a/app/(pages)/products/create/products-create.model.ts b/app/(pages)/products/create/products-create.model.ts index bfa1a40..3c1805d 100644 --- a/app/(pages)/products/create/products-create.model.ts +++ b/app/(pages)/products/create/products-create.model.ts @@ -177,7 +177,7 @@ export const useProductCreateModel = () => { for (let i = 0; i < customAttributes.length; i++) { const attr = customAttributes[i]; if (!attr.key.trim() || !attr.value.trim()) { - toast.error(`Atributo ${i + 1}: Nome e valor são obrigatórios`); + toast.warning(`Atributo ${i + 1}: Nome e valor são obrigatórios`); return false; } } @@ -186,7 +186,7 @@ export const useProductCreateModel = () => { const keys = customAttributes.map((a) => a.key.trim().toLowerCase()); const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index); if (duplicates.length > 0) { - toast.error(`Já existe um atributo com o nome "${duplicates[0]}"`); + toast.warning(`Já existe um atributo com o nome "${duplicates[0]}"`); return false; } @@ -255,12 +255,12 @@ export const useProductCreateModel = () => { } if (!warehouseId) { - toast.error("Selecione um warehouse para criar o produto"); + toast.warning("Selecione um estoque para criar o produto"); return; } if (isImageProcessing) { - toast.error("Aguarde a imagem terminar de processar antes de salvar."); + toast.warning("Aguarde a imagem terminar de processar antes de salvar."); return; } diff --git a/app/(pages)/products/products.model.ts b/app/(pages)/products/products.model.ts index 1a4639f..f0a0b74 100644 --- a/app/(pages)/products/products.model.ts +++ b/app/(pages)/products/products.model.ts @@ -264,7 +264,7 @@ export const useProductsModel = () => { `batches/warehouses/${warehouseId}/products/${deleteProduct.id}/batches`; await api.delete(deleteBatchesEndpoint).json(); - toast.success("Produto removido do armazém com sucesso"); + toast.success("Produto removido do estoque com sucesso"); mutate(); mutateGlobal((key) => typeof key === "string" && diff --git a/app/(pages)/sales/pdv/pdv.model.test.ts b/app/(pages)/sales/pdv/pdv.model.test.ts index 85bbe67..9d75732 100644 --- a/app/(pages)/sales/pdv/pdv.model.test.ts +++ b/app/(pages)/sales/pdv/pdv.model.test.ts @@ -11,6 +11,7 @@ const mockSWR = vi.fn(); const toastError = vi.fn(); const toastSuccess = vi.fn(); const toastInfo = vi.fn(); +const toastWarning = vi.fn(); const routerPush = vi.fn(); let activeWarehouseId: string | null = "warehouse-1"; @@ -77,6 +78,7 @@ vi.mock("sonner", () => ({ success: (...args: unknown[]) => toastSuccess(...args), error: (...args: unknown[]) => toastError(...args), info: (...args: unknown[]) => toastInfo(...args), + warning: (...args: unknown[]) => toastWarning(...args), }, })); @@ -326,7 +328,7 @@ describe("usePdvModel", () => { await result.current.onBarcodeScanned("000999"); }); - expect(toastError).toHaveBeenCalledWith("Nenhum armazém selecionado"); + expect(toastError).toHaveBeenCalledWith("Nenhum estoque selecionado"); expect(mockGet).not.toHaveBeenCalled(); }); @@ -478,7 +480,7 @@ describe("usePdvModel", () => { await result.current.onSubmit(submitData()); }); - expect(toastError).toHaveBeenCalledWith("Adicione pelo menos um produto ao carrinho"); + expect(toastWarning).toHaveBeenCalledWith("Adicione pelo menos um produto ao carrinho"); expect(mockPost).not.toHaveBeenCalled(); expect(result.current.cart).toHaveLength(0); }); diff --git a/app/(pages)/sales/pdv/pdv.model.ts b/app/(pages)/sales/pdv/pdv.model.ts index 86c707a..d8c325b 100644 --- a/app/(pages)/sales/pdv/pdv.model.ts +++ b/app/(pages)/sales/pdv/pdv.model.ts @@ -256,7 +256,7 @@ export function usePdvModel(): PdvViewProps { const onSubmit = useCallback( async (data: PdvSchema) => { if (cart.length === 0 || !warehouseId) { - toast.error("Adicione pelo menos um produto ao carrinho"); + toast.warning("Adicione pelo menos um produto ao carrinho"); return; } setIsSubmitting(true); @@ -344,7 +344,7 @@ export function usePdvModel(): PdvViewProps { const onBarcodeScanned = useCallback( async (barcode: string) => { if (!warehouseId) { - toast.error("Nenhum armazém selecionado"); + toast.error("Nenhum estoque selecionado"); return; } diff --git a/app/(pages)/transfers/new/new-transfer.model.test.ts b/app/(pages)/transfers/new/new-transfer.model.test.ts index 7476c96..564cfc2 100644 --- a/app/(pages)/transfers/new/new-transfer.model.test.ts +++ b/app/(pages)/transfers/new/new-transfer.model.test.ts @@ -118,6 +118,7 @@ const fakeToast = vi.hoisted(() => { class FakeToast { public readonly success = vi.fn<(message: string) => void>(); public readonly error = vi.fn<(message: string) => void>(); + public readonly warning = vi.fn<(message: string) => void>(); } return new FakeToast(); @@ -687,7 +688,7 @@ describe("useNewTransferModel", () => { await result.current.onSubmit(payload); }); - expect(fakeToast.error).toHaveBeenCalledWith("Selecione um warehouse de origem."); + expect(fakeToast.warning).toHaveBeenCalledWith("Selecione um estoque de origem."); expect(fakeApi.post).not.toHaveBeenCalled(); }); diff --git a/app/(pages)/transfers/new/new-transfer.model.ts b/app/(pages)/transfers/new/new-transfer.model.ts index 934f962..5f75faa 100644 --- a/app/(pages)/transfers/new/new-transfer.model.ts +++ b/app/(pages)/transfers/new/new-transfer.model.ts @@ -471,7 +471,7 @@ export function useNewTransferModel(): NewTransferViewProps { const onSubmit = async (data: NewTransferSchema): Promise => { if (!currentWarehouseId) { - toast.error("Selecione um warehouse de origem."); + toast.warning("Selecione um estoque de origem."); return; } diff --git a/app/warehouses/warehouses.model.test.ts b/app/warehouses/warehouses.model.test.ts index 533537c..936c8b5 100644 --- a/app/warehouses/warehouses.model.test.ts +++ b/app/warehouses/warehouses.model.test.ts @@ -323,7 +323,7 @@ describe("useWarehousesModel", () => { }); expect(api.post).toHaveBeenCalledWith("warehouses", { json: payload }); - expect(toast.success).toHaveBeenCalledWith("Warehouse created successfully"); + expect(toast.success).toHaveBeenCalledWith("Estoque criado com sucesso"); act(() => { result.current.openEditModal(result.current.warehouses[0]); @@ -334,7 +334,7 @@ describe("useWarehousesModel", () => { }); expect(api.put).toHaveBeenCalledWith("warehouses/1", { json: payload }); - expect(toast.success).toHaveBeenCalledWith("Warehouse updated successfully"); + expect(toast.success).toHaveBeenCalledWith("Estoque atualizado com sucesso"); }); it("should show save fallback error when submit fails", async () => { @@ -353,7 +353,7 @@ describe("useWarehousesModel", () => { }); }); - expect(toast.error).toHaveBeenCalledWith("Erro ao salvar armazém. Tente novamente."); + expect(toast.error).toHaveBeenCalledWith("Erro ao salvar estoque. Tente novamente."); }); it("should select warehouse and handle selection errors", async () => { @@ -366,7 +366,7 @@ describe("useWarehousesModel", () => { expect(api.post).toHaveBeenCalledWith("auth/switch-warehouse", { json: { warehouseId: "2" }, }); - expect(toast.success).toHaveBeenCalledWith("Warehouse created successfully"); + expect(toast.success).toHaveBeenCalledWith("Estoque selecionado"); vi.mocked(api.post).mockImplementationOnce(() => { throw { response: { data: { message: "Sem acesso" } } }; @@ -391,7 +391,7 @@ describe("useWarehousesModel", () => { }); expect(api.delete).toHaveBeenCalledWith("warehouses/1"); - expect(toast.success).toHaveBeenCalledWith("Warehouse deleted successfully"); + expect(toast.success).toHaveBeenCalledWith("Estoque deletado com sucesso"); expect(result.current.isDeleting).toBe(false); vi.mocked(api.delete).mockImplementationOnce(() => { @@ -412,7 +412,7 @@ describe("useWarehousesModel", () => { }); expect(toast.error).toHaveBeenCalledWith( - "warehouse has stock. Desative o armazém ou transfira o estoque primeiro.", + "warehouse has stock. Desative o estoque ou transfira o estoque primeiro.", ); expect(result.current.isDeleting).toBe(false); }); diff --git a/app/warehouses/warehouses.model.ts b/app/warehouses/warehouses.model.ts index 35c289a..9abdaed 100644 --- a/app/warehouses/warehouses.model.ts +++ b/app/warehouses/warehouses.model.ts @@ -176,13 +176,13 @@ export const useWarehousesModel = () => { if (response.success) { setWarehouseId(id); - toast.success(response.message || "Armazém selecionado"); + toast.success("Estoque selecionado"); } } catch (err) { const error = err as { response?: { data?: { message?: string } } }; const errorMessage = error.response?.data?.message || - "Erro ao selecionar armazém. Tente novamente."; + "Erro ao selecionar estoque. Tente novamente."; toast.error(errorMessage); } @@ -198,7 +198,7 @@ export const useWarehousesModel = () => { .json(); if (response.success) { - toast.success(response.message || "Armazém atualizado com sucesso"); + toast.success("Estoque atualizado com sucesso"); mutate(); closeModal(); } @@ -209,7 +209,7 @@ export const useWarehousesModel = () => { .json(); if (response.success) { - toast.success(response.message || "Armazém criado com sucesso"); + toast.success("Estoque criado com sucesso"); mutate(); closeModal(); } @@ -218,7 +218,7 @@ export const useWarehousesModel = () => { const error = err as { response?: { data?: { message?: string } } }; const errorMessage = error.response?.data?.message || - "Erro ao salvar armazém. Tente novamente."; + "Erro ao salvar estoque. Tente novamente."; toast.error(errorMessage); } @@ -243,18 +243,18 @@ export const useWarehousesModel = () => { .json(); if (response.success) { - toast.success(response.message || "Armazém deletado com sucesso"); + toast.success("Estoque deletado com sucesso"); mutate(); closeDeleteDialog(); } } catch (err) { const error = err as { response?: { status?: number; data?: { message?: string } } }; - const errorMessage = error.response?.data?.message || "Erro ao deletar armazém"; + const errorMessage = error.response?.data?.message || "Erro ao deletar estoque"; // Check if deletion is blocked by stock if (error.response?.status === 400 && errorMessage.includes("stock")) { toast.error( - `${errorMessage}. Desative o armazém ou transfira o estoque primeiro.` + `${errorMessage}. Desative o estoque ou transfira o estoque primeiro.` ); } else { toast.error(errorMessage); From be5ddc2ef8a5af0afc24307af0c6803ee694e9d2 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Thu, 11 Jun 2026 16:25:52 -0300 Subject: [PATCH 19/32] feat(toast): style sonner toasts with brutalist design system Apply dark brutalist theme to toast notifications: - Surface #171717 with neutral-800 borders and 4px radius - Success: emerald #059669 with white text - Error: rose #E11D48 with white text - Warning: amber #F59E0B with black text - Info: blue #2563EB with white text - Position: top-center - No box-shadow (consistent with design system) --- app/globals.css | 52 ++++++++++++++++++++++++++++++++++++++++ components/ui/sonner.tsx | 21 +++++++++++++--- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/app/globals.css b/app/globals.css index abb2f43..6802c6c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -137,4 +137,56 @@ .animate-shimmer { animation: shimmer 3s ease-in-out infinite; } +} + +.toaster { + --normal-bg: #171717; + --normal-text: #FAFAFA; + --normal-border: #262626; +} + +.toaster [data-sonner-toast] { + border-radius: 4px; + box-shadow: none; + border: 1px solid var(--normal-border); + background: var(--normal-bg); + color: var(--normal-text); +} + +.toaster .toast-success { + background: #059669; + border-color: #059669; + color: #FFFFFF; +} + +.toaster .toast-error { + background: #E11D48; + border-color: #E11D48; + color: #FFFFFF; +} + +.toaster .toast-warning { + background: #F59E0B; + border-color: #F59E0B; + color: #000000; +} + +.toaster .toast-info { + background: #2563EB; + border-color: #2563EB; + color: #FFFFFF; +} + +.toaster .toast-description { + color: inherit; + opacity: 0.9; +} + +.toaster .toast-close-button { + color: inherit; + opacity: 0.7; +} + +.toaster .toast-close-button:hover { + opacity: 1; } \ No newline at end of file diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx index 1eb0a26..0e8833b 100644 --- a/components/ui/sonner.tsx +++ b/components/ui/sonner.tsx @@ -6,12 +6,27 @@ const Toaster = ({ ...props }: ToasterProps) => { return ( Date: Wed, 17 Jun 2026 20:53:21 -0300 Subject: [PATCH 20/32] feat(nuqs): persist product search query in URL across navigations - Add nuqs ^2.8.9 dependency for URL-based state management - Wrap RootLayout with NuqsAdapter (Next.js App Router adapter) - Split ProductFilters into URL-persisted searchQuery (nuqs) and local tableFilters (useState) - Replace single setFilters with a composed setFilters that routes searchQuery to the URL - Update direct filter mutations (pagination, sort, KPI clicks) to use setTableFilters - Reset search query via setSearchQuery in clear/reset handlers - Wrap test renderHook with NuqsTestingAdapter for test compatibility Co-Authored-By: Claude --- app/(pages)/products/products.model.test.ts | 24 ++++--- app/(pages)/products/products.model.ts | 69 ++++++++++++++------- app/layout.tsx | 17 ++--- package.json | 1 + pnpm-lock.yaml | 36 +++++++++++ 5 files changed, 109 insertions(+), 38 deletions(-) diff --git a/app/(pages)/products/products.model.test.ts b/app/(pages)/products/products.model.test.ts index 0dbaff7..d51ceef 100644 --- a/app/(pages)/products/products.model.test.ts +++ b/app/(pages)/products/products.model.test.ts @@ -1,8 +1,16 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { NuqsTestingAdapter } from "nuqs/adapters/testing"; import { useProductsModel } from "./products.model"; import { toast } from "sonner"; +const renderModel = () => + renderHook(() => useProductsModel(), { + wrapper: ({ children }: { children: ReactNode }) => + createElement(NuqsTestingAdapter, null, children), + }); + const mockMutate = vi.fn(); const mockGlobalMutate = vi.fn(); const mockGet = vi.fn(); @@ -138,7 +146,7 @@ describe("useProductsModel - delete flow", () => { })), }); - const { result } = renderHook(() => useProductsModel()); + const { result } = renderModel(); await act(async () => { await result.current.onOpenDeleteDialog(result.current.products[0]); @@ -179,7 +187,7 @@ describe("useProductsModel - delete flow", () => { })), }); - const { result } = renderHook(() => useProductsModel()); + const { result } = renderModel(); await act(async () => { await result.current.onOpenDeleteDialog(result.current.products[0]); @@ -234,7 +242,7 @@ describe("useProductsModel - delete flow", () => { })), }); - const { result } = renderHook(() => useProductsModel()); + const { result } = renderModel(); await act(async () => { await result.current.onOpenDeleteDialog(result.current.products[0]); @@ -252,7 +260,7 @@ describe("useProductsModel - delete flow", () => { }); it("updates pagination, search and sort filters", () => { - const { result } = renderHook(() => useProductsModel()); + const { result } = renderModel(); act(() => { result.current.onPageChange(2); @@ -272,7 +280,7 @@ describe("useProductsModel - delete flow", () => { }); it("filters products without stock from KPI action", () => { - const { result } = renderHook(() => useProductsModel()); + const { result } = renderModel(); act(() => { result.current.onPageChange(2); @@ -301,7 +309,7 @@ describe("useProductsModel - delete flow", () => { })), }); - const { result } = renderHook(() => useProductsModel()); + const { result } = renderModel(); await act(async () => { await result.current.onOpenDeleteDialog(result.current.products[0]); @@ -326,7 +334,7 @@ describe("useProductsModel - delete flow", () => { throw { response: { data: { message: "Falha ao verificar" } } }; }); - const { result } = renderHook(() => useProductsModel()); + const { result } = renderModel(); await act(async () => { await result.current.onOpenDeleteDialog(result.current.products[0]); @@ -357,7 +365,7 @@ describe("useProductsModel - delete flow", () => { throw {}; }); - const { result } = renderHook(() => useProductsModel()); + const { result } = renderModel(); await act(async () => { await result.current.onOpenDeleteDialog(result.current.products[0]); diff --git a/app/(pages)/products/products.model.ts b/app/(pages)/products/products.model.ts index f0a0b74..3362a4e 100644 --- a/app/(pages)/products/products.model.ts +++ b/app/(pages)/products/products.model.ts @@ -1,4 +1,6 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { useQueryState, parseAsString } from "nuqs"; import useSWR, { mutate as mutateGlobal } from "swr"; import { api } from "@/lib/api"; import { toast } from "sonner"; @@ -20,7 +22,9 @@ interface BatchesResponse { data: Batch[]; } -const DEFAULT_FILTERS: Omit = { +type TableFilters = Omit; + +const DEFAULT_FILTERS: TableFilters = { sortBy: "name", sortOrder: "asc", stockStatus: "all", @@ -29,6 +33,10 @@ const DEFAULT_FILTERS: Omit = { pageSize: 20, }; +// Key for the search query persisted in the URL via nuqs, so it survives +// navigating into a product detail and coming back. +const SEARCH_QUERY_PARAM = "q"; + const DEFAULT_DRAFT: ProductFilterDraft = { stockStatus: "all", activeStatus: "all", @@ -70,10 +78,30 @@ const filterByActiveStatus = (product: Product, status: ActiveStatus) => { export const useProductsModel = () => { const { warehouseId } = useSelectedWarehouse(); - const [filters, setFilters] = useState({ - searchQuery: "", - ...DEFAULT_FILTERS, - }); + const [searchQuery, setSearchQuery] = useQueryState( + SEARCH_QUERY_PARAM, + parseAsString.withDefault("") + ); + const [tableFilters, setTableFilters] = + useState(DEFAULT_FILTERS); + + const filters = useMemo( + () => ({ ...tableFilters, searchQuery }), + [tableFilters, searchQuery] + ); + + // Mirrors the original Dispatch> contract, + // routing searchQuery to the URL and the remaining filters to local state. + const setFilters = useCallback>>( + (update) => { + const next = typeof update === "function" ? update(filters) : update; + const { searchQuery: nextSearch, ...rest } = next; + if (nextSearch !== searchQuery) setSearchQuery(nextSearch); + setTableFilters(rest); + }, + [filters, searchQuery, setSearchQuery] + ); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [secondConfirmOpen, setSecondConfirmOpen] = useState(false); const [deleteProduct, setDeleteProduct] = useState(null); @@ -143,23 +171,24 @@ export const useProductsModel = () => { }; const onPageChange = (page: number) => { - setFilters((prev) => ({ ...prev, page })); + setTableFilters((prev) => ({ ...prev, page })); }; const onPageSizeChange = (pageSize: number) => { - setFilters((prev) => ({ ...prev, pageSize, page: 0 })); + setTableFilters((prev) => ({ ...prev, pageSize, page: 0 })); }; - const onSearchChange = (searchQuery: string) => { - setFilters((prev) => ({ ...prev, searchQuery, page: 0 })); + const onSearchChange = (nextSearch: string) => { + setSearchQuery(nextSearch); + setTableFilters((prev) => ({ ...prev, page: 0 })); }; const onSortChange = (sortBy: SortField, sortOrder: SortOrder) => { - setFilters((prev) => ({ ...prev, sortBy, sortOrder, page: 0 })); + setTableFilters((prev) => ({ ...prev, sortBy, sortOrder, page: 0 })); }; const onOutOfStockKpiClick = () => { - setFilters((prev) => ({ + setTableFilters((prev) => ({ ...prev, stockStatus: "outOfStock", page: 0, @@ -181,7 +210,7 @@ export const useProductsModel = () => { }; const onApplyMobileFilters = () => { - setFilters((prev) => ({ + setTableFilters((prev) => ({ ...prev, stockStatus: mobileFiltersDraft.stockStatus, activeStatus: mobileFiltersDraft.activeStatus, @@ -192,19 +221,13 @@ export const useProductsModel = () => { }; const onClearFilters = () => { - setFilters((prev) => ({ - ...prev, - searchQuery: "", - ...DEFAULT_FILTERS, - })); + setSearchQuery(""); + setTableFilters(DEFAULT_FILTERS); }; const onClearMobileFilters = () => { - const nextFilters = { - searchQuery: "", - ...DEFAULT_FILTERS, - }; - setFilters(nextFilters); + setSearchQuery(""); + setTableFilters(DEFAULT_FILTERS); setMobileFiltersDraft(DEFAULT_DRAFT); }; diff --git a/app/layout.tsx b/app/layout.tsx index b099c13..fe7a8a0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,7 @@ import { AuthProvider } from "@/lib/contexts/auth-context"; import { LayoutContent } from "@/components/layout/layout-content"; import { MobileMenuProvider } from "@/components/layout/mobile-menu-context"; import { Toaster } from "@/components/ui/sonner"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -174,13 +175,15 @@ export default function RootLayout({ - - - - {children} - - - + + + + + {children} + + + + {SHOULD_LOAD_CLARITY && (