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/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. diff --git a/app/(pages)/batches/[id]/batches-detail.model.ts b/app/(pages)/batches/[id]/batches-detail.model.ts index f27211c..b4e47b2 100644 --- a/app/(pages)/batches/[id]/batches-detail.model.ts +++ b/app/(pages)/batches/[id]/batches-detail.model.ts @@ -11,6 +11,7 @@ import { parseISO, } from "date-fns"; import { ptBR } from "date-fns/locale"; +import { formatCentsToBRL } from "@/lib/currency"; import { useBreadcrumb } from "@/components/breadcrumb"; import type { BatchDetail, @@ -64,10 +65,6 @@ const STATUS_MAP: Record = { }, }; -const BATCH_DETAIL_CURRENCY_FORMATTER = new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", -}); /* ─── Pure Functions ─── */ @@ -96,10 +93,7 @@ export const computeBatchStatus = ( return STATUS_MAP.ok; }; -export const formatCentsToBRL = (cents: number | null | undefined): string => { - if (cents === null || cents === undefined) return "-"; - return BATCH_DETAIL_CURRENCY_FORMATTER.format(cents / 100); -}; +export { formatCentsToBRL }; export const formatCentsTotal = ( unitCents: number | null | undefined, 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/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.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)/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)/exploratory-tests/exploratory-tests.constants.ts b/app/(pages)/exploratory-tests/exploratory-tests.constants.ts new file mode 100644 index 0000000..6ea440a --- /dev/null +++ b/app/(pages)/exploratory-tests/exploratory-tests.constants.ts @@ -0,0 +1,462 @@ +export interface ExploratoryTest { + id: string; + category: string; + title: string; + howToTest: string; + pageUrl: string; + pageLabel: string; +} + +export interface ExploratoryTestCategory { + key: string; + label: string; + priority: "ALTA" | "MÉDIA" | "BAIXA"; +} + +export const EXPLORATORY_TEST_CATEGORIES: ExploratoryTestCategory[] = [ + { key: "scanner", label: "Scanner de Código de Barras", priority: "ALTA" }, + { key: "rascunho", label: "Rascunho IndexedDB", priority: "ALTA" }, + { key: "inline-lote", label: "Produto Inline + Lote", priority: "ALTA" }, + { key: "transferencias", label: "Transferências", priority: "ALTA" }, + { key: "prompts", label: "Geração de Prompts com IA", priority: "MÉDIA" }, + { key: "produto-form", label: "Formulário de Produto", priority: "MÉDIA" }, + { key: "api", label: "API — Retry com Refresh de Token", priority: "MÉDIA" }, + { key: "toasts", label: "Toasts — Estilo + Traduções", priority: "BAIXA" }, + { key: "warehouse", label: "Warehouse — Hidratação Síncrona", priority: "BAIXA" }, +]; + +export const EXPLORATORY_TESTS: ExploratoryTest[] = [ + // === Scanner === + { + id: "scanner-scan-normal", + category: "scanner", + title: "Scan de produto existente", + howToTest: "Vá para a página de criar movimentação. Clique no ícone de scanner. Aponte a câmera para um código de barras de um produto que já existe no sistema. O produto deve ser encontrado e adicionado.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "scanner-rescan-debounce", + category: "scanner", + title: "Mesmo código em menos de 1,5 segundos", + howToTest: "Escaneie o mesmo código de barras duas vezes bem rápido (menos de 1,5s entre os scans). O segundo scan deve ser ignorado.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "scanner-permission-denied", + category: "scanner", + title: "Permissão de câmera negada", + howToTest: "Abra o scanner. Quando o navegador pedir permissão da câmera, clique em 'Bloquear'. Deve aparecer um modal de erro com um botão 'Copiar Conteúdo'.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "scanner-no-camera", + category: "scanner", + title: "Dispositivo sem câmera", + howToTest: "Em um computador sem webcam, tente abrir o scanner. Deve aparecer o modal de erro com as informações do erro em formato JSON.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "scanner-multi-camera", + category: "scanner", + title: "Rotação entre câmeras (múltiplas câmeras)", + howToTest: "Em um celular ou tablet com câmera frontal e traseira, abra o scanner. O sistema deve alternar entre as câmeras automaticamente se uma falhar.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "scanner-ios-pwa", + category: "scanner", + title: "Scanner no PWA do iOS", + howToTest: "Abra o aplicativo como PWA no iPhone/iPad (adicione à tela de início). Use o scanner. Deve funcionar com a câmera.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "scanner-inline-not-found", + category: "scanner", + title: "Produto inline — código não encontrado", + howToTest: "Na página de criar movimentação, selecione um tipo de entrada (Compra ou Ajuste de Entrada). Escaneie um código de barras que não existe. Deve aparecer um modal perguntando se deseja criar o produto.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + // === Rascunho IndexedDB === + { + id: "rascunho-reload", + category: "rascunho", + title: "Rascunho persiste ao recarregar a página", + howToTest: "Na página de criar movimentação, preencha alguns campos (tipo, quantidade, adicione um produto). Aperte F5 para recarregar. Os dados devem voltar e um toast verde 'Rascunho da movimentação restaurado' deve aparecer.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "rascunho-close-tab", + category: "rascunho", + title: "Rascunho sobrevive ao fechar a aba", + howToTest: "Preencha parte da movimentação. Feche a aba completamente. Abra uma nova aba e volte para a página de criar movimentação. O rascunho deve ser restaurado com o toast.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "rascunho-image-blob", + category: "rascunho", + title: "Imagem do produto inline preservada no rascunho", + howToTest: "Adicione um produto inline com uma imagem. Recarregue a página (F5). A imagem deve continuar aparecendo no formulário.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "rascunho-two-tabs", + category: "rascunho", + title: "Duas abas simultâneas", + howToTest: "Abra duas abas na mesma página de criar movimentação. Altere dados na primeira aba, depois na segunda. O sistema deve detectar conflito de versão.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "rascunho-anonimo", + category: "rascunho", + title: "IndexedDB indisponível (guia anônima)", + howToTest: "Abra o aplicativo em uma janela anônima/privada. Preencha a movimentação e recarregue. Os dados serão perdidos (sem IndexedDB).", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "rascunho-warehouse-trocada", + category: "rascunho", + title: "Warehouse trocada entre sessões", + howToTest: "Selecione o warehouse A. Crie um rascunho de movimentação. Troque para o warehouse B no menu superior. Recarregue a página. O rascunho NÃO deve ser restaurado.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "rascunho-submit-progress", + category: "rascunho", + title: "Barra de progresso ao finalizar", + howToTest: "Crie uma movimentação com vários itens inline (com imagens). Clique em 'Finalizar'. Deve aparecer uma barra com os passos: 'Preparando dados...', 'Fazendo upload das imagens...', 'Ajustando preços...', 'Finalizando...'.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + // === Produto Inline + Lote === + { + id: "inline-existing-modal", + category: "inline-lote", + title: "Scan de código que já existe — modal aparece", + howToTest: "Na página de criar produto (dentro da movimentação), escaneie um código de barras de um produto que já existe. Deve aparecer um modal 'Produto já existe' com as opções 'Cancelar' e 'Adicionar Lote'.", + pageUrl: "/stock-movements/create/new-product", + pageLabel: "Novo Produto (inline)", + }, + { + id: "inline-existing-cancel", + category: "inline-lote", + title: "Cancelar modal de produto existente", + howToTest: "No modal 'Produto já existe', clique em 'Cancelar'. O modal deve fechar e o formulário deve continuar como estava.", + pageUrl: "/stock-movements/create/new-product", + pageLabel: "Novo Produto (inline)", + }, + { + id: "inline-existing-add-batch", + category: "inline-lote", + title: "Adicionar lote ao produto existente", + howToTest: "No modal 'Produto já existe', clique em 'Adicionar Lote'. Preencha quantidade, datas e preços. Confirme. O item deve aparecer na lista da movimentação.", + pageUrl: "/stock-movements/create/new-product", + pageLabel: "Novo Produto (inline)", + }, + { + id: "inline-price-suggestion", + category: "inline-lote", + title: "Sugestão de preço do último lote", + howToTest: "Ao adicionar um lote para um produto que já tem histórico, os campos de preço de custo e venda devem vir preenchidos com os valores do último lote.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "inline-duplicate-name", + category: "inline-lote", + title: "Produto inline duplicado (mesmo nome)", + howToTest: "Adicione um produto inline chamado 'Produto A'. Tente adicionar outro produto inline também chamado 'Produto A'. Deve mostrar erro de duplicata.", + pageUrl: "/stock-movements/create/new-product", + pageLabel: "Novo Produto (inline)", + }, + { + id: "inline-duplicate-barcode", + category: "inline-lote", + title: "Produto inline duplicado (mesmo código)", + howToTest: "Adicione um produto inline com código '123'. Tente adicionar outro produto inline com o mesmo código '123'. Deve mostrar erro de conflito.", + pageUrl: "/stock-movements/create/new-product", + pageLabel: "Novo Produto (inline)", + }, + { + id: "inline-existing-barcode-conflict", + category: "inline-lote", + title: "Inline + produto existente com mesmo código", + howToTest: "Adicione um produto inline com código 'ABC'. Depois use o scanner para ler um produto existente que tem o código 'ABC'. Deve mostrar aviso de conflito e bloquear.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + // === Transferências === + { + id: "transfer-full-flow", + category: "transferencias", + title: "Fluxo completo de transferência", + howToTest: "Vá para a página de nova transferência. Selecione o armazém de origem e destino. Busque um produto. Selecione um lote. Ajuste a quantidade. Confirme. Envie a transferência.", + pageUrl: "/transfers/new", + pageLabel: "Nova Transferência", + }, + { + id: "transfer-scanner", + category: "transferencias", + title: "Scanner na transferência", + howToTest: "Na página de nova transferência, clique no ícone de scanner. Escaneie um produto. O produto deve ser encontrado e o drawer de lote deve abrir.", + pageUrl: "/transfers/new", + pageLabel: "Nova Transferência", + }, + { + id: "transfer-no-batches", + category: "transferencias", + title: "Produto sem lotes disponíveis", + howToTest: "Na transferência, busque um produto que não tenha lotes cadastrados. O drawer de lote deve aparecer vazio.", + pageUrl: "/transfers/new", + pageLabel: "Nova Transferência", + }, + { + id: "transfer-same-warehouse", + category: "transferencias", + title: "Mesmo armazém origem e destino", + howToTest: "Tente selecionar o mesmo armazém como origem e destino. O sistema deve bloquear essa ação.", + pageUrl: "/transfers/new", + pageLabel: "Nova Transferência", + }, + { + id: "transfer-multiple-items", + category: "transferencias", + title: "Múltiplos itens na transferência", + howToTest: "Adicione 5 ou mais itens diferentes. Verifique se a lista mostra todos corretamente (cards no celular, tabela no desktop).", + pageUrl: "/transfers/new", + pageLabel: "Nova Transferência", + }, + { + id: "transfer-remove-item", + category: "transferencias", + title: "Remover item da lista", + howToTest: "Adicione um item. Clique no botão de remover (ícone de lixeira). O item deve sumir da lista imediatamente.", + pageUrl: "/transfers/new", + pageLabel: "Nova Transferência", + }, + // === Prompts === + { + id: "prompts-create", + category: "prompts", + title: "Criar um prompt", + howToTest: "Na página de detalhes do produto, clique na aba 'Prompts'. Clique em 'Criar prompt'. Faça upload de uma imagem de fundo. Dê um nome. Salve.", + pageUrl: "/products", + pageLabel: "Lista de Produtos", + }, + { + id: "prompts-generate-auto-price", + category: "prompts", + title: "Gerar com preço automático do lote", + howToTest: "Abra um prompt de um produto que tenha lotes. O campo de preço deve vir preenchido automaticamente com o valor do último lote.", + pageUrl: "/products", + pageLabel: "Lista de Produtos", + }, + { + id: "prompts-generate-manual-price", + category: "prompts", + title: "Gerar com preço manual", + howToTest: "No prompt, altere o preço manualmente para um valor diferente do sugerido. Clique em 'Gerar'. O sistema deve usar o valor que você digitou.", + pageUrl: "/products", + pageLabel: "Lista de Produtos", + }, + { + id: "prompts-cash-offer", + category: "prompts", + title: "Opção de oferta à vista", + howToTest: "Na página de gerar prompt, ative 'Oferta à vista'. Escolha entre 'Preço final' ou '% de desconto'. O preço à vista deve ser calculado.", + pageUrl: "/products", + pageLabel: "Lista de Produtos", + }, + { + id: "prompts-installments", + category: "prompts", + title: "Parcelamento", + howToTest: "Na página de gerar prompt, ative o parcelamento. Defina o número de parcelas. O valor da parcela deve ser calculado automaticamente.", + pageUrl: "/products", + pageLabel: "Lista de Produtos", + }, + { + id: "prompts-share-android", + category: "prompts", + title: "Compartilhar no Android/Desktop", + howToTest: "Gere um prompt e clique em 'Gerar imagem'. Deve abrir a janela de compartilhamento nativa do sistema.", + pageUrl: "/products", + pageLabel: "Lista de Produtos", + }, + { + id: "prompts-share-ios-pwa", + category: "prompts", + title: "Compartilhar no iOS PWA", + howToTest: "Abra o app como PWA no iPhone. Gere um prompt. O sistema deve mostrar uma mensagem informando que o compartilhamento de arquivos é bloqueado no iOS PWA.", + pageUrl: "/products", + pageLabel: "Lista de Produtos", + }, + { + id: "prompts-cancel-share", + category: "prompts", + title: "Cancelar compartilhamento", + howToTest: "Gere um prompt. Quando a janela de compartilhar abrir, cancele. O app deve voltar para a lista de prompts.", + pageUrl: "/products", + pageLabel: "Lista de Produtos", + }, + { + id: "prompts-no-logo", + category: "prompts", + title: "Empresa sem logo", + howToTest: "Gere um prompt para uma empresa que não tem logo cadastrada. A imagem deve ser gerada sem o logo (sem quebrar).", + pageUrl: "/products", + pageLabel: "Lista de Produtos", + }, + { + id: "prompts-double-click", + category: "prompts", + title: "Clique rápido em Gerar", + howToTest: "Na página de gerar prompt, clique duas vezes bem rápido no botão 'Gerar imagem'. O sistema deve bloquear o segundo clique.", + pageUrl: "/products", + pageLabel: "Lista de Produtos", + }, + // === Formulário de Produto === + { + id: "produto-form-large-image", + category: "produto-form", + title: "Upload de imagem muito grande (acima de 5MB)", + howToTest: "Na página de criar/editar produto, tente fazer upload de uma imagem com mais de 5MB. O sistema deve rejeitar.", + pageUrl: "/products/create", + pageLabel: "Criar Produto", + }, + { + id: "produto-form-processing-lock", + category: "produto-form", + title: "Lock de processamento de imagem", + howToTest: "Faça upload de uma imagem válida. Enquanto a imagem estiver processando, o botão de salvar deve ficar desabilitado e mostrar 'Imagem...' com um spinner.", + pageUrl: "/products/create", + pageLabel: "Criar Produto", + }, + { + id: "produto-form-submit-processing", + category: "produto-form", + title: "Tentar salvar durante processamento", + howToTest: "Faça upload de uma imagem e imediatamente tente clicar em salvar. Deve aparecer um toast avisando: 'Aguarde a imagem terminar de processar antes de salvar.'", + pageUrl: "/products/create", + pageLabel: "Criar Produto", + }, + { + id: "produto-form-continuous", + category: "produto-form", + title: "Produto inline contínuo", + howToTest: "Ao criar um produto inline dentro de uma movimentação, após salvar o formulário deve resetar e permanecer na mesma página para adicionar outro.", + pageUrl: "/stock-movements/create/new-product", + pageLabel: "Novo Produto (inline)", + }, + // === API === + { + id: "api-token-expired", + category: "api", + title: "Token expirado durante uso", + howToTest: "Mantenha a sessão aberta por várias horas (ou use ferramentas de desenvolvimento para simular). Quando o token expirar, a próxima requisição deve renovar automaticamente sem redirecionar para o login.", + pageUrl: "/stock-movements", + pageLabel: "Movimentações", + }, + { + id: "api-multiple-401", + category: "api", + title: "Múltiplas chamadas com token expirado", + howToTest: "Carregue uma página com várias requisições ao mesmo tempo (ex: dashboard). Se o token estiver expirado, apenas uma renovação deve ocorrer e todas as chamadas devem ser repetidas.", + pageUrl: "/stock-movements", + pageLabel: "Movimentações", + }, + { + id: "api-refresh-fails", + category: "api", + title: "Refresh de token falha", + howToTest: "Simule um token inválido (logout em outra aba). A aplicação deve redirecionar para a página de login.", + pageUrl: "/stock-movements", + pageLabel: "Movimentações", + }, + { + id: "api-403-forbidden", + category: "api", + title: "Erro 403 sem tentar refresh", + howToTest: "Tente acessar um recurso que você não tem permissão. O sistema deve mostrar o erro 403 diretamente, sem tentar renovar o token.", + pageUrl: "/system", + pageLabel: "Sistema", + }, + // === Toasts === + { + id: "toasts-style", + category: "toasts", + title: "Aparência dos toasts no tema brutalista", + howToTest: "Disparar qualquer toast (ex: salvar um produto, erro de validação). Verifique se o fundo é escuro (#171717), borda cinza, texto claro (#FAFAFA), bordas retas (4px).", + pageUrl: "/products/create", + pageLabel: "Criar Produto", + }, + { + id: "toasts-position", + category: "toasts", + title: "Posição dos toasts", + howToTest: "Disparar qualquer toast. Ele deve aparecer no topo central da tela.", + pageUrl: "/products/create", + pageLabel: "Criar Produto", + }, + { + id: "toasts-language", + category: "toasts", + title: "Mensagens em português", + howToTest: "Cause diferentes ações que gerem toasts (salvar, erro, validação). Todas as mensagens devem estar em português do Brasil.", + pageUrl: "/products/create", + pageLabel: "Criar Produto", + }, + { + id: "toasts-long-text", + category: "toasts", + title: "Toast com texto longo", + howToTest: "Se encontrar um toast com mensagem longa, verifique se o texto não quebra o layout nem corta palavras.", + pageUrl: "/stock-movements/create", + pageLabel: "Nova Movimentação", + }, + { + id: "toasts-stacking", + category: "toasts", + title: "Empilhamento de múltiplos toasts", + howToTest: "Disparar 3 toasts em sequência rápida. Eles devem empilhar um abaixo do outro sem se sobrepor.", + pageUrl: "/products/create", + pageLabel: "Criar Produto", + }, + // === Warehouse === + { + id: "warehouse-instant-load", + category: "warehouse", + title: "Warehouse carrega instantaneamente", + howToTest: "Com um warehouse já selecionado, recarregue a página (F5). O nome do warehouse deve aparecer imediatamente no topo, sem flicker ou atraso.", + pageUrl: "/products", + pageLabel: "Produtos", + }, + { + id: "warehouse-no-selection", + category: "warehouse", + title: "Primeiro acesso (sem warehouse)", + howToTest: "Limpe o armazenamento local (localStorage) do navegador. Acesse o app. Nenhum warehouse deve estar selecionado e as páginas devem lidar com isso.", + pageUrl: "/products", + pageLabel: "Produtos", + }, + { + id: "warehouse-stable-navigation", + category: "warehouse", + title: "Warehouse estável na navegação", + howToTest: "Selecione um warehouse. Navegue entre 3 ou 4 páginas diferentes (Produtos, Vendas, Movimentações). O warehouse selecionado deve permanecer o mesmo.", + pageUrl: "/products", + pageLabel: "Produtos", + }, +]; diff --git a/app/(pages)/exploratory-tests/exploratory-tests.model.ts b/app/(pages)/exploratory-tests/exploratory-tests.model.ts new file mode 100644 index 0000000..46d4659 --- /dev/null +++ b/app/(pages)/exploratory-tests/exploratory-tests.model.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { EXPLORATORY_TESTS, EXPLORATORY_TEST_CATEGORIES } from "./exploratory-tests.constants"; +import { readProgress, writeProgress, clearProgress } from "./exploratory-tests.storage"; +import type { ExploratoryTestsViewProps } from "./exploratory-tests.types"; + +export function useExploratoryTestsModel(): ExploratoryTestsViewProps { + const [completedTestIds, setCompletedTestIds] = useState>(new Set()); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + let cancelled = false; + readProgress().then((progress) => { + if (cancelled) return; + setCompletedTestIds(new Set(progress.completedTestIds)); + setIsLoaded(true); + }).catch(() => { + if (!cancelled) setIsLoaded(true); + }); + return () => { cancelled = true; }; + }, []); + + const toggleTest = useCallback((testId: string) => { + setCompletedTestIds((prev) => { + const next = new Set(prev); + if (next.has(testId)) { + next.delete(testId); + } else { + next.add(testId); + } + const ids = Array.from(next); + writeProgress(ids).catch(() => {}); + return next; + }); + }, []); + + const resetAll = useCallback(() => { + setCompletedTestIds(new Set()); + clearProgress().catch(() => {}); + toast.success("Progresso de testes resetado."); + }, []); + + const completedPercentage = useMemo(() => { + const total = EXPLORATORY_TESTS.length; + if (total === 0) return 0; + return Math.round((completedTestIds.size / total) * 100); + }, [completedTestIds.size]); + + const testsByCategory = useMemo(() => { + const map = new Map(); + for (const category of EXPLORATORY_TEST_CATEGORIES) { + map.set(category.key, EXPLORATORY_TESTS.filter((t) => t.category === category.key)); + } + return map; + }, []); + + return { + tests: isLoaded ? EXPLORATORY_TESTS : [], + categories: EXPLORATORY_TEST_CATEGORIES, + completedTestIds, + completedPercentage, + testsByCategory, + toggleTest, + resetAll, + }; +} diff --git a/app/(pages)/exploratory-tests/exploratory-tests.storage.ts b/app/(pages)/exploratory-tests/exploratory-tests.storage.ts new file mode 100644 index 0000000..7df1629 --- /dev/null +++ b/app/(pages)/exploratory-tests/exploratory-tests.storage.ts @@ -0,0 +1,119 @@ +import type { ExploratoryTestProgress } from "./exploratory-tests.types"; + +const DATABASE_NAME = "stockshift_testing"; +const DATABASE_VERSION = 1; +const STORE_NAME = "exploratoryTestProgress"; +const STORAGE_KEY = "current"; + +const getBrowserIndexedDb = (): IDBFactory | null => { + if (typeof window === "undefined") return null; + return window.indexedDB ?? null; +}; + +const logStorageError = (operation: string, error: unknown): void => { + console.error(`[exploratory-tests] Falha ao ${operation}`, error); +}; + +const openDatabase = (): Promise => { + const indexedDb = getBrowserIndexedDb(); + if (!indexedDb) { + return Promise.reject(new Error("IndexedDB indisponível.")); + } + return new Promise((resolve, reject) => { + const request = indexedDb.open(DATABASE_NAME, DATABASE_VERSION); + request.onerror = () => reject(new Error(`Falha ao abrir IndexedDB: ${request.error?.message}`)); + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME); + } + }; + request.onsuccess = () => resolve(request.result); + }); +}; + +const emptyProgress = (): ExploratoryTestProgress => ({ + completedTestIds: [], + updatedAt: "", +}); + +let fallbackProgress: ExploratoryTestProgress = emptyProgress(); + +export const readProgress = async (): Promise => { + try { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readonly"); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(STORAGE_KEY); + request.onerror = () => reject(new Error(`Falha ao ler progresso: ${request.error?.message}`)); + request.onsuccess = () => { + const raw = request.result; + if (raw && typeof raw === "object" && Array.isArray((raw as ExploratoryTestProgress).completedTestIds)) { + fallbackProgress = raw as ExploratoryTestProgress; + resolve(fallbackProgress); + } else { + resolve(emptyProgress()); + } + }; + transaction.oncomplete = () => database.close(); + transaction.onabort = () => { + database.close(); + reject(new Error("Transação de leitura abortada.")); + }; + }); + } catch (error) { + logStorageError("ler progresso", error); + return fallbackProgress; + } +}; + +export const writeProgress = async (completedTestIds: string[]): Promise => { + const progress: ExploratoryTestProgress = { + completedTestIds, + updatedAt: new Date().toISOString(), + }; + fallbackProgress = progress; + try { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.put(progress, STORAGE_KEY); + request.onerror = () => reject(new Error(`Falha ao gravar progresso: ${request.error?.message}`)); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onabort = () => { + database.close(); + reject(new Error("Transação de gravação abortada.")); + }; + }); + } catch (error) { + logStorageError("gravar progresso", error); + } +}; + +export const clearProgress = async (): Promise => { + fallbackProgress = emptyProgress(); + try { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(STORAGE_KEY); + request.onerror = () => reject(new Error(`Falha ao limpar progresso: ${request.error?.message}`)); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onabort = () => { + database.close(); + reject(new Error("Transação de exclusão abortada.")); + }; + }); + } catch (error) { + logStorageError("limpar progresso", error); + } +}; diff --git a/app/(pages)/exploratory-tests/exploratory-tests.types.ts b/app/(pages)/exploratory-tests/exploratory-tests.types.ts new file mode 100644 index 0000000..06b0734 --- /dev/null +++ b/app/(pages)/exploratory-tests/exploratory-tests.types.ts @@ -0,0 +1,14 @@ +export interface ExploratoryTestProgress { + completedTestIds: string[]; + updatedAt: string; +} + +export interface ExploratoryTestsViewProps { + tests: import("./exploratory-tests.constants").ExploratoryTest[]; + categories: import("./exploratory-tests.constants").ExploratoryTestCategory[]; + completedTestIds: Set; + completedPercentage: number; + testsByCategory: Map; + toggleTest: (testId: string) => void; + resetAll: () => void; +} diff --git a/app/(pages)/exploratory-tests/exploratory-tests.view.tsx b/app/(pages)/exploratory-tests/exploratory-tests.view.tsx new file mode 100644 index 0000000..a5e0798 --- /dev/null +++ b/app/(pages)/exploratory-tests/exploratory-tests.view.tsx @@ -0,0 +1,198 @@ +"use client"; + +import Link from "next/link"; +import { ExternalLink, RotateCcw, CheckSquare, Square } from "lucide-react"; +import { PageContainer } from "@/components/ui/page-container"; +import { PageHeader } from "@/components/ui/page-header"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ExploratoryTestsViewProps } from "./exploratory-tests.types"; +import type { ExploratoryTest, ExploratoryTestCategory } from "./exploratory-tests.constants"; + +const PRIORITY_COLORS: Record = { + ALTA: "text-red-400 border-red-500/40", + MÉDIA: "text-amber-400 border-amber-500/40", + BAIXA: "text-neutral-400 border-neutral-500/40", +}; + +export function ExploratoryTestsView({ + tests, + categories, + completedTestIds, + completedPercentage, + testsByCategory, + toggleTest, + resetAll, +}: ExploratoryTestsViewProps) { + const progressColor = completedPercentage === 100 + ? "text-green-400" + : completedPercentage >= 50 + ? "text-amber-400" + : "text-neutral-400"; + + return ( + + + + Resetar + + } + /> + +
+
+ {completedPercentage}% +
+
+

