From b431802d50d115b1ec92c356bdbed4db17eb0ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 31 Jul 2026 10:30:49 -0300 Subject: [PATCH] Add product composition export --- backend/routes/analyticsRoutes.js | 11 +- backend/services/productionOrderService.js | 17 ++- .../test/productCompositionService.test.js | 21 +++- src/dataService.ts | 24 ++++ src/pages/Products.tsx | 103 ++++++++++++------ 5 files changed, 134 insertions(+), 42 deletions(-) diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index 6748af9..5cbfe47 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -10,7 +10,7 @@ const { getProductDetailsAnalytics, getRfmAnalytics } = require('../services/analyticsService'); -const { getProductComposition } = require('../services/productionOrderService'); +const { getProductComposition, listProductCompositions } = require('../services/productionOrderService'); const router = express.Router(); @@ -69,6 +69,15 @@ router.get('/analytics/products/:productId/composition', verifyToken, async (req } }); +router.get('/analytics/product-compositions', verifyToken, async (req, res) => { + try { + res.json({ compositions: await listProductCompositions() }); + } catch (error) { + console.error('Error exporting product compositions:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + router.get('/analytics/clients', verifyToken, async (req, res) => { try { res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query))); diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index 301d43d..2876b1f 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -699,9 +699,8 @@ const mapProductCompositionRow = (row) => ({ })) : [] }); -const getProductComposition = async (productId, client = pool) => { +const findProductCompositions = async (productId, client = pool) => { const identity = normalizeText(productId); - if (!identity) return null; const result = await client.query(` SELECT @@ -751,15 +750,22 @@ const getProductComposition = async (productId, client = pool) => { FROM product_compositions composition WHERE composition.source = $1 AND ( - composition.finished_tiny_product_id = $2 + $2 = '' + OR composition.finished_tiny_product_id = $2 OR composition.finished_product_sku = UPPER($2) ) - LIMIT 1; + ORDER BY composition.finished_product_sku, composition.finished_product_description; `, [TINY_OLIST_V3_COMPOSITION_SOURCE, identity]); - return result.rows[0] ? mapProductCompositionRow(result.rows[0]) : null; + return result.rows.map(mapProductCompositionRow); }; +const getProductComposition = async (productId, client = pool) => ( + (await findProductCompositions(productId, client))[0] || null +); + +const listProductCompositions = async (client = pool) => findProductCompositions('', client); + const upsertTinyProductComposition = async (order, components) => { const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId); const finishedProductSku = normalizeSku(order.productSku); @@ -1051,6 +1057,7 @@ const updateProductionOrderStatus = async (id, status) => { module.exports = { createProductionOrders, getProductComposition, + listProductCompositions, listProductionOrders, normalizeStatus, upsertTinyProductionOrderDetail, diff --git a/backend/test/productCompositionService.test.js b/backend/test/productCompositionService.test.js index 76a6239..675e61f 100644 --- a/backend/test/productCompositionService.test.js +++ b/backend/test/productCompositionService.test.js @@ -13,6 +13,7 @@ const getRouteHandler = (router, method, path) => { const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync'); const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition'); +const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions'); const invokeHandler = async (handler, req) => { let statusCode = 200; @@ -132,18 +133,17 @@ const withFakeDatabase = async (run) => { } if (normalizedSql.includes('FROM product_compositions composition')) { - const composition = state.compositions.find(item => ( + const compositions = state.compositions.filter(item => ( item.source === params[0] - && (item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase()) + && (params[1] === '' || item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase()) )); - if (!composition) return { rows: [] }; return { - rows: [{ + rows: compositions.map(composition => ({ ...composition, components: state.compositionComponents .filter(item => item.product_composition_id === composition.id) .map(item => ({ ...item, component_product_id: null })) - }] + })) }; } @@ -243,6 +243,17 @@ test('product composition detail lookup returns stored components', async () => }); }); +test('product composition export endpoint returns every stored composition', async () => { + await withFakeDatabase(async () => { + await invokeHandler(tinySyncHandler, { body: compositionPayload() }); + const response = await invokeHandler(productCompositionsHandler, { params: {} }); + + assert.equal(response.statusCode, 200); + assert.equal(response.body.compositions.length, 1); + assert.equal(response.body.compositions[0].components.length, 2); + }); +}); + test('ordinary Tiny production-order payloads continue to create production orders', async () => { await withFakeDatabase(async (state) => { const response = await invokeHandler(tinySyncHandler, { body: { diff --git a/src/dataService.ts b/src/dataService.ts index 12e0056..ae8bace 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -551,6 +551,30 @@ export const fetchProductComposition = async (productId: string): Promise => { + const response = await authFetch('/analytics/product-compositions'); + const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null; + if (!response.ok) { + throw new Error(data?.error || 'Não foi possível exportar as composições.'); + } + + const compositions = data?.compositions || []; + const blob = new Blob([JSON.stringify({ + exportedAt: new Date().toISOString(), + compositions + }, null, 2)], { type: 'application/json;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `composicoes_produtos_${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + return compositions.length; +}; + const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial) => { if (!filters) return; if (filters.marketplace) params.set('marketplace', filters.marketplace); diff --git a/src/pages/Products.tsx b/src/pages/Products.tsx index 240fbdb..7dfc6fb 100644 --- a/src/pages/Products.tsx +++ b/src/pages/Products.tsx @@ -7,7 +7,7 @@ import SkuPlanningModal from '../components/SkuPlanningModal'; import ProductTypeBadge from '../components/ProductTypeBadge'; import RefreshStatus from '../components/RefreshStatus'; import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types'; -import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService'; +import { exportProductCompositions, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService'; import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; import { formatColorLabel } from '../displayFormatters'; import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification'; @@ -171,12 +171,15 @@ const Products = () => { const [productTypeFilter, setProductTypeFilter] = useState('all'); const [viewMode, setViewMode] = useState('sku'); const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); + const [isExportMenuOpen, setIsExportMenuOpen] = useState(false); const filterMenuRef = useRef(null); + const exportMenuRef = useRef(null); const [productAnalytics, setProductAnalytics] = useState([]); const [isLoading, setIsLoading] = useState(true); const [planningSettings, setPlanningSettings] = useState({ familyYields: {}, productOverrides: {} }); const [editingProduct, setEditingProduct] = useState(null); const [isSavingPlanning, setIsSavingPlanning] = useState(false); + const [isExportingCompositions, setIsExportingCompositions] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); @@ -236,16 +239,20 @@ const Products = () => { }; useEffect(() => { - if (!isFilterMenuOpen) return; + if (!isFilterMenuOpen && !isExportMenuOpen) return; const handlePointerDown = (event: PointerEvent) => { if (!filterMenuRef.current?.contains(event.target as Node)) { setIsFilterMenuOpen(false); } + if (!exportMenuRef.current?.contains(event.target as Node)) { + setIsExportMenuOpen(false); + } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setIsFilterMenuOpen(false); + setIsExportMenuOpen(false); } }; @@ -256,7 +263,7 @@ const Products = () => { document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; - }, [isFilterMenuOpen]); + }, [isFilterMenuOpen, isExportMenuOpen]); const productsData = useMemo(() => { const days = getRangeDays(dateRange); @@ -453,34 +460,68 @@ const Products = () => { }} /> - +
+ + {isExportMenuOpen && ( +
+ + +
+ )} +