Add product composition export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s
This commit is contained in:
@@ -10,7 +10,7 @@ const {
|
|||||||
getProductDetailsAnalytics,
|
getProductDetailsAnalytics,
|
||||||
getRfmAnalytics
|
getRfmAnalytics
|
||||||
} = require('../services/analyticsService');
|
} = require('../services/analyticsService');
|
||||||
const { getProductComposition } = require('../services/productionOrderService');
|
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
|
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
|
||||||
|
|||||||
@@ -699,9 +699,8 @@ const mapProductCompositionRow = (row) => ({
|
|||||||
})) : []
|
})) : []
|
||||||
});
|
});
|
||||||
|
|
||||||
const getProductComposition = async (productId, client = pool) => {
|
const findProductCompositions = async (productId, client = pool) => {
|
||||||
const identity = normalizeText(productId);
|
const identity = normalizeText(productId);
|
||||||
if (!identity) return null;
|
|
||||||
|
|
||||||
const result = await client.query(`
|
const result = await client.query(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -751,15 +750,22 @@ const getProductComposition = async (productId, client = pool) => {
|
|||||||
FROM product_compositions composition
|
FROM product_compositions composition
|
||||||
WHERE composition.source = $1
|
WHERE composition.source = $1
|
||||||
AND (
|
AND (
|
||||||
composition.finished_tiny_product_id = $2
|
$2 = ''
|
||||||
|
OR composition.finished_tiny_product_id = $2
|
||||||
OR composition.finished_product_sku = UPPER($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]);
|
`, [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 upsertTinyProductComposition = async (order, components) => {
|
||||||
const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId);
|
const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId);
|
||||||
const finishedProductSku = normalizeSku(order.productSku);
|
const finishedProductSku = normalizeSku(order.productSku);
|
||||||
@@ -1051,6 +1057,7 @@ const updateProductionOrderStatus = async (id, status) => {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
createProductionOrders,
|
createProductionOrders,
|
||||||
getProductComposition,
|
getProductComposition,
|
||||||
|
listProductCompositions,
|
||||||
listProductionOrders,
|
listProductionOrders,
|
||||||
normalizeStatus,
|
normalizeStatus,
|
||||||
upsertTinyProductionOrderDetail,
|
upsertTinyProductionOrderDetail,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const getRouteHandler = (router, method, path) => {
|
|||||||
|
|
||||||
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
|
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
|
||||||
const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
|
const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
|
||||||
|
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
|
||||||
|
|
||||||
const invokeHandler = async (handler, req) => {
|
const invokeHandler = async (handler, req) => {
|
||||||
let statusCode = 200;
|
let statusCode = 200;
|
||||||
@@ -132,18 +133,17 @@ const withFakeDatabase = async (run) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedSql.includes('FROM product_compositions composition')) {
|
if (normalizedSql.includes('FROM product_compositions composition')) {
|
||||||
const composition = state.compositions.find(item => (
|
const compositions = state.compositions.filter(item => (
|
||||||
item.source === params[0]
|
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 {
|
return {
|
||||||
rows: [{
|
rows: compositions.map(composition => ({
|
||||||
...composition,
|
...composition,
|
||||||
components: state.compositionComponents
|
components: state.compositionComponents
|
||||||
.filter(item => item.product_composition_id === composition.id)
|
.filter(item => item.product_composition_id === composition.id)
|
||||||
.map(item => ({ ...item, component_product_id: null }))
|
.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 () => {
|
test('ordinary Tiny production-order payloads continue to create production orders', async () => {
|
||||||
await withFakeDatabase(async (state) => {
|
await withFakeDatabase(async (state) => {
|
||||||
const response = await invokeHandler(tinySyncHandler, { body: {
|
const response = await invokeHandler(tinySyncHandler, { body: {
|
||||||
|
|||||||
@@ -551,6 +551,30 @@ export const fetchProductComposition = async (productId: string): Promise<Produc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const exportProductCompositions = async (): Promise<number> => {
|
||||||
|
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<ClientMetadataFilters>) => {
|
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
|
||||||
if (!filters) return;
|
if (!filters) return;
|
||||||
if (filters.marketplace) params.set('marketplace', filters.marketplace);
|
if (filters.marketplace) params.set('marketplace', filters.marketplace);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import SkuPlanningModal from '../components/SkuPlanningModal';
|
|||||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
|
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 { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
||||||
import { formatColorLabel } from '../displayFormatters';
|
import { formatColorLabel } from '../displayFormatters';
|
||||||
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
|
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||||
@@ -171,12 +171,15 @@ const Products = () => {
|
|||||||
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
|
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
|
||||||
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
|
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
|
||||||
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
||||||
|
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
|
||||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const exportMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
|
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||||
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
|
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
|
||||||
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
|
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
|
||||||
|
const [isExportingCompositions, setIsExportingCompositions] = useState(false);
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
@@ -236,16 +239,20 @@ const Products = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFilterMenuOpen) return;
|
if (!isFilterMenuOpen && !isExportMenuOpen) return;
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
if (!filterMenuRef.current?.contains(event.target as Node)) {
|
if (!filterMenuRef.current?.contains(event.target as Node)) {
|
||||||
setIsFilterMenuOpen(false);
|
setIsFilterMenuOpen(false);
|
||||||
}
|
}
|
||||||
|
if (!exportMenuRef.current?.contains(event.target as Node)) {
|
||||||
|
setIsExportMenuOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setIsFilterMenuOpen(false);
|
setIsFilterMenuOpen(false);
|
||||||
|
setIsExportMenuOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,7 +263,7 @@ const Products = () => {
|
|||||||
document.removeEventListener('pointerdown', handlePointerDown);
|
document.removeEventListener('pointerdown', handlePointerDown);
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isFilterMenuOpen]);
|
}, [isFilterMenuOpen, isExportMenuOpen]);
|
||||||
|
|
||||||
const productsData = useMemo<ProductRow[]>(() => {
|
const productsData = useMemo<ProductRow[]>(() => {
|
||||||
const days = getRangeDays(dateRange);
|
const days = getRangeDays(dateRange);
|
||||||
@@ -453,7 +460,21 @@ const Products = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div ref={exportMenuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExportMenuOpen(current => !current)}
|
||||||
|
aria-expanded={isExportMenuOpen}
|
||||||
|
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
||||||
|
title="Escolher exportação"
|
||||||
|
>
|
||||||
|
<Download size={16} className="text-brand-primary" />
|
||||||
|
<span>Exportar</span>
|
||||||
|
</button>
|
||||||
|
{isExportMenuOpen && (
|
||||||
|
<div className="absolute right-0 z-20 mt-2 w-56 overflow-hidden rounded-xl border border-dark-border bg-dark-card p-1 shadow-xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const exportData = productsData.map(product => ({
|
const exportData = productsData.map(product => ({
|
||||||
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
|
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
|
||||||
@@ -474,13 +495,33 @@ const Products = () => {
|
|||||||
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
|
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
|
||||||
}));
|
}));
|
||||||
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
|
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
setIsExportMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer"
|
||||||
title="Exportar para CSV"
|
|
||||||
>
|
>
|
||||||
<Download size={16} className="text-brand-primary" />
|
Produtos (CSV)
|
||||||
<span className="hidden sm:inline">Exportar</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isExportingCompositions}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsExportingCompositions(true);
|
||||||
|
try {
|
||||||
|
await exportProductCompositions();
|
||||||
|
setIsExportMenuOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : 'Não foi possível exportar as composições.');
|
||||||
|
} finally {
|
||||||
|
setIsExportingCompositions(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isExportingCompositions ? 'Preparando…' : 'Composições (JSON)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user