Progresso dos testes

+

+ {completedTestIds.size} de {tests.length} testes concluídos +

+
+
= 50 && completedPercentage < 100, + "bg-blue-500": completedPercentage > 0 && completedPercentage < 50, + "bg-neutral-700": completedPercentage === 0, + })} + style={{ width: `${completedPercentage}%` }} + /> +
+
+
+ +
+ {categories.map((category) => ( + + ))} +
+ +
+

+ O progresso é salvo automaticamente no navegador. + Ao limpar os dados do site, o progresso será perdido. +

+
+ + ); +} + +function CategorySection({ + category, + tests, + completedTestIds, + toggleTest, +}: { + category: ExploratoryTestCategory; + tests: ExploratoryTest[]; + completedTestIds: Set; + toggleTest: (testId: string) => void; +}) { + const categoryCompleted = tests.filter((t) => completedTestIds.has(t.id)).length; + const categoryTotal = tests.length; + + return ( +
+
+ + {category.priority} + +

{category.label}

+ + {categoryCompleted}/{categoryTotal} + +
+ +
+ {tests.map((test) => ( + toggleTest(test.id)} + /> + ))} +
+
+ ); +} + +function TestCard({ + test, + isCompleted, + onToggle, +}: { + test: ExploratoryTest; + isCompleted: boolean; + onToggle: () => void; +}) { + return ( +
+
+ + +
+
+

+ {test.title} +

+ + {test.pageLabel} + + +
+

+ {test.howToTest} +

+
+
+
+ ); +} diff --git a/app/(pages)/exploratory-tests/page.client.tsx b/app/(pages)/exploratory-tests/page.client.tsx new file mode 100644 index 0000000..4ab2f15 --- /dev/null +++ b/app/(pages)/exploratory-tests/page.client.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useExploratoryTestsModel } from "./exploratory-tests.model"; +import { ExploratoryTestsView } from "./exploratory-tests.view"; + +export function PageClient() { + const model = useExploratoryTestsModel(); + return ; +} diff --git a/app/(pages)/exploratory-tests/page.tsx b/app/(pages)/exploratory-tests/page.tsx new file mode 100644 index 0000000..55daf95 --- /dev/null +++ b/app/(pages)/exploratory-tests/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageClient } from "./page.client"; + +export const metadata: Metadata = { + title: "Testes Exploratórios | StockShift", + description: "Checklist de testes exploratórios da branch indexdb.", +}; + +export default function Page() { + // The exploratory tests page is a development-only tool and must not be + // reachable in production builds. + if (process.env.NODE_ENV !== "development") notFound(); + + return ; +} 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/[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/components/product-form.types.ts b/app/(pages)/products/components/product-form.types.ts index 6bbc16c..14516ec 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. * @@ -61,6 +93,7 @@ export interface BatchesDrawerProps { export interface ProductFormProps { mode: 'create' | 'edit' | 'inline'; onSubmit: (data: ProductCreateFormData) => void; + onInvalidSubmit?: () => void; // Called when submit fails validation (e.g. to scroll to the first errored field) isSubmitting: boolean; form: UseFormReturn; @@ -92,7 +125,36 @@ 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 | Promise; + + // Inline duplicate barcode warning drawer (new-product page) + inlineDuplicateWarning?: string | null; + onInlineDuplicateWarningOpenChange?: (open: boolean) => 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..05b3d35 100644 --- a/app/(pages)/products/components/product-form.view.tsx +++ b/app/(pages)/products/components/product-form.view.tsx @@ -1,11 +1,12 @@ "use client"; import Image from "next/image"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { CustomAttributesBuilder } from "@/components/product/custom-attributes-builder"; 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, @@ -21,6 +22,7 @@ import { DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; +import { InlineDuplicateWarningDrawer } from "@/components/stock-movement/inline-duplicate-warning-drawer"; import { Accordion, AccordionContent, @@ -49,6 +51,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { + AlertCircle, Calendar, CheckCircle2, DollarSign, @@ -69,6 +72,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { PermissionGate } from "@/components/permission-gate"; +import { useFooterVisibility } from "@/hooks/footer-visibility/use-footer-visibility"; import type { BatchesDrawerProps, ProductFormProps } from "./product-form.types"; import { cn } from "@/lib/utils"; @@ -149,32 +153,16 @@ export const ProductForm = (productForm: ProductFormProps) => { const isInlineEdit = Boolean(productForm.isInlineEdit); const profit = sellingPrice - costPrice; const margin = costPrice > 0 ? (profit / costPrice) * 100 : 0; - const [isFooterVisible, setIsFooterVisible] = useState(true); + const { isFooterVisible } = useFooterVisibility(); const [showMobileBatchModeToggle, setShowMobileBatchModeToggle] = useState( isInlineMode && !isInlineEdit && continuousMode, ); - const lastScrollYRef = useRef(0); useEffect(() => { if (!isInlineMode || isInlineEdit || !continuousMode) return; setShowMobileBatchModeToggle(true); }, [continuousMode, isInlineEdit, isInlineMode]); - useEffect(() => { - const handleScroll = (): void => { - const currentScrollY = window.scrollY; - const maxScrollY = document.documentElement.scrollHeight - window.innerHeight; - const isAtPageEnd = currentScrollY >= maxScrollY - 8; - const isScrollingUp = currentScrollY < lastScrollYRef.current; - setIsFooterVisible(isScrollingUp || isAtPageEnd || currentScrollY < 8); - lastScrollYRef.current = Math.max(currentScrollY, 0); - }; - - handleScroll(); - window.addEventListener("scroll", handleScroll, { passive: true }); - return () => window.removeEventListener("scroll", handleScroll); - }, []); - const viewState: ProductFormViewState = { batchesDrawerState: mode === "edit" ? productForm.batchesDrawer : undefined, continuousMode, @@ -222,6 +210,13 @@ const ProductFormShell = ({ onClose={productForm.closeScanner} onScan={productForm.handleBarcodeScan} /> + + {productForm.onInlineDuplicateWarningOpenChange && ( + + )}
@@ -230,6 +225,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, @@ -260,7 +316,12 @@ const ProductFormBody = ({ viewState: ProductFormViewState; }) => (
- +
@@ -1078,10 +1139,10 @@ const ProductFooterActionBar = ({ }) => (
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.test.ts b/app/(pages)/products/products.model.test.ts index 0dbaff7..163eb73 100644 --- a/app/(pages)/products/products.model.test.ts +++ b/app/(pages)/products/products.model.test.ts @@ -1,8 +1,22 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; -import { useProductsModel } from "./products.model"; +import { createElement, type ReactNode } from "react"; +import { NuqsTestingAdapter } from "nuqs/adapters/testing"; +import { + buildLatestBatchPrice, + buildLatestBatchPriceByProduct, + findMostRecentBatch, + useProductsModel, +} from "./products.model"; +import type { ProductBatchPriceSource } from "./products.types"; 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(); @@ -15,6 +29,7 @@ const baseProduct = { barcode: "123", barcodeType: "EAN13", description: null, + imageUrl: null, categoryId: null, categoryName: null, brand: null, @@ -47,9 +62,25 @@ let swrData: { }; } | undefined; +let warehouseBatchesData: + | { success: boolean; data: ProductBatchPriceSource[] } + | undefined; + +let productImagesData: Record | undefined; + +const resolveSwrData = (key: unknown) => { + if (typeof key === "string" && key.startsWith("batches/warehouse/")) { + return warehouseBatchesData; + } + if (typeof key === "string" && key.startsWith("product-images-")) { + return productImagesData; + } + return swrData; +}; + vi.mock("swr", () => ({ - default: vi.fn(() => ({ - data: swrData, + default: vi.fn((key: unknown) => ({ + data: resolveSwrData(key), error: null, isLoading: false, mutate: mockMutate, @@ -100,6 +131,8 @@ describe("useProductsModel - delete flow", () => { empty: false, }, }; + warehouseBatchesData = { success: true, data: [] }; + productImagesData = {}; }); it("loads selected warehouse batches with positive stock", async () => { @@ -138,7 +171,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 +212,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 +267,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 +285,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 +305,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 +334,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 +359,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 +390,7 @@ describe("useProductsModel - delete flow", () => { throw {}; }); - const { result } = renderHook(() => useProductsModel()); + const { result } = renderModel(); await act(async () => { await result.current.onOpenDeleteDialog(result.current.products[0]); @@ -381,4 +414,131 @@ describe("useProductsModel - delete flow", () => { ); expect(result.current.isDeletingProduct).toBe(false); }); + + it("exposes latest batch selling price per product from warehouse batches", async () => { + warehouseBatchesData = { + success: true, + data: [ + { + id: "batch-old", + productId: "prod-1", + sellingPrice: 1500, + costPrice: 1000, + createdAt: "2025-01-01T00:00:00Z", + }, + { + id: "batch-new", + productId: "prod-1", + sellingPrice: 2500, + costPrice: 1800, + createdAt: "2025-06-01T00:00:00Z", + }, + ], + }; + + const { result } = renderModel(); + + await waitFor(() => { + expect(result.current.latestBatchPriceByProduct["prod-1"]).not.toBeNull(); + }); + + expect(result.current.latestBatchPriceByProduct["prod-1"]).toMatchObject({ + batchId: "batch-new", + sellingPriceCents: 2500, + }); + }); + + it("fetches missing product images and merges them into filtered products", async () => { + productImagesData = { "prod-1": "https://example.com/prod-1.png" }; + + const { result } = renderModel(); + + await waitFor(() => { + expect(result.current.filteredProducts[0]?.imageUrl).toBe( + "https://example.com/prod-1.png" + ); + }); + }); +}); + +describe("findMostRecentBatch", () => { + const batches: ProductBatchPriceSource[] = [ + { id: "b1", productId: "p1", sellingPrice: 100, costPrice: 50, createdAt: "2025-01-01T00:00:00Z" }, + { id: "b2", productId: "p1", sellingPrice: 200, costPrice: 80, createdAt: "2025-06-01T00:00:00Z" }, + ]; + + it("returns the batch with the latest createdAt", () => { + expect(findMostRecentBatch(batches)?.id).toBe("b2"); + }); + + it("returns null for an empty list", () => { + expect(findMostRecentBatch([])).toBeNull(); + }); + + it("does not let an invalid first date poison selection of valid batches", () => { + const withInvalidFirst: ProductBatchPriceSource[] = [ + { id: "bad", productId: "p1", sellingPrice: 100, costPrice: 50, createdAt: "not-a-date" }, + { id: "good", productId: "p1", sellingPrice: 200, costPrice: 80, createdAt: "2025-06-01T00:00:00Z" }, + ]; + expect(findMostRecentBatch(withInvalidFirst)?.id).toBe("good"); + }); +}); + +describe("buildLatestBatchPrice", () => { + it("builds price info from a batch", () => { + const batch: ProductBatchPriceSource = { + id: "b1", + productId: "p1", + sellingPrice: 2500, + costPrice: 1800, + createdAt: "2025-06-01T00:00:00Z", + }; + expect(buildLatestBatchPrice(batch)).toMatchObject({ + batchId: "b1", + sellingPriceCents: 2500, + }); + }); + + it("returns null when batch is null", () => { + expect(buildLatestBatchPrice(null)).toBeNull(); + }); +}); + +describe("buildLatestBatchPriceByProduct", () => { + it("groups batches by product and keeps only the most recent per product", () => { + const batches: ProductBatchPriceSource[] = [ + { id: "b1", productId: "p1", sellingPrice: 100, costPrice: 50, createdAt: "2025-01-01T00:00:00Z" }, + { id: "b2", productId: "p1", sellingPrice: 200, costPrice: 80, createdAt: "2025-06-01T00:00:00Z" }, + { id: "b3", productId: "p2", sellingPrice: 300, costPrice: 150, createdAt: "2025-03-01T00:00:00Z" }, + ]; + + const map = buildLatestBatchPriceByProduct(batches); + + expect(map["p1"]?.batchId).toBe("b2"); + expect(map["p1"]?.sellingPriceCents).toBe(200); + expect(map["p2"]?.batchId).toBe("b3"); + }); + + it("returns an empty map for an empty batch list", () => { + expect(buildLatestBatchPriceByProduct([])).toEqual({}); + }); + + it("skips price-less batches so an earlier priced batch still wins", () => { + const batches: ProductBatchPriceSource[] = [ + { id: "older", productId: "p1", sellingPrice: 1500, costPrice: 1000, createdAt: "2025-01-01T00:00:00Z" }, + { id: "newer", productId: "p1", sellingPrice: null, costPrice: null, createdAt: "2025-06-01T00:00:00Z" }, + ]; + + const map = buildLatestBatchPriceByProduct(batches); + + expect(map["p1"]).toMatchObject({ batchId: "older", sellingPriceCents: 1500 }); + }); + + it("returns null when a product has only price-less batches", () => { + const batches: ProductBatchPriceSource[] = [ + { id: "b1", productId: "p1", sellingPrice: null, costPrice: null, createdAt: "2025-06-01T00:00:00Z" }, + ]; + + expect(buildLatestBatchPriceByProduct(batches)["p1"]).toBeNull(); + }); }); diff --git a/app/(pages)/products/products.model.ts b/app/(pages)/products/products.model.ts index 1a4639f..4b57d66 100644 --- a/app/(pages)/products/products.model.ts +++ b/app/(pages)/products/products.model.ts @@ -1,11 +1,17 @@ -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"; +import { formatCentsToBRL } from "@/lib/currency"; import { useSelectedWarehouse } from "@/hooks/use-selected-warehouse"; import { Batch, + LatestBatchPrice, Product, + ProductBatchPriceSource, + ProductImageResponse, ProductsResponse, ProductFilters, ProductFilterDraft, @@ -13,6 +19,7 @@ import { SortOrder, StockStatus, ActiveStatus, + WarehouseBatchesResponse, } from "./products.types"; interface BatchesResponse { @@ -20,7 +27,9 @@ interface BatchesResponse { data: Batch[]; } -const DEFAULT_FILTERS: Omit = { +type TableFilters = Omit; + +const DEFAULT_FILTERS: TableFilters = { sortBy: "name", sortOrder: "asc", stockStatus: "all", @@ -29,6 +38,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", @@ -36,6 +49,54 @@ const DEFAULT_DRAFT: ProductFilterDraft = { sortOrder: "asc", }; +export const findMostRecentBatch = ( + batches: readonly ProductBatchPriceSource[], +): ProductBatchPriceSource | null => { + return batches.reduce((latest, batch) => { + if (!latest) return batch; + const batchTime = new Date(batch.createdAt).getTime(); + const latestTime = new Date(latest.createdAt).getTime(); + if (!Number.isFinite(batchTime)) return latest; + if (!Number.isFinite(latestTime)) return batch; + return batchTime > latestTime ? batch : latest; + }, null); +}; + +export const buildLatestBatchPrice = ( + batch: ProductBatchPriceSource | null, +): LatestBatchPrice | null => { + if (!batch) return null; + return { + batchId: batch.id, + sellingPriceCents: batch.sellingPrice, + sellingPriceLabel: formatCentsToBRL(batch.sellingPrice, "Sem preço"), + }; +}; + +const groupBatchesByProduct = ( + batches: readonly ProductBatchPriceSource[], +): Map => + batches.reduce>((groups, batch) => { + const existing = groups.get(batch.productId) ?? []; + existing.push(batch); + groups.set(batch.productId, existing); + return groups; + }, new Map()); + +export const buildLatestBatchPriceByProduct = ( + batches: readonly ProductBatchPriceSource[], +): Record => { + const groups = groupBatchesByProduct(batches); + const result: Record = {}; + for (const [productId, productBatches] of groups) { + // Ignore batches without a price so a newer price-less batch never hides + // the known price of an earlier batch. + const priced = productBatches.filter((batch) => batch.sellingPrice !== null); + result[productId] = buildLatestBatchPrice(findMostRecentBatch(priced)); + } + return result; +}; + const buildFilterDraft = (filters: ProductFilters): ProductFilterDraft => ({ stockStatus: filters.stockStatus, activeStatus: filters.activeStatus, @@ -67,13 +128,78 @@ const filterByActiveStatus = (product: Product, status: ActiveStatus) => { } }; +const fetchProductImage = async ( + id: string, +): Promise<{ id: string; imageUrl: string | null }> => { + try { + const response = await api + .get(`products/${id}`) + .json(); + return { id, imageUrl: response.data.imageUrl ?? null }; + } catch { + return { id, imageUrl: null }; + } +}; + +const fetchProductImages = async ( + productIds: string[], +): Promise> => { + const results = await Promise.all(productIds.map(fetchProductImage)); + return Object.fromEntries(results.map((result) => [result.id, result.imageUrl])); +}; + +const buildProductImagesKey = ( + products: readonly Product[], +): string | null => { + const ids = products + .filter((product) => !product.imageUrl) + .map((product) => product.id) + .sort(); + if (ids.length === 0) return null; + return `product-images-${ids.join(",")}`; +}; + +const parseProductImageIds = (key: string): string[] => + key.replace("product-images-", "").split(","); + +const mergeProductImages = ( + products: Product[], + images: Record | undefined, +): Product[] => { + if (!images) return products; + return products.map((product) => ({ + ...product, + imageUrl: product.imageUrl ?? images[product.id] ?? null, + })); +}; + 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); @@ -115,17 +241,55 @@ export const useProductsModel = () => { } ); + // Fetch batches for the warehouse to derive the most recent batch selling + // price per product. Used by the mobile product card. + const warehouseBatchesUrl = warehouseId ? `batches/warehouse/${warehouseId}` : null; + const { data: warehouseBatchesData } = useSWR( + warehouseBatchesUrl, + async (requestUrl: string) => { + try { + return await api.get(requestUrl).json(); + } catch (err) { + console.error("Erro ao carregar lotes do armazém:", err); + return { success: true, data: [] }; + } + }, + { revalidateOnFocus: false } + ); + // Client-side filtering for stock status and active status const products = useMemo(() => data?.data.content ?? [], [data]); + // The warehouse products list may not return imageUrl. Fetch missing + // images individually so the mobile card can show the product photo. + const productImagesKey = useMemo( + () => buildProductImagesKey(products), + [products] + ); + const { data: productImagesData } = useSWR>( + productImagesKey, + async (key: string) => fetchProductImages(parseProductImageIds(key)), + { revalidateOnFocus: false } + ); + + const productsWithImages = useMemo( + () => mergeProductImages(products, productImagesData), + [products, productImagesData] + ); + + const latestBatchPriceByProduct = useMemo( + () => buildLatestBatchPriceByProduct(warehouseBatchesData?.data ?? []), + [warehouseBatchesData] + ); + const filteredProducts = useMemo( () => - products.filter( + productsWithImages.filter( (p) => filterByStockStatus(p, filters.stockStatus) && filterByActiveStatus(p, filters.activeStatus) ), - [products, filters.stockStatus, filters.activeStatus] + [productsWithImages, filters.stockStatus, filters.activeStatus] ); const pagination = data?.data @@ -143,23 +307,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 +346,7 @@ export const useProductsModel = () => { }; const onApplyMobileFilters = () => { - setFilters((prev) => ({ + setTableFilters((prev) => ({ ...prev, stockStatus: mobileFiltersDraft.stockStatus, activeStatus: mobileFiltersDraft.activeStatus, @@ -192,19 +357,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); }; @@ -264,7 +423,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" && @@ -300,6 +459,7 @@ export const useProductsModel = () => { return { products, filteredProducts, + latestBatchPriceByProduct, isLoading, error: error || null, requiresWarehouse: !warehouseId, diff --git a/app/(pages)/products/products.types.ts b/app/(pages)/products/products.types.ts index 3ee73af..c6dde6d 100644 --- a/app/(pages)/products/products.types.ts +++ b/app/(pages)/products/products.types.ts @@ -15,6 +15,7 @@ export interface Product { barcode: string | null; barcodeType: string | null; description: string | null; + imageUrl: string | null; categoryId: string | null; categoryName: string | null; brand: Brand | null; @@ -37,6 +38,30 @@ export interface Batch { expirationDate: string | null; } +export interface ProductBatchPriceSource { + id: string; + productId: string; + sellingPrice: number | null; + costPrice: number | null; + createdAt: string; +} + +export interface WarehouseBatchesResponse { + success: boolean; + data: ProductBatchPriceSource[]; +} + +export interface ProductImageResponse { + success: boolean; + data: { imageUrl: string | null }; +} + +export interface LatestBatchPrice { + batchId: string; + sellingPriceCents: number | null; + sellingPriceLabel: string; +} + interface ProductsPageable { pageNumber: number; pageSize: number; @@ -84,6 +109,7 @@ export interface ProductFilterDraft { export interface ProductsViewProps { products: Product[]; filteredProducts: Product[]; + latestBatchPriceByProduct: Record; isLoading: boolean; error: Error | null; requiresWarehouse: boolean; diff --git a/app/(pages)/products/products.view.test.tsx b/app/(pages)/products/products.view.test.tsx index 3d7b37d..b7edbd4 100644 --- a/app/(pages)/products/products.view.test.tsx +++ b/app/(pages)/products/products.view.test.tsx @@ -39,6 +39,7 @@ afterEach(() => cleanup()); const baseProps = { products: [], filteredProducts: [], + latestBatchPriceByProduct: {}, isLoading: false, error: null, requiresWarehouse: false, @@ -96,6 +97,7 @@ const productItem = { barcode: "123", barcodeType: "EAN13", description: null, + imageUrl: null, categoryId: null, categoryName: null, brand: null, @@ -214,4 +216,110 @@ describe("ProductsView - delete action", () => { expect(screen.getByText(/confirmação final/i)).toBeTruthy(); }); + + it("renders compact mobile card with photo, name, quantity, category badge and latest batch price", () => { + const productWithCategoryAndImage = { + ...productItem, + id: "prod-cafe", + name: "Café Torrado 1kg", + imageUrl: "https://example.com/cafe.png", + categoryName: "Bebidas", + totalQuantity: 12, + }; + + render( + + ); + + expect(screen.getAllByText("Café Torrado 1kg").length).toBeGreaterThan(0); + expect(screen.getAllByText("Bebidas").length).toBeGreaterThan(0); + expect(screen.getByText("R$ 20,00 • 12 Unids")).toBeTruthy(); + expect(screen.getByRole("img", { name: /foto de café torrado/i })).toBeTruthy(); + }); + + it("renders product image placeholder when product has no photo", () => { + const productWithoutImage = { + ...productItem, + id: "prod-sem-foto", + name: "Produto Sem Foto", + categoryName: "Outros", + totalQuantity: 5, + }; + + render( + + ); + + expect(screen.getByRole("img", { name: /produto sem foto/i })).toBeTruthy(); + }); + + it("hides category badge when product has no category", () => { + render( + + ); + + expect(screen.queryByText(/sem categoria/i)).toBeNull(); + }); + + it("shows fallback price label when product has no latest batch", () => { + const productWithoutBatch = { + ...productItem, + id: "prod-no-batch", + name: "Produto Sem Lote", + totalQuantity: 3, + }; + + render( + + ); + + expect(screen.getByText(/Sem preço.*Unids/)).toBeTruthy(); + }); + + it("truncates long product names in the mobile card", () => { + const productWithLongName = { + ...productItem, + id: "prod-longo", + name: "Perfume Importado Eau de Parfum 100ml Edição Limitada", + categoryName: "Perfumes Importados Femininos", + totalQuantity: 7, + }; + + const { container } = render( + + ); + + const card = container.querySelector('[data-testid="product-mobile-card"]'); + const name = card?.querySelector("h3"); + + expect(name?.classList.contains("truncate")).toBe(true); + }); }); diff --git a/app/(pages)/products/products.view.tsx b/app/(pages)/products/products.view.tsx index cbb923a..4f5f905 100644 --- a/app/(pages)/products/products.view.tsx +++ b/app/(pages)/products/products.view.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import { Button } from "@/components/ui/button"; import { Table, @@ -57,6 +58,7 @@ import { PowerOff, LayoutList, ArrowDownUp, + X, } from "lucide-react"; import Link from "next/link"; import type { ReactNode } from "react"; @@ -70,7 +72,9 @@ import { StockStatus, ActiveStatus, ProductFilters, + LatestBatchPrice, } from "./products.types"; +import { buildCategoryBadgeStyle } from "@/lib/category-color"; import { cn } from "@/lib/utils"; const STOCK_FILTER_OPTIONS: Array<{ @@ -594,8 +598,18 @@ const ProductsSearchInput = ({ props }: { props: ProductsViewProps }) => ( placeholder="Pesquisar no inventário (nome, SKU, código)..." value={props.filters.searchQuery} onChange={(event) => props.onSearchChange(event.target.value)} - className="w-full rounded-[4px] border-neutral-800 bg-[#171717] pl-10 text-sm text-neutral-200 placeholder:text-neutral-600 transition-all hover:border-neutral-700 focus:border-blue-600 focus:ring-0" + className="w-full rounded-[4px] border-neutral-800 bg-[#171717] pl-10 pr-10 text-sm text-neutral-200 placeholder:text-neutral-600 transition-all hover:border-neutral-700 focus:border-blue-600 focus:ring-0" /> + {props.filters.searchQuery && ( + + )}
); @@ -955,11 +969,12 @@ const ProductTableActions = ({ ); const ProductsMobileCards = ({ props }: { props: ProductsViewProps }) => ( -
+
{props.filteredProducts.map((product) => ( ))} @@ -968,38 +983,77 @@ const ProductsMobileCards = ({ props }: { props: ProductsViewProps }) => ( const ProductMobileCard = ({ product, + latestBatchPrice, onOpenDeleteDialog, }: { product: Product; + latestBatchPrice: LatestBatchPrice | null; onOpenDeleteDialog: (product: Product) => void; }) => { - const stockStatus = getStockStatus(product.totalQuantity); + const priceLabel = latestBatchPrice?.sellingPriceLabel ?? "Sem preço"; return ( -
-
-
-

{product.name}

-
- - {product.barcode || "SEM COD. BARRAS"} - - - {product.categoryName} -
-
-
- - -
+
+ +
+

+ {product.name} +

+ + + {priceLabel} • {product.totalQuantity} Unids + +
+
+
); }; +const ProductCardImage = ({ product }: { product: Product }) => { + if (product.imageUrl) { + return ( + + {`Foto + + ); + } + + return ( + + + + ); +}; + +const ProductCategoryBadge = ({ name }: { name: string | null }) => { + const trimmedName = name?.trim(); + if (!trimmedName) return null; + return ( + + {trimmedName} + + ); +}; + const ProductActions = ({ product, onOpenDeleteDialog, 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)/sales/pdv/pdv.view.tsx b/app/(pages)/sales/pdv/pdv.view.tsx index 164cadb..14d0064 100644 --- a/app/(pages)/sales/pdv/pdv.view.tsx +++ b/app/(pages)/sales/pdv/pdv.view.tsx @@ -9,7 +9,7 @@ import { } from "react"; import type { PointerEvent } from "react"; import type { UseFormReturn } from "react-hook-form"; -import { type IDetectedBarcode } from "@yudiel/react-qr-scanner"; +import { type BarcodeScannerDetectedCode } from "@/components/product/barcode-scanner.types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { NumberInput } from "@/components/ui/number-input"; @@ -128,7 +128,7 @@ export const PdvView = ({ selectedPayment && METHODS_WITH_INSTALLMENTS.includes(selectedPayment) && selectedMode !== "LINK"; const handleBarcodeScan = useCallback( - (detectedCodes: IDetectedBarcode[]) => { + (detectedCodes: BarcodeScannerDetectedCode[]) => { if (detectedCodes && detectedCodes.length > 0) { const code = detectedCodes[0].rawValue; if (code) onBarcodeScanned(code); @@ -673,7 +673,7 @@ function DiscountRow({ form, discountAmount }: DiscountRowProps) { interface BarcodeDrawerProps { open: boolean; onClose: () => void; - onScan: (codes: IDetectedBarcode[]) => void; + onScan: (codes: BarcodeScannerDetectedCode[]) => void; } function BarcodeDrawer({ open, onClose, onScan }: BarcodeDrawerProps) { @@ -692,7 +692,7 @@ function BarcodeDrawer({ open, onClose, onScan }: BarcodeDrawerProps) { onScan={onScan} onError={(err: unknown) => console.error("Camera error:", err)} styles={{ container: { width: "100%", height: "280px" }, video: { objectFit: "cover" } }} - components={{ onOff: false, torch: false, zoom: false, finder: true }} + components={{ finder: true }} />
+ + + } + > +
+
+ +
+

+ Produto inexistente +

+

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

+
+
+ {barcode && ( +
+

+ Código de barras +

+

+ {barcode} +

+
+ )} +
+ + ); +} diff --git a/app/(pages)/stock-movements/create/new-product/custom-attribute-validation.test.ts b/app/(pages)/stock-movements/create/new-product/custom-attribute-validation.test.ts new file mode 100644 index 0000000..d37e97f --- /dev/null +++ b/app/(pages)/stock-movements/create/new-product/custom-attribute-validation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + customAttributeFieldId, + findCustomAttributeError, +} from "./custom-attribute-validation"; +import type { CustomAttribute } from "@/components/product/custom-attributes-builder"; + +const attribute = ( + id: string, + key: string, + value: string, +): CustomAttribute => ({ id, key, value }); + +describe("findCustomAttributeError", () => { + it("returns null when every row has a name and a value", () => { + const attributes = [attribute("a", "cor", "azul"), attribute("b", "peso", "1kg")]; + + expect(findCustomAttributeError(attributes)).toBeNull(); + }); + + it("points at the empty name field of the first incomplete row", () => { + const attributes = [attribute("a", "cor", "azul"), attribute("b", "", "1kg")]; + + expect(findCustomAttributeError(attributes)).toEqual({ + index: 1, + field: "key", + message: "Atributo 2: Nome e valor são obrigatórios", + }); + }); + + it("points at the value field when only the value is missing", () => { + const attributes = [attribute("a", "cor", "")]; + + expect(findCustomAttributeError(attributes)).toEqual({ + index: 0, + field: "value", + message: "Atributo 1: Nome e valor são obrigatórios", + }); + }); + + it("flags a duplicate name on the second occurrence, case-insensitively", () => { + const attributes = [ + attribute("a", "Cor", "azul"), + attribute("b", "cor", "verde"), + ]; + + expect(findCustomAttributeError(attributes)).toEqual({ + index: 1, + field: "key", + message: 'Já existe um atributo com o nome "cor"', + }); + }); + + it("reports incomplete rows before duplicates", () => { + const attributes = [ + attribute("a", "cor", "azul"), + attribute("b", "cor", ""), + ]; + + expect(findCustomAttributeError(attributes)?.field).toBe("value"); + }); +}); + +describe("customAttributeFieldId", () => { + it("builds the DOM id used by CustomAttributesBuilder", () => { + expect(customAttributeFieldId(2, "key")).toBe("attr-key-2"); + expect(customAttributeFieldId(0, "value")).toBe("attr-value-0"); + }); +}); diff --git a/app/(pages)/stock-movements/create/new-product/custom-attribute-validation.ts b/app/(pages)/stock-movements/create/new-product/custom-attribute-validation.ts new file mode 100644 index 0000000..2d6d0b9 --- /dev/null +++ b/app/(pages)/stock-movements/create/new-product/custom-attribute-validation.ts @@ -0,0 +1,48 @@ +import type { CustomAttribute } from "@/components/product/custom-attributes-builder"; + +export interface CustomAttributeError { + index: number; + field: "key" | "value"; + message: string; +} + +/** DOM id of a custom attribute input, mirroring CustomAttributesBuilder. */ +export const customAttributeFieldId = ( + index: number, + field: "key" | "value", +): string => `attr-${field}-${index}`; + +/** + * Returns the first blocking error among the custom attribute rows — an + * incomplete row first, then a duplicate name — or null when all rows are + * valid. The returned `index`/`field` point at the input to scroll to. + */ +export const findCustomAttributeError = ( + attributes: CustomAttribute[], +): CustomAttributeError | null => { + const incompleteIndex = attributes.findIndex( + (attr) => !attr.key.trim() || !attr.value.trim(), + ); + if (incompleteIndex >= 0) { + const attribute = attributes[incompleteIndex]; + return { + index: incompleteIndex, + field: attribute.key.trim() ? "value" : "key", + message: `Atributo ${incompleteIndex + 1}: Nome e valor são obrigatórios`, + }; + } + + const keys = attributes.map((attr) => attr.key.trim().toLowerCase()); + const duplicateIndex = keys.findIndex( + (key, index) => keys.indexOf(key) !== index, + ); + if (duplicateIndex >= 0) { + return { + index: duplicateIndex, + field: "key", + message: `Já existe um atributo com o nome "${keys[duplicateIndex]}"`, + }; + } + + return null; +}; 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..59e86f0 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,9 +35,11 @@ const brandsResponse = { }; const createDraft = (overrides?: Partial): StockMovementDraft => ({ - schemaVersion: 1, + schemaVersion: 3, updatedAt: "2026-01-20T09:00:00.000Z", + revision: 1, type: "PURCHASE_IN", + warehouseId: "wh-1", notes: "", items: [], selectedProductId: "", @@ -45,6 +48,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 +86,8 @@ vi.mock("@/lib/api", () => ({ api: { get: (...args: unknown[]) => mockGet(...args), }, + isApiNotFoundError: (error: unknown) => + Boolean((error as { notFound?: boolean })?.notFound), })); vi.mock("next/navigation", () => ({ @@ -98,6 +106,7 @@ vi.mock("sonner", () => ({ toast: { success: (...args: unknown[]) => toastSuccess(...args), error: (...args: unknown[]) => toastError(...args), + warning: (...args: unknown[]) => toastWarning(...args), }, })); @@ -105,21 +114,34 @@ 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) => ({ + 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, - 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; }, })); @@ -240,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" }), }, }, }, @@ -296,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); }); @@ -501,19 +523,32 @@ 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 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(); }); expect(result.current.isScannerOpen).toBe(true); - act(() => { - result.current.handleBarcodeScan("123456789"); + await act(async () => { + await result.current.handleBarcodeScan("123456789"); }); + + 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(); @@ -521,6 +556,385 @@ 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 () => ({ + 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"); + }); + + 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"); + 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("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("avisa e não preenche barcode ao escanear produto novo já presente no inline", async () => { + currentDraft = createDraft({ + items: [ + { + quantity: 1, + productName: "Produto Inline", + newProductData: { name: "Produto Inline", barcode: "555000111" }, + }, + ], + }); + mockGet.mockReset(); + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => { + throw notFoundApiError(); + }), + })); + + const { result } = renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await act(async () => { + await result.current.handleBarcodeScan("555000111"); + }); + + expect(result.current.inlineDuplicateWarning).toBe( + 'O produto "Produto Inline" já está na lista de produtos da movimentação como um novo produto e não pode ser adicionado novamente.', + ); + expect(toastWarning).not.toHaveBeenCalled(); + expect(result.current.form.getValues("barcode")).not.toBe("555000111"); + expect(result.current.scannedExistingProduct).toBeNull(); + + act(() => { + result.current.onInlineDuplicateWarningOpenChange?.(false); + }); + expect(result.current.inlineDuplicateWarning).toBeNull(); + }); + + it("acusa falha ao insistir em adicionar produto inline já escaneado", async () => { + currentDraft = createDraft({ + items: [ + { + quantity: 1, + productName: "Produto Inline", + newProductData: { name: "Produto Inline", barcode: "555000111" }, + }, + ], + }); + mockGet.mockReset(); + mockGet.mockImplementation(() => ({ + json: vi.fn(async () => { + throw notFoundApiError(); + }), + })); + + const { result } = renderHook(() => + useNewProductInlineModel({ movementType, editItem: editItemQuery }), + ); + + await act(async () => { + await result.current.handleBarcodeScan("555000111"); + }); + expect(result.current.inlineDuplicateWarning).not.toBeNull(); + + await act(async () => { + await result.current.onSubmit( + buildFormData({ name: "Outro Nome", barcode: "555000111" }), + ); + }); + + expect(toastError).toHaveBeenCalledWith( + 'O código 555000111 já está em uso pelo produto "Produto Inline" 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 e7ee88c..f606bcd 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,19 +19,45 @@ 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, } 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 { + buildExistingProductBatchesUrl, + buildExistingProductProfitSummary, + buildExistingProductSalePriceSuggestion, + buildExistingProductCostPriceSuggestion, + findMostRecentWarehouseProductBatch, +} from "../stock-movement-batch-pricing.model"; +import { validateExistingProductBatchForm } from "../stock-movement-batch-form-validation"; +import { + buildRepeatedProductBatchWarning, + findDuplicateInlineProductError, + findScannedInlineProductDuplicateWarning, + getPendingInlineProductBarcodeConflictError, + hasExistingProductInItems, +} from "../stock-movement-draft-guards"; +import { lookupStockMovementProductByBarcode } from "../stock-movement-product-lookup"; +import { + scrollToFieldById, + scrollToFirstInvalidField, +} from "./scroll-to-first-invalid-field"; +import { + customAttributeFieldId, + findCustomAttributeError, +} from "./custom-attribute-validation"; const buildReturnHref = (type: string | null): string => { if (!isManualMovementType(type)) return "/stock-movements/create"; @@ -65,11 +92,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, @@ -83,22 +110,9 @@ 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 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, @@ -108,23 +122,49 @@ 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, + 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: "", @@ -133,20 +173,24 @@ const appendProductToMovementDraft = ( })); }; -const updateMovementDraft = async ( - buildNextDraft: (draft: StockMovementDraft) => StockMovementDraft, +const appendExistingProductBatchToDraft = async ( + item: StockMovementDraftItem, ): Promise => { - const draft = await readStockMovementDraft(); - if (!draft) return; - await writeStockMovementDraft(buildNextDraft(draft)); + await mutateStockMovementDraft((draft) => ({ + ...draft, + items: [...draft.items, item], + selectedProductId: "", + itemQuantity: "", + inlineProductBarcode: undefined, + })); }; -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 @@ -184,7 +228,11 @@ export const useNewProductInlineModel = ({ ); const [isScannerOpen, setIsScannerOpen] = useState(false); const [isAiModalOpen, setIsAiModalOpen] = useState(false); + const [scannedExistingProduct, setScannedExistingProduct] = useState(null); + const [inlineDuplicateWarning, setInlineDuplicateWarning] = 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 +251,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: { @@ -249,6 +319,7 @@ export const useNewProductInlineModel = ({ isInlineProductRouteReady( movementType, initialDraft, + warehouseId, editItemIndex, isEditingInlineProduct, ) @@ -263,6 +334,7 @@ export const useNewProductInlineModel = ({ isEditingInlineProduct, movementType, router, + warehouseId, ]); useEffect(() => { @@ -271,6 +343,7 @@ export const useNewProductInlineModel = ({ !isInlineProductRouteReady( movementType, initialDraft, + warehouseId, editItemIndex, isEditingInlineProduct, ) @@ -309,6 +382,7 @@ export const useNewProductInlineModel = ({ isDraftLoaded, isEditingInlineProduct, movementType, + warehouseId, ]); const addCustomAttribute = (): void => { @@ -339,22 +413,12 @@ export const useNewProductInlineModel = ({ }; const validateCustomAttributes = (): boolean => { - const invalidIndex = customAttributes.findIndex((attr) => { - return !attr.key.trim() || !attr.value.trim(); - }); - if (invalidIndex >= 0) { - toast.error(`Atributo ${invalidIndex + 1}: Nome e valor são obrigatórios`); - return false; - } + const error = findCustomAttributeError(customAttributes); + if (!error) return true; - 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}"`); - return false; - } - - return true; + toast.warning(error.message); + scrollToFieldById(customAttributeFieldId(error.index, error.field)); + return false; }; const mergeAttributes = (data: ProductCreateFormData): Record | undefined => { @@ -391,6 +455,135 @@ export const useNewProductInlineModel = ({ window.setTimeout(() => nameInputRef.current?.focus(), 100); }; + const handleBarcodeScan = async (barcode: string): Promise => { + const lookup = await lookupStockMovementProductByBarcode(barcode); + if (lookup.status === "found") { + setScannedExistingProduct({ + id: lookup.product.id, + name: lookup.product.name, + barcode, + }); + return; + } + if (lookup.status === "error") { + toast.error(lookup.message); + return; + } + + // Product is not in stock (online catalog): make sure it isn't already in + // the movement as an inline (new) product before filling the barcode field. + const draft = await readStockMovementDraft(); + const duplicateWarning = findScannedInlineProductDuplicateWarning( + draft?.items ?? [], + barcode, + editItemIndex, + ); + if (duplicateWarning) { + setInlineDuplicateWarning(duplicateWarning); + return; + } + + form.setValue("barcode", barcode); + }; + + const handleInlineDuplicateWarningOpenChange = (open: boolean): void => { + if (!open) setInlineDuplicateWarning(null); + }; + + const handleExistingProductModalOpenChange = (open: boolean): void => { + if (!open) { + setScannedExistingProduct(null); + } + }; + + 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); + }; + + 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 validationError = validateExistingProductBatchForm( + existingProductBatchForm, + ); + if (validationError) { + updateBatchForm({ error: validationError }); + 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, @@ -406,50 +599,80 @@ 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 = 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 handleInvalidSubmit = (): void => { + // Defer one frame so react-hook-form has committed `aria-invalid` to the DOM. + requestAnimationFrame(() => scrollToFirstInvalidField()); + }; + + 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 { @@ -461,6 +684,7 @@ export const useNewProductInlineModel = ({ mode: "inline", form, onSubmit, + onInvalidSubmit: handleInvalidSubmit, isSubmitting, categories: categoriesData?.data || [], isLoadingCategories, @@ -475,7 +699,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 +710,28 @@ export const useNewProductInlineModel = ({ isInlineEdit: isEditingInlineProduct, onQuantityIncrement, onQuantityDecrement, + scannedExistingProduct, + onExistingProductModalOpenChange: handleExistingProductModalOpenChange, + onCreateBatchForExistingProduct: handleCreateBatchForExistingProduct, + inlineDuplicateWarning, + onInlineDuplicateWarningOpenChange: handleInlineDuplicateWarningOpenChange, + 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: "" }} + /> ); } diff --git a/app/(pages)/stock-movements/create/new-product/scroll-to-first-invalid-field.test.ts b/app/(pages)/stock-movements/create/new-product/scroll-to-first-invalid-field.test.ts new file mode 100644 index 0000000..586047e --- /dev/null +++ b/app/(pages)/stock-movements/create/new-product/scroll-to-first-invalid-field.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + findFirstInvalidField, + scrollToFieldById, + scrollToFirstInvalidField, +} from "./scroll-to-first-invalid-field"; + +const rectAt = (top: number, height = 40): DOMRect => + ({ + top, + bottom: top + height, + height, + left: 0, + right: 0, + width: 0, + x: 0, + y: top, + toJSON: () => ({}), + }) as DOMRect; + +// jsdom's default viewport height is 768px. +const OFFSCREEN_RECT = rectAt(2000); +const VISIBLE_RECT = rectAt(100); + +const buildField = ( + id: string, + invalid: boolean, + rect: DOMRect = OFFSCREEN_RECT, +): HTMLInputElement => { + const input = document.createElement("input"); + input.id = id; + if (invalid) input.setAttribute("aria-invalid", "true"); + input.getBoundingClientRect = () => rect; + document.body.appendChild(input); + return input; +}; + +afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +describe("findFirstInvalidField", () => { + it("returns the topmost field flagged aria-invalid", () => { + buildField("name", false); + const quantity = buildField("quantity", true); + buildField("price", true); + + expect(findFirstInvalidField()).toBe(quantity); + }); + + it("returns null when no field is invalid", () => { + buildField("name", false); + + expect(findFirstInvalidField()).toBeNull(); + }); +}); + +describe("scrollToFirstInvalidField", () => { + it("centers the first off-screen invalid field in the viewport", () => { + buildField("name", false); + const quantity = buildField("quantity", true); + const scrollIntoView = vi.fn(); + quantity.scrollIntoView = scrollIntoView; + + scrollToFirstInvalidField(); + + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "center", + behavior: "smooth", + }); + }); + + it("leaves an already fully visible invalid field untouched", () => { + const quantity = buildField("quantity", true, VISIBLE_RECT); + const scrollIntoView = vi.fn(); + quantity.scrollIntoView = scrollIntoView; + + scrollToFirstInvalidField(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("does nothing when there is no invalid field", () => { + const valid = buildField("name", false); + const scrollIntoView = vi.fn(); + valid.scrollIntoView = scrollIntoView; + + scrollToFirstInvalidField(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); +}); + +describe("scrollToFieldById", () => { + it("centers the off-screen field with the matching id", () => { + buildField("attr-key-0", false); + const target = buildField("attr-value-1", false); + const scrollIntoView = vi.fn(); + target.scrollIntoView = scrollIntoView; + + scrollToFieldById("attr-value-1"); + + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "center", + behavior: "smooth", + }); + }); + + it("leaves an already fully visible field untouched", () => { + const target = buildField("attr-value-1", false, VISIBLE_RECT); + const scrollIntoView = vi.fn(); + target.scrollIntoView = scrollIntoView; + + scrollToFieldById("attr-value-1"); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("does nothing when no field matches the id", () => { + const field = buildField("attr-key-0", false); + const scrollIntoView = vi.fn(); + field.scrollIntoView = scrollIntoView; + + scrollToFieldById("attr-key-9"); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); +}); diff --git a/app/(pages)/stock-movements/create/new-product/scroll-to-first-invalid-field.ts b/app/(pages)/stock-movements/create/new-product/scroll-to-first-invalid-field.ts new file mode 100644 index 0000000..d937b84 --- /dev/null +++ b/app/(pages)/stock-movements/create/new-product/scroll-to-first-invalid-field.ts @@ -0,0 +1,43 @@ +const INVALID_FIELD_SELECTOR = '[aria-invalid="true"]'; + +/** Whether the field sits fully within the viewport's vertical bounds. */ +const isFieldFullyVisible = (field: HTMLElement): boolean => { + const { top, bottom } = field.getBoundingClientRect(); + const viewportHeight = + window.innerHeight || document.documentElement.clientHeight; + return top >= 0 && bottom <= viewportHeight; +}; + +/** + * Centers a field in the viewport, but only when it is off-screen — a field + * already fully visible is left untouched. + */ +const scrollFieldIntoView = (field: HTMLElement): void => { + if (isFieldFullyVisible(field)) return; + field.scrollIntoView({ block: "center", behavior: "smooth" }); +}; + +/** + * Finds the first form control flagged invalid by react-hook-form's + * `aria-invalid` attribute, following DOM (top-to-bottom) order. + */ +export const findFirstInvalidField = ( + root: ParentNode = document, +): HTMLElement | null => root.querySelector(INVALID_FIELD_SELECTOR); + +/** Scrolls the first `aria-invalid` field into view when it is off-screen. */ +export const scrollToFirstInvalidField = (root: ParentNode = document): void => { + const field = findFirstInvalidField(root); + if (!field) return; + scrollFieldIntoView(field); +}; + +/** + * Scrolls the field with the given id into view when it is off-screen. + * Used for inputs that carry no `aria-invalid` (e.g. custom attribute rows). + */ +export const scrollToFieldById = (id: string, root: Document = document): void => { + const field = root.getElementById(id); + if (!(field instanceof HTMLElement)) return; + scrollFieldIntoView(field); +}; 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..afa1cf1 --- /dev/null +++ b/app/(pages)/stock-movements/create/stock-movement-draft-guards.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + buildRepeatedProductBatchWarning, + findDuplicateInlineProductError, + findScannedInlineProductDuplicateWarning, + 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("findScannedInlineProductDuplicateWarning", () => { + it("avisa quando barcode escaneado já pertence a produto novo do draft", () => { + expect( + findScannedInlineProductDuplicateWarning(draftItems, "789100000001"), + ).toBe( + 'O produto "Produto Novo A" já está na lista de produtos da movimentação como um novo produto e não pode ser adicionado novamente.', + ); + }); + + it("ignora o item em edição, barcode vazio e produto existente", () => { + expect( + findScannedInlineProductDuplicateWarning(draftItems, "789100000001", 0), + ).toBeNull(); + expect(findScannedInlineProductDuplicateWarning(draftItems, null)).toBeNull(); + expect(findScannedInlineProductDuplicateWarning(draftItems, " ")).toBeNull(); + expect( + findScannedInlineProductDuplicateWarning(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..145f03d --- /dev/null +++ b/app/(pages)/stock-movements/create/stock-movement-draft-guards.ts @@ -0,0 +1,76 @@ +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 findScannedInlineProductDuplicateWarning = ( + items: StockMovementDraftItem[], + barcode: string | null | undefined, + ignoredIndex: number | null = null, +): string | null => { + const normalizedBarcode = barcode?.trim(); + if (!normalizedBarcode) return null; + const conflictingItem = withoutIgnoredIndex(items, ignoredIndex).find( + (item) => item.newProductData?.barcode === normalizedBarcode, + ); + if (!conflictingItem) return null; + const productName = + conflictingItem.newProductData?.name ?? conflictingItem.productName ?? ""; + return `O produto "${productName}" já está na lista de produtos da movimentação como um novo produto e não pode ser adicionado novamente.`; +}; + +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/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..65d80ca --- /dev/null +++ b/app/(pages)/stock-movements/create/stock-movement-product-options.ts @@ -0,0 +1,38 @@ +import type { StockMovementProductOption } from "./create-stock-movement.types"; + +export interface ProductListResponse { + success: boolean; + data: + | { content: StockMovementProductOption[] } + | StockMovementProductOption[]; +} + +export const PRODUCT_SEARCH_LIMIT = 5; + +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)}`; +}; diff --git a/app/(pages)/stock-movements/create/stock-movement-scanner.view.tsx b/app/(pages)/stock-movements/create/stock-movement-scanner.view.tsx index aa37ac9..377ca7b 100644 --- a/app/(pages)/stock-movements/create/stock-movement-scanner.view.tsx +++ b/app/(pages)/stock-movements/create/stock-movement-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 { Button } from "@/components/ui/button"; @@ -24,7 +24,7 @@ export function StockMovementScanner({ onOpenChange, onScan, }: StockMovementScannerProps) { - const handleScan = (detectedCodes: IDetectedBarcode[]) => { + const handleScan = (detectedCodes: BarcodeScannerDetectedCode[]) => { const barcode = detectedCodes[0]?.rawValue; if (!barcode) return; onScan(barcode); @@ -67,12 +67,7 @@ export function StockMovementScanner({ 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-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, + }; +} 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..3e3c721 --- /dev/null +++ b/app/(pages)/stock-movements/create/use-stock-movement-scanner.model.ts @@ -0,0 +1,270 @@ +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; + inlineDuplicateWarning: string | null; + onInlineDuplicateWarningOpenChange: (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 [inlineDuplicateWarning, setInlineDuplicateWarning] = 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 showPendingInlineProductWarning = (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"; + setInlineDuplicateWarning( + `${productName} já está na movimentação como produto novo.`, + ); + return true; + }; + + const handleInlineDuplicateWarningOpenChange = (open: boolean): void => { + if (!open) setInlineDuplicateWarning(null); + }; + + 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") { + if (showPendingInlineProductWarning(barcode)) return; + 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, + inlineDuplicateWarning, + onInlineDuplicateWarningOpenChange: handleInlineDuplicateWarningOpenChange, + onCreateProductFromMissingModal: handleCreateProductFromMissingModal, + onCreateNewProduct: handleCreateNewProduct, + onEditNewProductItem: handleEditNewProductItem, + onEditExistingProductBatchData: handleEditExistingProductBatchData, + appendScannedProduct, + }; +} diff --git a/app/(pages)/stock-movements/stock-movements.model.test.ts b/app/(pages)/stock-movements/stock-movements.model.test.ts index 20fa474..6b1a800 100644 --- a/app/(pages)/stock-movements/stock-movements.model.test.ts +++ b/app/(pages)/stock-movements/stock-movements.model.test.ts @@ -33,7 +33,7 @@ const fakeSWR = vi.hoisted(() => { 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/[id]/validate/validate-transfer.view.tsx b/app/(pages)/transfers/[id]/validate/validate-transfer.view.tsx index 04cf8ea..bbe5eb9 100644 --- a/app/(pages)/transfers/[id]/validate/validate-transfer.view.tsx +++ b/app/(pages)/transfers/[id]/validate/validate-transfer.view.tsx @@ -7,7 +7,7 @@ import { type ReactNode, type SetStateAction, } from "react"; -import { type IDetectedBarcode } from "@yudiel/react-qr-scanner"; +import { type BarcodeScannerDetectedCode } from "@/components/product/barcode-scanner.types"; import { AlertCircle, AlertTriangle, @@ -40,7 +40,7 @@ import type { interface ValidateTransferViewState extends ValidateTransferViewProps { circumference: number; completedItems: number; - handleCameraScan: (detectedCodes: IDetectedBarcode[]) => void; + handleCameraScan: (detectedCodes: BarcodeScannerDetectedCode[]) => void; handleSubmit: (event: FormEvent) => void; radius: number; setShowScanner: Dispatch>; @@ -63,7 +63,7 @@ export function ValidateTransferView(props: ValidateTransferViewProps) { event.preventDefault(); props.onScan(); }; - const handleCameraScan = (detectedCodes: IDetectedBarcode[]) => { + const handleCameraScan = (detectedCodes: BarcodeScannerDetectedCode[]) => { if (!detectedCodes || detectedCodes.length === 0) return; props.onBarcodeChange(detectedCodes[0].rawValue); setShowScanner(false); @@ -542,12 +542,7 @@ function CameraScannerModal({ container: { width: "100%", height: "280px" }, video: { objectFit: "cover" }, }} - components={{ - onOff: false, - torch: false, - zoom: false, - finder: true, - }} + components={{ finder: true }} />
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..591bec2 --- /dev/null +++ b/app/(pages)/transfers/new/new-transfer-scanner.view.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { type BarcodeScannerDetectedCode } from "@/components/product/barcode-scanner.types"; +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: BarcodeScannerDetectedCode[]): 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..d2cf345 100644 --- a/app/(pages)/transfers/new/new-transfer.model.test.ts +++ b/app/(pages)/transfers/new/new-transfer.model.test.ts @@ -1,22 +1,49 @@ 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, + 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 +74,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(); @@ -90,6 +117,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(); @@ -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 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 batchContentResponse: BatchListResponse = { +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,95 @@ 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"); + }); +}); + 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 +411,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 +643,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, }, @@ -400,7 +656,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(); }); @@ -412,9 +668,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 +689,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 +713,7 @@ describe("useNewTransferModel", () => { notes: "Observação do lote", items: [ { - sourceBatchId: "batch-cafe", + sourceBatchId: "11111111-1111-4111-8111-111111111111", quantity: 2, }, ], @@ -480,15 +729,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..f7bb5a4 100644 --- a/app/(pages)/transfers/new/new-transfer.model.ts +++ b/app/(pages)/transfers/new/new-transfer.model.ts @@ -1,15 +1,32 @@ -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 { useFooterVisibility } from "@/hooks/footer-visibility/use-footer-visibility"; +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 +35,133 @@ 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()); +}; + +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 } = useFooterVisibility(); + const productSearchBlurTimeoutRef = + useRef | null>(null); + const lastScannedBarcodeRef = useRef(null); + const barcodeResetTimeoutRef = useRef | null>( + null, + ); useBreadcrumb({ title: "Nova Transferência", @@ -60,105 +184,257 @@ 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); + }; + }, []); 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 handleProductChange = (productId: string) => { - setSelectedProductId(productId); - setSelectedBatchId(""); - setItemQuantity(""); + 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 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 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 qty = Number(itemQuantity); - if (!qty || qty <= 0) { - setAddItemError("Quantidade inválida."); + 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."); + toast.warning("Selecione um estoque de origem."); return; } @@ -186,8 +462,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 +473,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.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/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..a04ca90 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({