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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user