import { useEffect, useState } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom';
import { Package, DollarSign, Pencil, ReceiptText, Warehouse } from 'lucide-react';
import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker';
import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
import { getProductTypeConfig, resolveProductType } from '../productClassification';
import { getPlanningStock } from '../planningStock';
const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)';
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)';
const VARIANT_BAR_COLOR = '#52DFA0';
type ProductChartMetric = 'quantity' | 'revenue' | 'ticket';
type ProductMetricChartPoint = ProductDetailsAnalytics['chartData'][number] & {
selectedValue: number;
movingAverage?: number;
};
const formatDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
type CustomTooltipProps = {
active?: boolean;
payload?: Array<{
value: number;
dataKey?: string;
payload?: ProductMetricChartPoint;
}>;
label?: string;
metric: ProductChartMetric;
formatCurrency: (value: number) => string;
formatNumber: (value: number) => string;
isHourly: boolean;
dateBucket: DateBucket;
};
const CustomTooltip = ({ active, payload, label, metric, formatCurrency, formatNumber, isHourly, dateBucket }: CustomTooltipProps) => {
if (active && payload && payload.length) {
const primaryPayload = payload.find(item => item.dataKey === 'selectedValue') || payload[0];
const point = primaryPayload.payload;
const value = primaryPayload.value;
const displayValue = metric === 'quantity' ? `${formatNumber(value)} un.` : formatCurrency(value);
const displayLabel = isHourly ? String(label || '') : formatDateBucketLongLabel(String(label || ''), dateBucket);
return (
{displayLabel}
{displayValue}
{point && (
Unidades: {formatNumber(point.quantitySold ?? point.value ?? 0)}
Receita: {formatCurrency(point.revenue ?? 0)}
Pedidos: {formatNumber(point.orderCount ?? 0)}
)}
);
}
return null;
};
const ProductDetailsSkeleton = () => (
{[0, 1, 2, 3].map(item => (
))}
{[0, 1, 2, 3].map(item => (
))}
);
const ProductDetails = () => {
const { id } = useParams<{ id: string }>();
const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void
}>();
const [details, setDetails] = useState(null);
const [composition, setComposition] = useState(null);
const [isCompositionLoading, setIsCompositionLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [chartMetric, setChartMetric] = useState('quantity');
const [selectedProductBucket, setSelectedProductBucket] = useState(null);
const [planningSettings, setPlanningSettings] = useState({ familyYields: {}, productOverrides: {} });
const [isPlanningModalOpen, setIsPlanningModalOpen] = useState(false);
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
useEffect(() => {
let isMounted = true;
const loadPlanningSettings = async () => {
const settings = await fetchCuttingSettings();
if (isMounted) setPlanningSettings(settings);
};
void loadPlanningSettings();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
const loadProductDetails = async () => {
if (!id) {
if (isMounted) {
setDetails(null);
setComposition(null);
setIsLoading(false);
setIsCompositionLoading(false);
}
return;
}
setIsLoading(true);
setIsCompositionLoading(true);
const [productDetails, productComposition] = await Promise.all([
fetchProductDetailsAnalytics(id, dateRange),
fetchProductComposition(id)
]);
if (isMounted) {
setDetails(productDetails);
setComposition(productComposition);
setIsLoading(false);
setIsCompositionLoading(false);
}
};
void loadProductDetails();
return () => {
isMounted = false;
};
}, [dateRange, id]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
const formatNumber = (value: number) => {
return new Intl.NumberFormat('pt-BR').format(value);
};
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
const productOverrides = { ...planningSettings.productOverrides };
if (override) {
productOverrides[productId] = override;
} else {
delete productOverrides[productId];
}
const nextSettings = { ...planningSettings, productOverrides };
setIsSavingPlanning(true);
try {
const savedSettings = await saveCuttingSettings(nextSettings);
setPlanningSettings(savedSettings);
setIsPlanningModalOpen(false);
} finally {
setIsSavingPlanning(false);
}
};
if (isLoading && !details) {
return ;
}
if (!details?.productInfo) {
return (
Produto não encontrado.
Voltar para produtos
);
}
const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details;
const productType = resolveProductType(productInfo.name, planningSettings.productOverrides[productInfo.id]);
const productTypeConfig = getProductTypeConfig(productType);
const isRefreshing = isLoading && Boolean(details);
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
const isHourlyChart = isSingleDayRange && chartData.some(point => /h$|:/.test(point.date));
const dateBucket = isHourlyChart ? 'day' : getAutoDateBucket(dateRange);
const periodDays = getRangeDayCount(dateRange);
const dailyAverageSold = totalSold / periodDays;
const projectedStockDays = dailyAverageSold > 0 ? getPlanningStock(productInfo.stock) / dailyAverageSold : null;
const stockActionLabel = projectedStockDays === null
? 'Sem venda no período'
: projectedStockDays <= 7
? 'Reposição crítica'
: projectedStockDays <= 21
? 'Planejar reposição'
: 'Estoque confortável';
const metricConfig = {
quantity: {
label: 'Unidades',
title: `Volume por ${isHourlyChart ? 'Horário' : 'Data'}`,
subtitle: 'Quantidade vendida no período selecionado.',
tickFormatter: (value: number) => formatNumber(value)
},
revenue: {
label: 'Receita',
title: `Receita por ${isHourlyChart ? 'Horário' : 'Data'}`,
subtitle: 'Faturamento do produto no período selecionado.',
tickFormatter: (value: number) => value >= 1000 ? `${formatNumber(value / 1000)}k` : formatCurrency(value)
},
ticket: {
label: 'Ticket médio',
title: `Ticket médio por ${isHourlyChart ? 'Horário' : 'Data'}`,
subtitle: 'Receita média por pedido neste produto.',
tickFormatter: (value: number) => value >= 1000 ? `${formatNumber(value / 1000)}k` : formatCurrency(value)
}
} satisfies Record string;
}>;
const selectedMetric = metricConfig[chartMetric];
const metricChartData = (() => {
const bucketMap = new Map();
chartData.forEach(point => {
const sourceKey = isHourlyChart ? point.date : getDateBucketKey(point.date, dateBucket);
const current = bucketMap.get(sourceKey) || {
date: sourceKey,
value: 0,
quantitySold: 0,
revenue: 0,
orderCount: 0,
averageTicket: 0,
selectedValue: 0
};
const quantity = point.quantitySold ?? point.value ?? 0;
const revenue = point.revenue ?? 0;
const orderCount = point.orderCount ?? 0;
current.value = (current.value || 0) + quantity;
current.quantitySold = (current.quantitySold || 0) + quantity;
current.revenue = (current.revenue || 0) + revenue;
current.orderCount = (current.orderCount || 0) + orderCount;
current.averageTicket = current.orderCount ? current.revenue / current.orderCount : 0;
bucketMap.set(sourceKey, current);
});
const rows = [...bucketMap.values()]
.filter(point => (point.quantitySold || point.value || point.revenue || point.orderCount))
.sort((a, b) => a.date.localeCompare(b.date))
.map(point => ({
...point,
selectedValue: chartMetric === 'quantity'
? (point.quantitySold ?? point.value)
: chartMetric === 'revenue'
? (point.revenue ?? 0)
: (point.averageTicket ?? 0)
}));
const movingAverageWindow = isHourlyChart ? 0 : getMovingAverageWindow(dateBucket);
if (movingAverageWindow > 1) {
const values = rows.map(point => point.selectedValue);
rows.forEach((point, index) => {
point.movingAverage = averageRecentValues(values, index, movingAverageWindow);
});
}
return rows;
})();
const selectedProductPoint = selectedProductBucket
? metricChartData.find(point => point.date === selectedProductBucket)
: null;
const selectedProductBucketLabel = selectedProductPoint
? isHourlyChart
? selectedProductPoint.date
: formatDateBucketLongLabel(selectedProductPoint.date, dateBucket)
: '';
const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0);
return (
ID: #{productInfo.id}
{productInfo.name}
{productTypeConfig.description}
setIsPlanningModalOpen(true)}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
title={`Editar planejamento do SKU ${productInfo.id}`}
aria-label={`Editar planejamento do SKU ${productInfo.id}`}
>
Unidades Vendidas
{formatNumber(totalSold)}
{formatNumber(dailyAverageSold)} un./dia
Receita Total
{formatCurrency(totalRevenue)}
Ticket Médio
{formatCurrency(averageTicket)}
Estoque
{formatNumber(productInfo.stock)}
{projectedStockDays === null ? stockActionLabel : `${formatNumber(projectedStockDays)} dias · ${stockActionLabel}`}
Composição
{composition && (
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}
)}
{isCompositionLoading ? (
Carregando composição…
) : !composition ? (
Nenhuma composição sincronizada para este produto.
) : composition.components.length === 0 ? (
Esta composição não possui insumos.
) : (
Produto / insumo
SKU
Quantidade por unidade
Unidade
{composition.components.map(component => (
{component.productId ? (
{component.componentName}
) : component.componentName}
{component.componentSku || '—'}
{formatNumber(component.quantityPerUnit)}
{component.unit || '—'}
))}
)}
{selectedMetric.title}
{isHourlyChart
? selectedMetric.subtitle
: dateBucket === 'day'
? `${selectedMetric.subtitle} Média móvel de 7 dias.`
: dateBucket === 'week'
? 'Valores agrupados por semana com média móvel de 4 semanas.'
: 'Valores agrupados por mês com média móvel de 3 meses.'}
{(Object.keys(metricConfig) as ProductChartMetric[]).map(metric => (
setChartMetric(metric)}
className={`cursor-pointer rounded-lg px-3 py-1.5 text-xs font-bold transition-colors ${
chartMetric === metric
? 'bg-dark-card text-dark-text shadow-sm'
: 'text-dark-muted hover:text-dark-text'
}`}
>
{metricConfig[metric].label}
))}
{metricChartData.length === 0 ? (
Nenhuma venda no período selecionado.
) : (
{
if (event?.activeLabel) setSelectedProductBucket(String(event.activeLabel));
}}
>
(
isHourlyChart ? String(value) : formatDateBucketLabel(String(value), dateBucket)
)}
/>
selectedMetric.tickFormatter(Number(value))} />
} cursor={{ fill: CHART_CURSOR_COLOR }} />
{!isHourlyChart && metricChartData.some(point => point.movingAverage !== undefined) && (
)}
)}
{selectedProductPoint && (
{selectedProductBucketLabel}
Detalhe do ponto selecionado.
setSelectedProductBucket(null)}
className="h-8 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:text-dark-text"
>
Limpar
Unidades
{formatNumber(selectedProductPoint.quantitySold ?? selectedProductPoint.value ?? 0)}
Receita
{formatCurrency(selectedProductPoint.revenue ?? 0)}
Pedidos
{formatNumber(selectedProductPoint.orderCount ?? 0)}
Ticket
{formatCurrency(selectedProductPoint.averageTicket ?? 0)}
Cobertura
{projectedStockDays === null ? '-' : `${formatNumber(projectedStockDays)} dias`}
)}
Variações do Produto
Tamanhos, cores ou SKUs parecidos no mesmo período.
{formatNumber(totalOrders)} {totalOrders === 1 ? 'pedido' : 'pedidos'}
{variantBreakdown.length === 0 ? (
Nenhuma variação encontrada para este produto.
) : (
{variantBreakdown.map(variant => {
const width = maxVariantQuantity ? Math.max(4, (variant.quantitySold / maxVariantQuantity) * 100) : 0;
const metadata = parseProductName(variant.name);
return (
{variant.name}
#{variant.id}
{metadata.color && (
{formatColorLabel(metadata.color)}
)}
{metadata.size && (
Tam. {metadata.size}
)}
{formatNumber(variant.quantitySold)} un.
vendidas
{formatCurrency(variant.revenue)}
receita
);
})}
)}
{isPlanningModalOpen && (
setIsPlanningModalOpen(false)}
onSave={(override) => saveProductOverride(productInfo.id, override)}
/>
)}
);
};
export default ProductDetails;