Add product composition export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s

This commit is contained in:
Cauê Faleiros
2026-07-31 10:30:49 -03:00
parent 5559e9951a
commit b431802d50
5 changed files with 134 additions and 42 deletions

View File

@@ -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>) => {
if (!filters) return;
if (filters.marketplace) params.set('marketplace', filters.marketplace);

View File

@@ -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<ProductTypeFilter>('all');
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(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<ProductRow[]>(() => {
const days = getRangeDays(dateRange);
@@ -453,34 +460,68 @@ const Products = () => {
}}
/>
<button
onClick={() => {
const exportData = productsData.map(product => ({
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
'Descrição': product.name,
'SKUs': product.skuCount,
'Cores': product.colors.join(' | '),
'Tamanhos': product.sizes.join(' | '),
'Tipo de produto': getProductTypeConfig(product.productType).label,
'Cor principal': product.topColor,
'Tamanho principal': product.topSize,
'Status': product.riskLabel,
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
'Total Vendido (un.)': product.quantitySold,
'Estoque': product.stock,
'Média Diária': product.dailySales.toFixed(2).replace('.', ','),
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','),
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
}));
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
}}
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="Exportar para CSV"
>
<Download size={16} className="text-brand-primary" />
<span className="hidden sm:inline">Exportar</span>
</button>
<div ref={exportMenuRef} className="relative">
<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={() => {
const exportData = productsData.map(product => ({
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
'Descrição': product.name,
'SKUs': product.skuCount,
'Cores': product.colors.join(' | '),
'Tamanhos': product.sizes.join(' | '),
'Tipo de produto': getProductTypeConfig(product.productType).label,
'Cor principal': product.topColor,
'Tamanho principal': product.topSize,
'Status': product.riskLabel,
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
'Total Vendido (un.)': product.quantitySold,
'Estoque': product.stock,
'Média Diária': product.dailySales.toFixed(2).replace('.', ','),
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','),
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
}));
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
setIsExportMenuOpen(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"
>
Produtos (CSV)
</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>