Compare commits

...

2 Commits

Author SHA1 Message Date
Cauê Faleiros
948931886e Improve chart readability and drilldowns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
2026-07-14 13:55:59 -03:00
Cauê Faleiros
63efb47e77 Route SKU actions to focused editors 2026-07-14 13:24:25 -03:00
8 changed files with 535 additions and 100 deletions

View File

@@ -1,6 +1,7 @@
import type { DashboardAnalytics, DateRange, OrderData } from '../types'; import type { DashboardAnalytics, DateRange, OrderData } from '../types';
import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue, parseOrderDate } from './orders'; import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue, parseOrderDate } from './orders';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
import { normalizeUnknownLabel } from '../chartUtils';
const COLORS = [ const COLORS = [
'#25C2FF', '#18D6B5', '#A06BFF', '#FF6B8A', '#FFC247', '#25C2FF', '#18D6B5', '#A06BFF', '#FF6B8A', '#FFC247',
@@ -20,6 +21,10 @@ const getProductColor = (name: string): string => {
return globalColorMap[name]; return globalColorMap[name];
}; };
const formatSellerDisplayName = (value: string) => (
normalizeUnknownLabel(formatDisplayName(removeTrailingSellerId(value)), 'Sem vendedor')
);
export interface ChartProductMetric { export interface ChartProductMetric {
name: string; name: string;
id: string; id: string;
@@ -77,23 +82,23 @@ export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetr
})), })),
revenueBySeller: (metrics.revenueBySeller || []).map(seller => ({ revenueBySeller: (metrics.revenueBySeller || []).map(seller => ({
...seller, ...seller,
name: formatDisplayName(removeTrailingSellerId(seller.name)), name: formatSellerDisplayName(seller.name),
fill: productColors[seller.name] fill: productColors[seller.name]
})), })),
ordersBySeller: (metrics.ordersBySeller || []).map(seller => ({ ordersBySeller: (metrics.ordersBySeller || []).map(seller => ({
...seller, ...seller,
name: formatDisplayName(removeTrailingSellerId(seller.name)), name: formatSellerDisplayName(seller.name),
fill: productColors[seller.name] fill: productColors[seller.name]
})), })),
sellerRevenueByDate: (metrics.sellerRevenueByDate || []).map(seller => ({ sellerRevenueByDate: (metrics.sellerRevenueByDate || []).map(seller => ({
...seller, ...seller,
name: formatDisplayName(removeTrailingSellerId(seller.name)), name: formatSellerDisplayName(seller.name),
orders: seller.orders || 0, orders: seller.orders || 0,
fill: productColors[seller.name] fill: productColors[seller.name]
})), })),
sellerRevenueByHour: (metrics.sellerRevenueByHour || []).map(seller => ({ sellerRevenueByHour: (metrics.sellerRevenueByHour || []).map(seller => ({
...seller, ...seller,
name: formatDisplayName(removeTrailingSellerId(seller.name)), name: formatSellerDisplayName(seller.name),
orders: seller.orders || 0, orders: seller.orders || 0,
fill: productColors[seller.name] fill: productColors[seller.name]
})) }))
@@ -118,7 +123,7 @@ export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRa
filteredData.forEach(order => { filteredData.forEach(order => {
const itemRevenue = getOrderItemRevenue(order); const itemRevenue = getOrderItemRevenue(order);
const productName = getBaseProductName(order.Descricao_Produto); const productName = getBaseProductName(order.Descricao_Produto);
const sellerName = formatDisplayName(removeTrailingSellerId(order.nome_vendedor || '')); const sellerName = formatSellerDisplayName(order.nome_vendedor || '');
const sellerId = order.id_vendedor || sellerName; const sellerId = order.id_vendedor || sellerName;
const orderKey = order.ID_Pedido || `${order.Nome_Cliente}_${order.Data_Pedido}_${order.Valor_Pedido}`; const orderKey = order.ID_Pedido || `${order.Nome_Cliente}_${order.Data_Pedido}_${order.Valor_Pedido}`;

View File

@@ -12,3 +12,15 @@ export const buildSkuEditPath = ({ sku, name = '', color = '', size = '' }: SkuE
if (size) params.set('size', size); if (size) params.set('size', size);
return `/registrations?${params.toString()}`; return `/registrations?${params.toString()}`;
}; };
export const buildCuttingSkuConfigPath = ({ sku }: Pick<SkuEditParams, 'sku'>) => {
const params = new URLSearchParams({ config: 'corrections', sku });
return `/cutting?${params.toString()}`;
};
export const buildConsumptionReferencePath = ({ sku, name = '', color = '' }: Omit<SkuEditParams, 'size'>) => {
const params = new URLSearchParams({ tab: 'references', sku });
if (name) params.set('name', name);
if (color) params.set('color', color);
return `/registrations?${params.toString()}`;
};

125
src/chartUtils.ts Normal file
View File

@@ -0,0 +1,125 @@
import type { DateRange } from './types';
export type DateBucket = 'day' | 'week' | 'month';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export const getRangeDayCount = (dateRange: DateRange) => (
Math.max(1, Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / MS_PER_DAY) + 1)
);
export const getAutoDateBucket = (dateRange: DateRange): DateBucket => {
const days = getRangeDayCount(dateRange);
if (days > 365) return 'month';
if (days > 90) return 'week';
return 'day';
};
export const parseChartDate = (value: string): Date | null => {
if (!value) return null;
const isoMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (isoMatch) {
const [, year, month, day] = isoMatch;
return new Date(Number(year), Number(month) - 1, Number(day));
}
const localMatch = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (localMatch) {
const [, day, month, year] = localMatch;
return new Date(Number(year), Number(month) - 1, Number(day));
}
const dashedLocalMatch = value.match(/^(\d{2})-(\d{2})-(\d{4})$/);
if (dashedLocalMatch) {
const [, day, month, year] = dashedLocalMatch;
return new Date(Number(year), Number(month) - 1, Number(day));
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
};
export const formatChartDateKey = (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}`;
};
const startOfWeek = (date: Date) => {
const nextDate = new Date(date);
const day = nextDate.getDay();
const offset = day === 0 ? -6 : 1 - day;
nextDate.setDate(nextDate.getDate() + offset);
nextDate.setHours(0, 0, 0, 0);
return nextDate;
};
export const getDateBucketKey = (value: string, bucket: DateBucket) => {
const date = parseChartDate(value);
if (!date) return value;
if (bucket === 'day') return formatChartDateKey(date);
if (bucket === 'week') return formatChartDateKey(startOfWeek(date));
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
};
export const formatDateBucketLabel = (value: string, bucket: DateBucket) => {
if (bucket === 'month') {
const match = value.match(/^(\d{4})-(\d{2})$/);
if (!match) return value;
const [, year, month] = match;
return new Intl.DateTimeFormat('pt-BR', { month: 'short', year: '2-digit' }).format(new Date(Number(year), Number(month) - 1, 1));
}
const date = parseChartDate(value);
if (!date) return value;
if (bucket === 'week') {
const endDate = new Date(date);
endDate.setDate(endDate.getDate() + 6);
const formatter = new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit' });
return `${formatter.format(date)}-${formatter.format(endDate)}`;
}
return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit' }).format(date);
};
export const formatDateBucketLongLabel = (value: string, bucket: DateBucket) => {
if (bucket === 'month') {
const match = value.match(/^(\d{4})-(\d{2})$/);
if (!match) return value;
const [, year, month] = match;
return new Intl.DateTimeFormat('pt-BR', { month: 'long', year: 'numeric' }).format(new Date(Number(year), Number(month) - 1, 1));
}
const date = parseChartDate(value);
if (!date) return value;
if (bucket === 'week') {
const endDate = new Date(date);
endDate.setDate(endDate.getDate() + 6);
const formatter = new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' });
return `${formatter.format(date)} - ${formatter.format(endDate)}`;
}
return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
};
export const getMovingAverageWindow = (bucket: DateBucket) => {
if (bucket === 'day') return 7;
if (bucket === 'week') return 4;
return 3;
};
export const averageRecentValues = (values: number[], endIndex: number, windowSize: number) => {
const startIndex = Math.max(0, endIndex - windowSize + 1);
const windowValues = values.slice(startIndex, endIndex + 1);
if (!windowValues.length) return 0;
return windowValues.reduce((total, value) => total + value, 0) / windowValues.length;
};
export const normalizeUnknownLabel = (value: string, fallback: string) => {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
if (!normalized) return fallback;
if (/^(0|null|undefined|unknown|n\/a|-|sem nome)$/i.test(normalized)) return fallback;
return normalized;
};

View File

@@ -1,11 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Palette, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react'; import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Palette, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import ProductColorBadge from '../components/ProductColorBadge'; import ProductColorBadge from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import { buildSkuEditPath } from '../catalogLinks'; import { buildCuttingSkuConfigPath } from '../catalogLinks';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService'; import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
@@ -117,6 +117,7 @@ const Cutting = () => {
dateRange: DateRange, dateRange: DateRange,
setDateRange: (range: DateRange) => void setDateRange: (range: DateRange) => void
}>(); }>();
const [searchParams] = useSearchParams();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]); const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]); const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -134,6 +135,8 @@ const Cutting = () => {
const [correctionPage, setCorrectionPage] = useState(1); const [correctionPage, setCorrectionPage] = useState(1);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const cutConfigSection = searchParams.get('config');
const targetCorrectionSku = (searchParams.get('sku') || '').trim();
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -249,13 +252,20 @@ const Cutting = () => {
(correctionIssueFilter === 'all' || row.issues.includes(correctionIssueFilter)) (correctionIssueFilter === 'all' || row.issues.includes(correctionIssueFilter))
)); ));
return [...rows].sort((a, b) => { const sortedRows = [...rows].sort((a, b) => {
if (b.suggestedCutQuantity !== a.suggestedCutQuantity) { if (b.suggestedCutQuantity !== a.suggestedCutQuantity) {
return b.suggestedCutQuantity - a.suggestedCutQuantity; return b.suggestedCutQuantity - a.suggestedCutQuantity;
} }
return a.name.localeCompare(b.name, 'pt-BR'); return a.name.localeCompare(b.name, 'pt-BR');
}); });
}, [correctionIssueFilter, cutPlan.rows]);
if (!targetCorrectionSku) return sortedRows;
const targetRow = cutPlan.rows.find(row => row.id.toLowerCase() === targetCorrectionSku.toLowerCase());
if (!targetRow) return sortedRows;
return [targetRow, ...sortedRows.filter(row => row.id !== targetRow.id)];
}, [correctionIssueFilter, cutPlan.rows, targetCorrectionSku]);
const correctionItemsPerPage = 12; const correctionItemsPerPage = 12;
const correctionTotalPages = Math.ceil(correctionRows.length / correctionItemsPerPage); const correctionTotalPages = Math.ceil(correctionRows.length / correctionItemsPerPage);
const safeCorrectionPage = Math.min(correctionPage, correctionTotalPages || 1); const safeCorrectionPage = Math.min(correctionPage, correctionTotalPages || 1);
@@ -264,6 +274,20 @@ const Cutting = () => {
const configuredYieldCount = CUT_FAMILY_RULES.filter(rule => cuttingSettings.familyYields[rule.key]).length; const configuredYieldCount = CUT_FAMILY_RULES.filter(rule => cuttingSettings.familyYields[rule.key]).length;
const productOverrideCount = Object.keys(cuttingSettings.productOverrides).length; const productOverrideCount = Object.keys(cuttingSettings.productOverrides).length;
useEffect(() => {
if (cutConfigSection !== 'corrections' || !targetCorrectionSku) return;
const correctionIndex = correctionRows.findIndex(row => row.id.toLowerCase() === targetCorrectionSku.toLowerCase());
queueMicrotask(() => {
setIsSettingsOpen(true);
setSettingsSection('corrections');
setCorrectionIssueFilter('all');
if (correctionIndex >= 0) {
setCorrectionPage(Math.floor(correctionIndex / correctionItemsPerPage) + 1);
}
});
}, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]);
useEffect(() => { useEffect(() => {
if (!isSettingsOpen) return undefined; if (!isSettingsOpen) return undefined;
@@ -586,8 +610,12 @@ const Cutting = () => {
<div className="divide-y divide-dark-border"> <div className="divide-y divide-dark-border">
{paginatedCorrectionRows.map(row => { {paginatedCorrectionRows.map(row => {
const override = cuttingSettings.productOverrides[row.id] || {}; const override = cuttingSettings.productOverrides[row.id] || {};
const isTargetRow = targetCorrectionSku.toLowerCase() === row.id.toLowerCase();
return ( return (
<div key={row.id} className="grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] items-center gap-3 px-4 py-3"> <div
key={row.id}
className={`grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] items-center gap-3 px-4 py-3 ${isTargetRow ? 'bg-brand-primary/10 ring-1 ring-inset ring-brand-primary/35' : ''}`}
>
<span className="font-mono text-[11px] text-dark-muted">#{row.id}</span> <span className="font-mono text-[11px] text-dark-muted">#{row.id}</span>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-xs font-bold text-dark-text" title={row.name}>{row.name}</div> <div className="truncate text-xs font-bold text-dark-text" title={row.name}>{row.name}</div>
@@ -948,10 +976,10 @@ const Cutting = () => {
<td className="px-4 py-2.5 text-right"> <td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Link <Link
to={buildSkuEditPath({ sku: row.id, name: row.name, color: row.color, size: row.size })} to={buildCuttingSkuConfigPath({ sku: row.id })}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border" className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Editar SKU ${row.id}`} title={`Configurar corte do SKU ${row.id}`}
aria-label={`Editar SKU ${row.id}`} aria-label={`Configurar corte do SKU ${row.id}`}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Link> </Link>

View File

@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useOutletContext, useNavigate } from 'react-router-dom'; import { useOutletContext, useNavigate } from 'react-router-dom';
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Line } from 'recharts';
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react'; import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { DashboardAnalytics, OrderData, DateRange } from '../types'; import type { DashboardAnalytics, OrderData, DateRange } from '../types';
import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard'; import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
import { fetchDashboardAnalytics } from '../dataService'; import { fetchDashboardAnalytics } from '../dataService';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getDateBucketKey, getMovingAverageWindow, type DateBucket } from '../chartUtils';
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
@@ -23,20 +24,6 @@ const formatCompactCurrency = (value: number) => {
return formatNumber(value); return formatNumber(value);
}; };
const formatDateTick = (value: string) => {
if (!value) return '';
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit' }).format(date);
};
const formatDateLabel = (value: string) => {
if (!value) return '';
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
};
const formatHourKey = (hour: number) => `${String(hour).padStart(2, '0')}h`; const formatHourKey = (hour: number) => `${String(hour).padStart(2, '0')}h`;
const formatDateKey = (date: Date) => { const formatDateKey = (date: Date) => {
@@ -80,7 +67,8 @@ type SellerTimeSeriesSeller = {
type SellerTimeSeriesChartData = { type SellerTimeSeriesChartData = {
date: string; date: string;
[key: string]: string | number; movingAverage?: number;
[key: string]: string | number | undefined;
}; };
type SellerMetricConfig = { type SellerMetricConfig = {
@@ -253,9 +241,11 @@ type SellerMetricTooltipProps = {
focusedSellerId: string | null; focusedSellerId: string | null;
sellers: SellerTimeSeriesSeller[]; sellers: SellerTimeSeriesSeller[];
metricConfig: SellerMetricConfig; metricConfig: SellerMetricConfig;
isHourly: boolean;
dateBucket: DateBucket;
}; };
const SellerMetricTooltip = ({ active, payload, label, focusedSellerId, sellers, metricConfig }: SellerMetricTooltipProps) => { const SellerMetricTooltip = ({ active, payload, label, focusedSellerId, sellers, metricConfig, isHourly, dateBucket }: SellerMetricTooltipProps) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
const sellersByKey = new Map(sellers.map(seller => [seller.seriesKey, seller])); const sellersByKey = new Map(sellers.map(seller => [seller.seriesKey, seller]));
@@ -279,7 +269,9 @@ const SellerMetricTooltip = ({ active, payload, label, focusedSellerId, sellers,
className="min-w-56 rounded-xl border p-3 shadow-lg" className="min-w-56 rounded-xl border p-3 shadow-lg"
style={{ backgroundColor: 'var(--chart-tooltip-bg)', borderColor: 'var(--chart-tooltip-border)' }} style={{ backgroundColor: 'var(--chart-tooltip-bg)', borderColor: 'var(--chart-tooltip-border)' }}
> >
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-dark-muted">{formatDateLabel(String(label || ''))}</p> <p className="mb-2 text-xs font-bold uppercase tracking-wide text-dark-muted">
{isHourly ? String(label || '') : formatDateBucketLongLabel(String(label || ''), dateBucket)}
</p>
<div className="space-y-2 text-sm" style={{ color: 'var(--chart-tooltip-text)' }}> <div className="space-y-2 text-sm" style={{ color: 'var(--chart-tooltip-text)' }}>
{rows.length ? rows.map(({ seller, value }) => ( {rows.length ? rows.map(({ seller, value }) => (
<div key={`seller-tooltip-${seller.id}`} className="flex items-center justify-between gap-4"> <div key={`seller-tooltip-${seller.id}`} className="flex items-center justify-between gap-4">
@@ -310,6 +302,7 @@ const Dashboard = () => {
const [isMetricsLoading, setIsMetricsLoading] = useState(true); const [isMetricsLoading, setIsMetricsLoading] = useState(true);
const [focusedSellerId, setFocusedSellerId] = useState<string | null>(null); const [focusedSellerId, setFocusedSellerId] = useState<string | null>(null);
const [sellerMetric, setSellerMetric] = useState<SellerMetricKey>('revenue'); const [sellerMetric, setSellerMetric] = useState<SellerMetricKey>('revenue');
const [selectedSellerBucket, setSelectedSellerBucket] = useState<string | null>(null);
const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => { const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => {
setIsMetricsLoading(true); setIsMetricsLoading(true);
@@ -374,6 +367,7 @@ const Dashboard = () => {
const sellerTimeSeries = useMemo(() => { const sellerTimeSeries = useMemo(() => {
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end); const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
const isHourly = isSingleDayRange && chartSellerRevenueByHour.length > 0; const isHourly = isSingleDayRange && chartSellerRevenueByHour.length > 0;
const dateBucket = getAutoDateBucket(dateRange);
const activeTrendPoints = isHourly const activeTrendPoints = isHourly
? chartSellerRevenueByHour.map(point => ({ ? chartSellerRevenueByHour.map(point => ({
...point, ...point,
@@ -438,7 +432,7 @@ const Dashboard = () => {
return row; return row;
}); });
return { sellers, chartData, isFallback: true, isHourly }; return { sellers, chartData, isFallback: true, isHourly, dateBucket, movingAverageWindow: 0 };
} }
const sellersById = new Map<string, Omit<SellerTimeSeriesSeller, 'seriesKey' | 'total'>>(); const sellersById = new Map<string, Omit<SellerTimeSeriesSeller, 'seriesKey' | 'total'>>();
@@ -446,6 +440,7 @@ const Dashboard = () => {
activeTrendPoints.forEach(point => { activeTrendPoints.forEach(point => {
const id = point.id || point.name; const id = point.id || point.name;
const bucketKey = isHourly ? point.date : getDateBucketKey(point.date, dateBucket);
const existing = sellersById.get(id); const existing = sellersById.get(id);
sellersById.set(id, { sellersById.set(id, {
id, id,
@@ -455,10 +450,10 @@ const Dashboard = () => {
orders: (existing?.orders || 0) + (point.orders || 0) orders: (existing?.orders || 0) + (point.orders || 0)
}); });
if (!valuesByDate.has(point.date)) { if (!valuesByDate.has(bucketKey)) {
valuesByDate.set(point.date, new Map()); valuesByDate.set(bucketKey, new Map());
} }
const dateValues = valuesByDate.get(point.date); const dateValues = valuesByDate.get(bucketKey);
if (dateValues) { if (dateValues) {
const existingValue = dateValues.get(id); const existingValue = dateValues.get(id);
dateValues.set(id, { dateValues.set(id, {
@@ -497,11 +492,38 @@ const Dashboard = () => {
return row; return row;
}); });
return { sellers, chartData, isFallback: false, isHourly }; const movingAverageWindow = isHourly ? 0 : getMovingAverageWindow(dateBucket);
}, [chartOrdersBySeller, chartRevenueBySeller, chartSellerRevenueByDate, chartSellerRevenueByHour, dateRange.end, dateRange.start, sellerColorMap, sellerMetric]); const totals = chartData.map(row => (
sellers.reduce((total, seller) => total + Number(row[seller.seriesKey] || 0), 0)
));
if (movingAverageWindow > 1) {
chartData.forEach((row, index) => {
row.movingAverage = averageRecentValues(totals, index, movingAverageWindow);
});
}
return { sellers, chartData, isFallback: false, isHourly, dateBucket, movingAverageWindow };
}, [chartOrdersBySeller, chartRevenueBySeller, chartSellerRevenueByDate, chartSellerRevenueByHour, dateRange, sellerColorMap, sellerMetric]);
const focusedSeller = focusedSellerId ? sellerTimeSeries.sellers.find(seller => seller.id === focusedSellerId) : null; const focusedSeller = focusedSellerId ? sellerTimeSeries.sellers.find(seller => seller.id === focusedSellerId) : null;
const effectiveFocusedSellerId = focusedSeller?.id || null; const effectiveFocusedSellerId = focusedSeller?.id || null;
const selectedSellerRow = selectedSellerBucket
? sellerTimeSeries.chartData.find(row => row.date === selectedSellerBucket)
: null;
const selectedSellerBreakdown = selectedSellerRow
? sellerTimeSeries.sellers
.map(seller => ({
seller,
value: Number(selectedSellerRow[seller.seriesKey] || 0)
}))
.filter(item => item.value > 0)
.sort((a, b) => b.value - a.value)
: [];
const selectedSellerBucketLabel = selectedSellerRow
? sellerTimeSeries.isHourly
? selectedSellerRow.date
: formatDateBucketLongLabel(selectedSellerRow.date, sellerTimeSeries.dateBucket)
: '';
const handleManualRefresh = () => { const handleManualRefresh = () => {
void loadDashboardMetrics(dateRange, { force: true }); void loadDashboardMetrics(dateRange, { force: true });
@@ -580,7 +602,11 @@ const Dashboard = () => {
? 'Evolução por horário no dia selecionado.' ? 'Evolução por horário no dia selecionado.'
: sellerTimeSeries.isFallback : sellerTimeSeries.isFallback
? sellerMetricConfig.fallbackDescription ? sellerMetricConfig.fallbackDescription
: sellerMetricConfig.trendDescription} : sellerTimeSeries.dateBucket === 'day'
? `${sellerMetricConfig.trendDescription} Média móvel de 7 dias.`
: sellerTimeSeries.dateBucket === 'week'
? 'Evolução agrupada por semana. Média móvel de 4 semanas.'
: 'Evolução agrupada por mês. Média móvel de 3 meses.'}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -616,7 +642,13 @@ const Dashboard = () => {
<div> <div>
<div className="h-96 min-w-0"> <div className="h-96 min-w-0">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={sellerTimeSeries.chartData} margin={{ top: 14, right: 24, left: 4, bottom: 12 }}> <AreaChart
data={sellerTimeSeries.chartData}
margin={{ top: 14, right: 24, left: 4, bottom: 12 }}
onClick={(event) => {
if (event?.activeLabel) setSelectedSellerBucket(String(event.activeLabel));
}}
>
<defs> <defs>
{sellerTimeSeries.sellers.map(seller => ( {sellerTimeSeries.sellers.map(seller => (
<linearGradient key={`seller-gradient-${seller.seriesKey}`} id={`seller-gradient-${seller.seriesKey}`} x1="0" y1="0" x2="0" y2="1"> <linearGradient key={`seller-gradient-${seller.seriesKey}`} id={`seller-gradient-${seller.seriesKey}`} x1="0" y1="0" x2="0" y2="1">
@@ -634,7 +666,11 @@ const Dashboard = () => {
axisLine={false} axisLine={false}
minTickGap={sellerTimeSeries.isHourly ? 10 : 18} minTickGap={sellerTimeSeries.isHourly ? 10 : 18}
interval={sellerTimeSeries.isHourly ? 2 : undefined} interval={sellerTimeSeries.isHourly ? 2 : undefined}
tickFormatter={formatDateTick} tickFormatter={(value) => (
sellerTimeSeries.isHourly
? String(value)
: formatDateBucketLabel(String(value), sellerTimeSeries.dateBucket)
)}
/> />
<YAxis <YAxis
stroke={CHART_AXIS_COLOR} stroke={CHART_AXIS_COLOR}
@@ -645,9 +681,24 @@ const Dashboard = () => {
width={54} width={54}
/> />
<Tooltip <Tooltip
content={<SellerMetricTooltip focusedSellerId={effectiveFocusedSellerId} sellers={sellerTimeSeries.sellers} metricConfig={sellerMetricConfig} />} content={<SellerMetricTooltip focusedSellerId={effectiveFocusedSellerId} sellers={sellerTimeSeries.sellers} metricConfig={sellerMetricConfig} isHourly={sellerTimeSeries.isHourly} dateBucket={sellerTimeSeries.dateBucket} />}
cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }} cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }}
/> />
{!sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && (
<Line
type="monotone"
dataKey="movingAverage"
name="Média móvel"
stroke="var(--chart-label)"
strokeWidth={2.5}
strokeDasharray="6 5"
dot={false}
activeDot={false}
isAnimationActive
animationBegin={160}
animationDuration={700}
/>
)}
{sellerTimeSeries.sellers.map(seller => { {sellerTimeSeries.sellers.map(seller => {
const isFocused = effectiveFocusedSellerId === seller.id; const isFocused = effectiveFocusedSellerId === seller.id;
const isDimmed = Boolean(effectiveFocusedSellerId && !isFocused); const isDimmed = Boolean(effectiveFocusedSellerId && !isFocused);
@@ -688,6 +739,46 @@ const Dashboard = () => {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{selectedSellerRow && (
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input/45 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h4 className="text-sm font-bold text-dark-text">{selectedSellerBucketLabel}</h4>
<p className="mt-1 text-xs font-semibold text-dark-muted">
Quebra por vendedor no ponto selecionado.
</p>
</div>
<button
type="button"
onClick={() => setSelectedSellerBucket(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
</button>
</div>
{selectedSellerBreakdown.length ? (
<div className="mt-4 grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{selectedSellerBreakdown.slice(0, 6).map(({ seller, value }) => (
<button
key={`seller-drill-${seller.id}`}
type="button"
onClick={() => setFocusedSellerId(current => current === seller.id ? null : seller.id)}
className="flex min-w-0 cursor-pointer items-center justify-between gap-3 rounded-lg border border-dark-border bg-dark-card px-3 py-2 text-left transition-colors hover:border-brand-primary"
>
<span className="flex min-w-0 items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: seller.fill }} />
<span className="truncate text-xs font-bold text-dark-text">{seller.name}</span>
</span>
<span className="shrink-0 text-xs font-bold text-dark-muted">{sellerMetricConfig.formatTick(value)}</span>
</button>
))}
</div>
) : (
<p className="mt-4 text-sm font-semibold text-dark-muted">Sem valores nesse ponto.</p>
)}
</div>
)}
<div className="relative mt-5 border-t border-dark-border pt-3"> <div className="relative mt-5 border-t border-dark-border pt-3">
<div className="flex h-8 items-center gap-5 overflow-x-auto overflow-y-hidden whitespace-nowrap pr-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <div className="flex h-8 items-center gap-5 overflow-x-auto overflow-y-hidden whitespace-nowrap pr-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{sellerTimeSeries.sellers.map(seller => { {sellerTimeSeries.sellers.map(seller => {

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom'; import { useParams, Link, useOutletContext } from 'react-router-dom';
import { Package, DollarSign, ReceiptText, Warehouse } from 'lucide-react'; import { Package, DollarSign, ReceiptText, Warehouse } from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import BackButton from '../components/BackButton'; import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
@@ -9,6 +9,7 @@ import type { DateRange, ProductDetailsAnalytics } from '../types';
import { fetchProductDetailsAnalytics } from '../dataService'; import { fetchProductDetailsAnalytics } from '../dataService';
import { parseProductName } from '../productParsing'; import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
const CHART_GRID_COLOR = 'var(--chart-grid)'; const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)'; const CHART_AXIS_COLOR = 'var(--chart-axis)';
@@ -18,6 +19,11 @@ const VARIANT_BAR_COLOR = '#52DFA0';
type ProductChartMetric = 'quantity' | 'revenue' | 'ticket'; type ProductChartMetric = 'quantity' | 'revenue' | 'ticket';
type ProductMetricChartPoint = ProductDetailsAnalytics['chartData'][number] & {
selectedValue: number;
movingAverage?: number;
};
const formatDateKey = (date: Date) => { const formatDateKey = (date: Date) => {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -29,26 +35,31 @@ type CustomTooltipProps = {
active?: boolean; active?: boolean;
payload?: Array<{ payload?: Array<{
value: number; value: number;
payload?: ProductDetailsAnalytics['chartData'][number] & { selectedValue?: number }; dataKey?: string;
payload?: ProductMetricChartPoint;
}>; }>;
label?: string; label?: string;
metric: ProductChartMetric; metric: ProductChartMetric;
formatCurrency: (value: number) => string; formatCurrency: (value: number) => string;
formatNumber: (value: number) => string; formatNumber: (value: number) => string;
isHourly: boolean;
dateBucket: DateBucket;
}; };
const CustomTooltip = ({ active, payload, label, metric, formatCurrency, formatNumber }: CustomTooltipProps) => { const CustomTooltip = ({ active, payload, label, metric, formatCurrency, formatNumber, isHourly, dateBucket }: CustomTooltipProps) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const point = payload[0].payload; const primaryPayload = payload.find(item => item.dataKey === 'selectedValue') || payload[0];
const value = payload[0].value; const point = primaryPayload.payload;
const value = primaryPayload.value;
const displayValue = metric === 'quantity' ? `${formatNumber(value)} un.` : formatCurrency(value); const displayValue = metric === 'quantity' ? `${formatNumber(value)} un.` : formatCurrency(value);
const displayLabel = isHourly ? String(label || '') : formatDateBucketLongLabel(String(label || ''), dateBucket);
return ( return (
<div <div
className="rounded-xl border p-3 shadow-lg" className="rounded-xl border p-3 shadow-lg"
style={{ backgroundColor: 'var(--chart-tooltip-bg)', borderColor: 'var(--chart-tooltip-border)' }} style={{ backgroundColor: 'var(--chart-tooltip-bg)', borderColor: 'var(--chart-tooltip-border)' }}
> >
<p className="font-bold mb-1" style={{ color: CHART_DETAIL_BAR_COLOR }}>{label}</p> <p className="font-bold mb-1" style={{ color: CHART_DETAIL_BAR_COLOR }}>{displayLabel}</p>
<p className="m-0 font-semibold" style={{ color: 'var(--chart-tooltip-text)' }}>{displayValue}</p> <p className="m-0 font-semibold" style={{ color: 'var(--chart-tooltip-text)' }}>{displayValue}</p>
{point && ( {point && (
<div className="mt-2 space-y-1 text-xs" style={{ color: 'var(--chart-axis)' }}> <div className="mt-2 space-y-1 text-xs" style={{ color: 'var(--chart-axis)' }}>
@@ -118,6 +129,7 @@ const ProductDetails = () => {
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null); const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity'); const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -171,22 +183,34 @@ const ProductDetails = () => {
const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details; const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details;
const isRefreshing = isLoading && Boolean(details); const isRefreshing = isLoading && Boolean(details);
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end); 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 ? 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 = { const metricConfig = {
quantity: { quantity: {
label: 'Unidades', label: 'Unidades',
title: `Volume por ${isSingleDayRange ? 'Horário' : 'Data'}`, title: `Volume por ${isHourlyChart ? 'Horário' : 'Data'}`,
subtitle: 'Quantidade vendida no período selecionado.', subtitle: 'Quantidade vendida no período selecionado.',
tickFormatter: (value: number) => formatNumber(value) tickFormatter: (value: number) => formatNumber(value)
}, },
revenue: { revenue: {
label: 'Receita', label: 'Receita',
title: `Receita por ${isSingleDayRange ? 'Horário' : 'Data'}`, title: `Receita por ${isHourlyChart ? 'Horário' : 'Data'}`,
subtitle: 'Faturamento do produto no período selecionado.', subtitle: 'Faturamento do produto no período selecionado.',
tickFormatter: (value: number) => value >= 1000 ? `${formatNumber(value / 1000)}k` : formatCurrency(value) tickFormatter: (value: number) => value >= 1000 ? `${formatNumber(value / 1000)}k` : formatCurrency(value)
}, },
ticket: { ticket: {
label: 'Ticket médio', label: 'Ticket médio',
title: `Ticket médio por ${isSingleDayRange ? 'Horário' : 'Data'}`, title: `Ticket médio por ${isHourlyChart ? 'Horário' : 'Data'}`,
subtitle: 'Receita média por pedido neste produto.', subtitle: 'Receita média por pedido neste produto.',
tickFormatter: (value: number) => value >= 1000 ? `${formatNumber(value / 1000)}k` : formatCurrency(value) tickFormatter: (value: number) => value >= 1000 ? `${formatNumber(value / 1000)}k` : formatCurrency(value)
} }
@@ -197,7 +221,36 @@ const ProductDetails = () => {
tickFormatter: (value: number) => string; tickFormatter: (value: number) => string;
}>; }>;
const selectedMetric = metricConfig[chartMetric]; const selectedMetric = metricConfig[chartMetric];
const metricChartData = chartData.map(point => ({ const metricChartData = (() => {
const bucketMap = new Map<string, ProductMetricChartPoint>();
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, ...point,
selectedValue: chartMetric === 'quantity' selectedValue: chartMetric === 'quantity'
? (point.quantitySold ?? point.value) ? (point.quantitySold ?? point.value)
@@ -205,6 +258,25 @@ const ProductDetails = () => {
? (point.revenue ?? 0) ? (point.revenue ?? 0)
: (point.averageTicket ?? 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); const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0);
return ( return (
@@ -237,6 +309,7 @@ const ProductDetails = () => {
<div> <div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Unidades Vendidas</p> <p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Unidades Vendidas</p>
<p className="text-3xl font-bold text-dark-text">{formatNumber(totalSold)}</p> <p className="text-3xl font-bold text-dark-text">{formatNumber(totalSold)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(dailyAverageSold)} un./dia</p>
</div> </div>
<div className="p-3 bg-brand-primary/10 rounded-xl text-brand-primary"> <div className="p-3 bg-brand-primary/10 rounded-xl text-brand-primary">
<Package size={24} /> <Package size={24} />
@@ -264,6 +337,9 @@ const ProductDetails = () => {
<div> <div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Estoque</p> <p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Estoque</p>
<p className="text-3xl font-bold text-dark-text">{formatNumber(productInfo.stock)}</p> <p className="text-3xl font-bold text-dark-text">{formatNumber(productInfo.stock)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{projectedStockDays === null ? stockActionLabel : `${formatNumber(projectedStockDays)} dias · ${stockActionLabel}`}
</p>
</div> </div>
<div className="p-3 bg-purple-500/10 rounded-xl text-purple-400"> <div className="p-3 bg-purple-500/10 rounded-xl text-purple-400">
<Warehouse size={24} /> <Warehouse size={24} />
@@ -275,7 +351,15 @@ const ProductDetails = () => {
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="mb-8 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div> <div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">{selectedMetric.title}</h3> <h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">{selectedMetric.title}</h3>
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">{selectedMetric.subtitle}</p> <p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
{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.'}
</p>
</div> </div>
<div className="flex w-fit rounded-xl border border-dark-border bg-dark-input p-1"> <div className="flex w-fit rounded-xl border border-dark-border bg-dark-input p-1">
{(Object.keys(metricConfig) as ProductChartMetric[]).map(metric => ( {(Object.keys(metricConfig) as ProductChartMetric[]).map(metric => (
@@ -301,7 +385,13 @@ const ProductDetails = () => {
) : ( ) : (
<div className="h-[400px] w-full"> <div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={metricChartData} margin={{ top: 5, right: 30, left: 20, bottom: isSingleDayRange ? 24 : 80 }}> <AreaChart
data={metricChartData}
margin={{ top: 5, right: 30, left: 20, bottom: 28 }}
onClick={(event) => {
if (event?.activeLabel) setSelectedProductBucket(String(event.activeLabel));
}}
>
<defs> <defs>
<linearGradient id="productVolumeGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="productVolumeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={CHART_DETAIL_BAR_COLOR} stopOpacity={0.38} /> <stop offset="5%" stopColor={CHART_DETAIL_BAR_COLOR} stopOpacity={0.38} />
@@ -311,13 +401,13 @@ const ProductDetails = () => {
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<XAxis <XAxis
dataKey="date" stroke={CHART_AXIS_COLOR} fontSize={10} tickLine={false} axisLine={false} dataKey="date" stroke={CHART_AXIS_COLOR} fontSize={10} tickLine={false} axisLine={false}
interval={isSingleDayRange ? 2 : 0} minTickGap={18}
angle={isSingleDayRange ? 0 : -45} tickFormatter={(value) => (
textAnchor={isSingleDayRange ? 'middle' : 'end'} isHourlyChart ? String(value) : formatDateBucketLabel(String(value), dateBucket)
height={isSingleDayRange ? 24 : 80} )}
/> />
<YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => selectedMetric.tickFormatter(Number(value))} /> <YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => selectedMetric.tickFormatter(Number(value))} />
<Tooltip content={<CustomTooltip metric={chartMetric} formatCurrency={formatCurrency} formatNumber={formatNumber} />} cursor={{ fill: CHART_CURSOR_COLOR }} /> <Tooltip content={<CustomTooltip metric={chartMetric} formatCurrency={formatCurrency} formatNumber={formatNumber} isHourly={isHourlyChart} dateBucket={dateBucket} />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Area <Area
type="monotone" type="monotone"
dataKey="selectedValue" dataKey="selectedValue"
@@ -327,10 +417,61 @@ const ProductDetails = () => {
dot={{ r: 3, strokeWidth: 2, fill: 'var(--color-dark-card)', stroke: CHART_DETAIL_BAR_COLOR }} dot={{ r: 3, strokeWidth: 2, fill: 'var(--color-dark-card)', stroke: CHART_DETAIL_BAR_COLOR }}
activeDot={{ r: 5, strokeWidth: 2, fill: CHART_DETAIL_BAR_COLOR, stroke: 'var(--color-dark-card)' }} activeDot={{ r: 5, strokeWidth: 2, fill: CHART_DETAIL_BAR_COLOR, stroke: 'var(--color-dark-card)' }}
/> />
{!isHourlyChart && metricChartData.some(point => point.movingAverage !== undefined) && (
<Line
type="monotone"
dataKey="movingAverage"
name="Média móvel"
stroke="var(--chart-label)"
strokeWidth={2.5}
strokeDasharray="6 5"
dot={false}
activeDot={false}
/>
)}
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)} )}
{selectedProductPoint && (
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input/45 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h4 className="text-sm font-bold text-dark-text">{selectedProductBucketLabel}</h4>
<p className="mt-1 text-xs font-semibold text-dark-muted">Detalhe do ponto selecionado.</p>
</div>
<button
type="button"
onClick={() => 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
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-5">
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Unidades</div>
<div className="mt-1 text-sm font-bold text-dark-text">{formatNumber(selectedProductPoint.quantitySold ?? selectedProductPoint.value ?? 0)}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Receita</div>
<div className="mt-1 text-sm font-bold text-dark-text">{formatCurrency(selectedProductPoint.revenue ?? 0)}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Pedidos</div>
<div className="mt-1 text-sm font-bold text-dark-text">{formatNumber(selectedProductPoint.orderCount ?? 0)}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Ticket</div>
<div className="mt-1 text-sm font-bold text-dark-text">{formatCurrency(selectedProductPoint.averageTicket ?? 0)}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Cobertura</div>
<div className="mt-1 text-sm font-bold text-dark-text">{projectedStockDays === null ? '-' : `${formatNumber(projectedStockDays)} dias`}</div>
</div>
</div>
</div>
)}
</div> </div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm"> <div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">

View File

@@ -146,7 +146,7 @@ const Registrations = () => {
const sku = (searchParams.get('sku') || '').trim(); const sku = (searchParams.get('sku') || '').trim();
if (!sku && !(requestedTab === 'products' || requestedTab === 'categories' || requestedTab === 'references')) return; if (!sku && !(requestedTab === 'products' || requestedTab === 'categories' || requestedTab === 'references')) return;
const prefillKey = `${sku}|${searchParams.get('name') || ''}|${catalog.products.length}`; const prefillKey = `${requestedTab || ''}|${sku}|${searchParams.get('name') || ''}|${searchParams.get('color') || ''}|${catalog.products.length}`;
if (appliedSkuPrefillRef.current === prefillKey) return; if (appliedSkuPrefillRef.current === prefillKey) return;
appliedSkuPrefillRef.current = prefillKey; appliedSkuPrefillRef.current = prefillKey;
@@ -158,10 +158,22 @@ const Registrations = () => {
if (!sku) return; if (!sku) return;
const existingProduct = catalog.products.find(product => product.sku.toLowerCase() === sku.toLowerCase()); const existingProduct = catalog.products.find(product => product.sku.toLowerCase() === sku.toLowerCase());
setActiveTab('products');
setProductFilter('all'); setProductFilter('all');
setStatus('idle'); setStatus('idle');
if (requestedTab === 'references' && existingProduct) {
setActiveTab('references');
setReferenceForm(current => ({
...current,
productId: String(existingProduct.id),
color: searchParams.get('color') || existingProduct.color || current.color
}));
setFeedback(`Criando referência de consumo para SKU ${existingProduct.sku}.`);
return;
}
setActiveTab('products');
if (existingProduct) { if (existingProduct) {
setProductForm({ setProductForm({
type: existingProduct.type, type: existingProduct.type,
@@ -189,7 +201,11 @@ const Registrations = () => {
color: searchParams.get('color') || '', color: searchParams.get('color') || '',
sizes: requestedSize ? [requestedSize] : defaultProductForm.sizes sizes: requestedSize ? [requestedSize] : defaultProductForm.sizes
}); });
setFeedback(`Novo cadastro para SKU ${sku}.`); setFeedback(
requestedTab === 'references'
? `Cadastre o SKU ${sku} antes de criar a referência de consumo.`
: `Novo cadastro para SKU ${sku}.`
);
}); });
}, [catalog.products, searchParams]); }, [catalog.products, searchParams]);

View File

@@ -11,6 +11,7 @@ import {
Download, Download,
Link as LinkIcon, Link as LinkIcon,
Package, Package,
Pencil,
RefreshCw, RefreshCw,
Repeat2, Repeat2,
Ruler, Ruler,
@@ -21,6 +22,7 @@ import {
Trash2, Trash2,
Warehouse, Warehouse,
} from 'lucide-react'; } from 'lucide-react';
import { buildConsumptionReferencePath } from '../catalogLinks';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService'; import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService';
import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types'; import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
@@ -1246,15 +1248,29 @@ const PurchaseNeedsScreen = () => {
<span>Cobertura</span> <span>Cobertura</span>
</div> </div>
<div className="divide-y divide-dark-border"> <div className="divide-y divide-dark-border">
{visibleNeeds.map(need => ( {visibleNeeds.map(need => {
const referenceProduct = need.products?.[0];
return (
<div key={need.material} className="grid grid-cols-1 gap-3 bg-dark-card px-4 py-4 lg:grid-cols-[1.3fr_110px_110px_110px_110px_110px] lg:items-center"> <div key={need.material} className="grid grid-cols-1 gap-3 bg-dark-card px-4 py-4 lg:grid-cols-[1.3fr_110px_110px_110px_110px_110px] lg:items-center">
<div> <div>
<p className="text-sm font-bold text-dark-text">{need.material}</p> <p className="text-sm font-bold text-dark-text">{need.material}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted"> <div className="mt-1 flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold text-dark-muted">
{need.missingReference {need.missingReference
? 'Cadastre produto/material em Cadastros > Referência de Consumo' ? 'Cadastre produto/material em Cadastros > Referência de Consumo'
: `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`} : `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`}
</p> </p>
{need.missingReference && referenceProduct ? (
<RouterLink
to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Cadastrar referência do SKU ${referenceProduct.productId}`}
aria-label={`Cadastrar referência do SKU ${referenceProduct.productId}`}
>
<Pencil className="h-3.5 w-3.5" />
</RouterLink>
) : null}
</div>
{need.products?.length ? ( {need.products?.length ? (
<p className="mt-1 text-xs font-semibold text-dark-muted"> <p className="mt-1 text-xs font-semibold text-dark-muted">
{need.products.slice(0, 2).map(product => product.productId).join(', ')} {need.products.slice(0, 2).map(product => product.productId).join(', ')}
@@ -1278,7 +1294,8 @@ const PurchaseNeedsScreen = () => {
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]} {need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
</span> </span>
</div> </div>
))} );
})}
</div> </div>
</div> </div>
) : ( ) : (