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 BackButton from '../components/BackButton';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { fetchProductAnalytics } from '../dataService';
|
||||
import { decodeProductGroupKey, normalizeProductText, parseProductName, sortProductSizes } from '../productParsing';
|
||||
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
||||
|
||||
type VariantRow = ProductAnalyticsItem & {
|
||||
@@ -23,6 +24,8 @@ type BreakdownRow = {
|
||||
skuCount: number;
|
||||
};
|
||||
|
||||
const BREAKDOWN_LIMIT = 12;
|
||||
|
||||
const COLOR_SWATCHES: Array<{ pattern: string; color: string }> = [
|
||||
{ pattern: 'preto', color: '#171717' },
|
||||
{ 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';
|
||||
};
|
||||
|
||||
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 totals = new Map<string, BreakdownRow>();
|
||||
|
||||
@@ -84,16 +102,7 @@ const buildBreakdown = (rows: VariantRow[], field: 'color' | 'size') => {
|
||||
});
|
||||
});
|
||||
|
||||
const values = [...totals.values()];
|
||||
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);
|
||||
return [...totals.values()].sort((a, b) => b.quantitySold - a.quantitySold);
|
||||
};
|
||||
|
||||
const BreakdownPanel = ({
|
||||
@@ -108,6 +117,8 @@ const BreakdownPanel = ({
|
||||
type: 'color' | 'size';
|
||||
}) => {
|
||||
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 (
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
@@ -129,25 +140,22 @@ const BreakdownPanel = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map(row => {
|
||||
{visibleRows.map(row => {
|
||||
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 (
|
||||
<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">
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20"
|
||||
style={{ backgroundColor: swatchColor }}
|
||||
/>
|
||||
{type === 'color' ? <ColorSwatch label={row.label} /> : <span className="h-2.5 w-2.5 shrink-0 rounded-sm bg-sky-400" />}
|
||||
<span className="truncate text-xs font-bold text-dark-text" title={row.label}>
|
||||
{type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : row.label}
|
||||
</span>
|
||||
</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
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${width}%`, backgroundColor: swatchColor, opacity: 0.86 }}
|
||||
style={{ width: `${width}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
@@ -157,6 +165,11 @@ const BreakdownPanel = ({
|
||||
</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>
|
||||
@@ -195,6 +208,8 @@ const ProductGroupDetails = () => {
|
||||
}>();
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
|
||||
const groupName = useMemo(() => {
|
||||
if (!groupKey) return '';
|
||||
@@ -271,6 +286,10 @@ const ProductGroupDetails = () => {
|
||||
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
|
||||
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
|
||||
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) {
|
||||
return <ProductGroupDetailsSkeleton />;
|
||||
@@ -306,7 +325,13 @@ const ProductGroupDetails = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
onChange={(range) => {
|
||||
setDateRange(range);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RefreshStatus isRefreshing={isRefreshing} />
|
||||
@@ -370,10 +395,15 @@ const ProductGroupDetails = () => {
|
||||
</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="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">
|
||||
<div>
|
||||
<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 className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1100px] table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
@@ -401,7 +431,7 @@ const ProductGroupDetails = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<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">
|
||||
@@ -410,10 +440,7 @@ const ProductGroupDetails = () => {
|
||||
</td>
|
||||
<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="h-2 w-2 shrink-0 rounded-sm border border-white/20"
|
||||
style={{ backgroundColor: getSwatchColor(row.color || 'Sem cor') }}
|
||||
/>
|
||||
<ColorSwatch label={row.color || 'Sem cor'} className="h-2 w-2" />
|
||||
<span className="truncate">{row.color || '-'}</span>
|
||||
</span>
|
||||
</td>
|
||||
@@ -445,6 +472,22 @@ const ProductGroupDetails = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user