Polish product group details layout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
This commit is contained in:
@@ -3,9 +3,10 @@ import { Link, useOutletContext, useParams } from 'react-router-dom';
|
|||||||
import { DollarSign, Package, Palette, Ruler, TrendingDown, TrendingUp, Warehouse } from 'lucide-react';
|
import { DollarSign, Package, Palette, Ruler, TrendingDown, TrendingUp, Warehouse } from 'lucide-react';
|
||||||
import BackButton from '../components/BackButton';
|
import BackButton from '../components/BackButton';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import { fetchProductAnalytics } from '../dataService';
|
import { fetchProductAnalytics } from '../dataService';
|
||||||
import { decodeProductGroupKey, normalizeProductText, parseProductName, sortProductSizes } from '../productParsing';
|
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
||||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
import type { DateRange, ProductAnalyticsItem } from '../types';
|
||||||
|
|
||||||
type VariantRow = ProductAnalyticsItem & {
|
type VariantRow = ProductAnalyticsItem & {
|
||||||
@@ -23,6 +24,8 @@ type BreakdownRow = {
|
|||||||
skuCount: number;
|
skuCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BREAKDOWN_LIMIT = 12;
|
||||||
|
|
||||||
const COLOR_SWATCHES: Array<{ pattern: string; color: string }> = [
|
const COLOR_SWATCHES: Array<{ pattern: string; color: string }> = [
|
||||||
{ pattern: 'preto', color: '#171717' },
|
{ pattern: 'preto', color: '#171717' },
|
||||||
{ pattern: 'branco', color: '#f8fafc' },
|
{ pattern: 'branco', color: '#f8fafc' },
|
||||||
@@ -69,6 +72,21 @@ const getSwatchColor = (label: string) => {
|
|||||||
return COLOR_SWATCHES.find(item => normalizedLabel.includes(item.pattern.normalize('NFD').replace(/\p{Diacritic}/gu, '')))?.color || '#64748b';
|
return COLOR_SWATCHES.find(item => normalizedLabel.includes(item.pattern.normalize('NFD').replace(/\p{Diacritic}/gu, '')))?.color || '#64748b';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBarColor = (label: string) => {
|
||||||
|
const color = getSwatchColor(label);
|
||||||
|
return `color-mix(in srgb, ${color} 74%, var(--color-dark-text) 26%)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ColorSwatch = ({ label, className = 'h-2.5 w-2.5' }: { label: string; className?: string }) => (
|
||||||
|
<span
|
||||||
|
className={`${className} shrink-0 rounded-sm`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: getSwatchColor(label),
|
||||||
|
boxShadow: '0 0 0 1px var(--color-dark-card), 0 0 0 2px var(--color-dark-border)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const buildBreakdown = (rows: VariantRow[], field: 'color' | 'size') => {
|
const buildBreakdown = (rows: VariantRow[], field: 'color' | 'size') => {
|
||||||
const totals = new Map<string, BreakdownRow>();
|
const totals = new Map<string, BreakdownRow>();
|
||||||
|
|
||||||
@@ -84,16 +102,7 @@ const buildBreakdown = (rows: VariantRow[], field: 'color' | 'size') => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const values = [...totals.values()];
|
return [...totals.values()].sort((a, b) => b.quantitySold - a.quantitySold);
|
||||||
if (field === 'size') {
|
|
||||||
return values.sort((a, b) => {
|
|
||||||
const [sortedA, sortedB] = sortProductSizes([a.label, b.label]);
|
|
||||||
if (sortedA === a.label && sortedB === b.label) return 0;
|
|
||||||
return sortedA === a.label ? -1 : 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return values.sort((a, b) => b.quantitySold - a.quantitySold);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const BreakdownPanel = ({
|
const BreakdownPanel = ({
|
||||||
@@ -108,6 +117,8 @@ const BreakdownPanel = ({
|
|||||||
type: 'color' | 'size';
|
type: 'color' | 'size';
|
||||||
}) => {
|
}) => {
|
||||||
const maxSold = Math.max(...rows.map(row => row.quantitySold), 0);
|
const maxSold = Math.max(...rows.map(row => row.quantitySold), 0);
|
||||||
|
const visibleRows = rows.slice(0, BREAKDOWN_LIMIT);
|
||||||
|
const hiddenCount = Math.max(0, rows.length - visibleRows.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||||
@@ -129,25 +140,22 @@ const BreakdownPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{rows.map(row => {
|
{visibleRows.map(row => {
|
||||||
const width = maxSold ? Math.max(4, (row.quantitySold / maxSold) * 100) : 0;
|
const width = maxSold ? Math.max(4, (row.quantitySold / maxSold) * 100) : 0;
|
||||||
const swatchColor = type === 'color' ? getSwatchColor(row.label) : '#25c2ff';
|
const barColor = type === 'color' ? getBarColor(row.label) : '#25c2ff';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={row.label} className="grid grid-cols-[120px_1fr_92px] items-center gap-3">
|
<div key={row.label} className="grid grid-cols-[minmax(7.5rem,9rem)_1fr_6rem] items-center gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span
|
{type === 'color' ? <ColorSwatch label={row.label} /> : <span className="h-2.5 w-2.5 shrink-0 rounded-sm bg-sky-400" />}
|
||||||
className="h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20"
|
|
||||||
style={{ backgroundColor: swatchColor }}
|
|
||||||
/>
|
|
||||||
<span className="truncate text-xs font-bold text-dark-text" title={row.label}>
|
<span className="truncate text-xs font-bold text-dark-text" title={row.label}>
|
||||||
{type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : row.label}
|
{type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : row.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2.5 overflow-hidden rounded-full bg-dark-border">
|
<div className="h-3 overflow-hidden rounded-full border border-dark-border bg-dark-input">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full"
|
className="h-full rounded-full"
|
||||||
style={{ width: `${width}%`, backgroundColor: swatchColor, opacity: 0.86 }}
|
style={{ width: `${width}%`, backgroundColor: barColor }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-xs">
|
<div className="text-right text-xs">
|
||||||
@@ -157,6 +165,11 @@ const BreakdownPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<div className="border-t border-dark-border pt-3 text-xs font-semibold text-dark-muted">
|
||||||
|
+{formatNumber(hiddenCount)} itens fora do top {BREAKDOWN_LIMIT}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -195,6 +208,8 @@ const ProductGroupDetails = () => {
|
|||||||
}>();
|
}>();
|
||||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||||
|
|
||||||
const groupName = useMemo(() => {
|
const groupName = useMemo(() => {
|
||||||
if (!groupKey) return '';
|
if (!groupKey) return '';
|
||||||
@@ -271,6 +286,10 @@ const ProductGroupDetails = () => {
|
|||||||
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
|
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
|
||||||
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
|
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
|
||||||
const isRefreshing = isLoading && products.length > 0;
|
const isRefreshing = isLoading && products.length > 0;
|
||||||
|
const totalPages = Math.ceil(groupRows.length / itemsPerPage);
|
||||||
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||||
|
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||||
|
const paginatedRows = groupRows.slice(startIndex, startIndex + itemsPerPage);
|
||||||
|
|
||||||
if (isLoading && products.length === 0) {
|
if (isLoading && products.length === 0) {
|
||||||
return <ProductGroupDetailsSkeleton />;
|
return <ProductGroupDetailsSkeleton />;
|
||||||
@@ -306,7 +325,13 @@ const ProductGroupDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
|
<DateRangePicker
|
||||||
|
dateRange={dateRange}
|
||||||
|
onChange={(range) => {
|
||||||
|
setDateRange(range);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RefreshStatus isRefreshing={isRefreshing} />
|
<RefreshStatus isRefreshing={isRefreshing} />
|
||||||
@@ -370,9 +395,14 @@ const ProductGroupDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card">
|
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card">
|
||||||
<div className="border-b border-zinc-100 px-6 py-4 dark:border-dark-border">
|
<div className="flex flex-col gap-2 border-b border-zinc-100 px-6 py-4 dark:border-dark-border md:flex-row md:items-end md:justify-between">
|
||||||
<h3 className="text-base font-bold text-zinc-900 dark:text-dark-text">Variações do grupo</h3>
|
<div>
|
||||||
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">SKUs, cores e tamanhos que formam este grupo.</p>
|
<h3 className="text-base font-bold text-zinc-900 dark:text-dark-text">Variações do grupo</h3>
|
||||||
|
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">SKUs, cores e tamanhos que formam este grupo.</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 dark:text-dark-muted">
|
||||||
|
{formatNumber(groupRows.length)} SKUs
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[1100px] table-fixed text-left text-sm">
|
<table className="w-full min-w-[1100px] table-fixed text-left text-sm">
|
||||||
@@ -401,7 +431,7 @@ const ProductGroupDetails = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||||
{groupRows.map(row => (
|
{paginatedRows.map(row => (
|
||||||
<tr key={row.id} className="transition-colors hover:bg-zinc-50/80 dark:hover:bg-dark-input/50">
|
<tr key={row.id} className="transition-colors hover:bg-zinc-50/80 dark:hover:bg-dark-input/50">
|
||||||
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{row.id}</td>
|
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{row.id}</td>
|
||||||
<td className="max-w-0 px-6 py-2.5">
|
<td className="max-w-0 px-6 py-2.5">
|
||||||
@@ -410,10 +440,7 @@ const ProductGroupDetails = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-2.5">
|
<td className="px-6 py-2.5">
|
||||||
<span className="inline-flex max-w-full items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">
|
<span className="inline-flex max-w-full items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">
|
||||||
<span
|
<ColorSwatch label={row.color || 'Sem cor'} className="h-2 w-2" />
|
||||||
className="h-2 w-2 shrink-0 rounded-sm border border-white/20"
|
|
||||||
style={{ backgroundColor: getSwatchColor(row.color || 'Sem cor') }}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{row.color || '-'}</span>
|
<span className="truncate">{row.color || '-'}</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -445,6 +472,22 @@ const ProductGroupDetails = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<PaginationControls
|
||||||
|
totalItems={groupRows.length}
|
||||||
|
currentPage={safeCurrentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={itemsPerPage}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
itemLabel="SKUs"
|
||||||
|
pageSizeLabel="SKUs por página"
|
||||||
|
startIndex={startIndex}
|
||||||
|
endIndex={Math.min(startIndex + itemsPerPage, groupRows.length)}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onPageSizeChange={(pageSize) => {
|
||||||
|
setItemsPerPage(pageSize);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user