Files
Craze-Data-check/src/components/DimensionsView.tsx
T

767 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X, Undo2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { ConfirmModal } from './ConfirmModal';
import { ColumnFilterPopover } from './ColumnFilterPopover';
interface DimensionsViewProps {
data: ExcelRow[];
headers: string[];
onEdit: (index: number) => void;
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
onCaptureState: (message: string) => void;
rowStatuses: Record<string, string>;
onRevertRow: (articleNo: string) => void;
}
interface DimensionGroup {
key: string;
innerDims: string;
rows: { row: ExcelRow; index: number }[];
isInconsistent: boolean;
volume: number;
discrepancies: {
outer: boolean;
units: boolean;
moq: boolean;
};
}
interface NearDuplicateCluster {
groups: DimensionGroup[];
volumes: number[];
maxDiffPct: number;
}
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState, rowStatuses, onRevertRow }: DimensionsViewProps) {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
const [syncing, setSyncing] = useState<{ key: string, field: string } | null>(null);
const [pendingAction, setPendingAction] = useState<{
group: DimensionGroup,
sourceRow: ExcelRow,
fieldType: 'outer' | 'units' | 'moq' | 'all'
} | null>(null);
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
clusterKey: string;
targetGroupKey: string;
selectedIndices: number[];
} | null>(null);
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState<string[]>([]);
const [classFilter, setClassFilter] = useState<string[]>([]);
const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null);
const groups = useMemo(() => {
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
data.forEach((row, index) => {
const iw = String(row[COLUMNS.INNER_W] || '0').trim();
const il = String(row[COLUMNS.INNER_L] || '0').trim();
const ih = String(row[COLUMNS.INNER_H] || '0').trim();
// Normalize key by sorting dimension values so different orderings
// (e.g. "29x10x15" vs "10x29x15") are treated as the same group
const sortedDims = [parseFloat(il) || 0, parseFloat(iw) || 0, parseFloat(ih) || 0]
.sort((a, b) => a - b);
const key = sortedDims.join('x');
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push({ row, index });
});
const result: DimensionGroup[] = [];
groupMap.forEach((rows, key) => {
// Skip empty/placeholder groups (0x0x0 or empty fields)
if (key === '0x0x0' || key === 'xx' || key === 'x x') return;
const first = rows[0].row;
const firstOuter = `${first[COLUMNS.OUTER_L]}x${first[COLUMNS.OUTER_W]}x${first[COLUMNS.OUTER_H]}`;
const firstUnits = String(first[COLUMNS.UNITS_OUTER]);
const firstMOQ = String(first[COLUMNS.MOQ]);
let outerMatch = true;
let unitsMatch = true;
let moqMatch = true;
rows.forEach(({ row }) => {
const outer = `${row[COLUMNS.OUTER_L]}x${row[COLUMNS.OUTER_W]}x${row[COLUMNS.OUTER_H]}`;
const units = String(row[COLUMNS.UNITS_OUTER]);
const moq = String(row[COLUMNS.MOQ]);
if (outer !== firstOuter) outerMatch = false;
if (units !== firstUnits) unitsMatch = false;
if (moq !== firstMOQ) moqMatch = false;
});
const parts = key.split('x').map(Number);
const volume = parts[0] * parts[1] * parts[2];
const isVerified = rows.some(({ row }) => row[COLUMNS.VERIFIED_DIMS] === true);
result.push({
key,
innerDims: key,
rows,
volume,
isInconsistent: (!outerMatch || !unitsMatch || !moqMatch) && !isVerified,
discrepancies: {
outer: !outerMatch,
units: !unitsMatch,
moq: !moqMatch
}
});
});
return result.sort((a, b) => (b.isInconsistent ? 1 : 0) - (a.isInconsistent ? 1 : 0));
}, [data]);
const filteredGroups = useMemo(() => {
let result = groups;
if (showOnlyInconsistent) {
result = result.filter(g =>
g.isInconsistent ||
g.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending')
);
}
if (search || lineFilter.length > 0 || classFilter.length > 0) {
const s = search.toLowerCase();
result = result.filter(g => {
const matchesSearch = !search || g.rows.some(({ row }) =>
String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
const matchesLine = lineFilter.length === 0 || g.rows.some(({ row }) => lineFilter.includes(String(row[COLUMNS.LINE] || '')));
const matchesClass = classFilter.length === 0 || g.rows.some(({ row }) => classFilter.includes(String(row[COLUMNS.CLASSIFICATION] || '')));
return matchesSearch && matchesLine && matchesClass;
});
}
return result;
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter, rowStatuses]);
const uniqueLines = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
, [data]);
const uniqueClasses = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.CLASSIFICATION] || '')))).sort()
, [data]);
const nearDuplicateClusters = useMemo((): NearDuplicateCluster[] => {
// Two groups are "similar" if every sorted dimension pair differs by < 1 cm absolute
// (keys are already sorted ascending, e.g. "15x20x29")
const MAX_DIFF_CM = 1;
const maxAbsDiff = (keyA: string, keyB: string): number => {
const a = keyA.split('x').map(Number);
const b = keyB.split('x').map(Number);
return Math.max(...a.map((v, i) => Math.abs(v - b[i])));
};
const validGroups = groups.filter(g => g.volume > 0);
const assignedKeys = new Set<string>();
const clusters: NearDuplicateCluster[] = [];
for (let i = 0; i < validGroups.length; i++) {
const a = validGroups[i];
if (assignedKeys.has(a.key)) continue;
const cluster: DimensionGroup[] = [a];
assignedKeys.add(a.key);
for (let j = i + 1; j < validGroups.length; j++) {
const b = validGroups[j];
if (assignedKeys.has(b.key)) continue;
// b must be within 1 cm on every axis of every group already in the cluster
const isSimilar = cluster.every(g => maxAbsDiff(g.key, b.key) < MAX_DIFF_CM);
if (isSimilar) {
cluster.push(b);
assignedKeys.add(b.key);
}
}
if (cluster.length >= 2) {
const volumes = cluster.map(g => g.volume);
// Max absolute diff (cm) across all pairs in the cluster
let maxDiffPct = 0;
for (let x = 0; x < cluster.length; x++) {
for (let y = x + 1; y < cluster.length; y++) {
maxDiffPct = Math.max(maxDiffPct, maxAbsDiff(cluster[x].key, cluster[y].key));
}
}
clusters.push({ groups: cluster, volumes, maxDiffPct });
}
}
return clusters;
}, [groups]);
const toggleGroup = (key: string) => {
const next = new Set(expandedGroups);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
setExpandedGroups(next);
};
const handleSyncField = async (group: DimensionGroup, sourceRow: ExcelRow, fieldType: 'outer' | 'units' | 'moq') => {
setPendingAction({ group, sourceRow, fieldType });
};
const handleFullSync = async (group: DimensionGroup, sourceRow: ExcelRow) => {
setPendingAction({ group, sourceRow, fieldType: 'all' });
};
const handleVerifyGroup = (e: React.MouseEvent, group: DimensionGroup) => {
e.stopPropagation();
group.rows.forEach(({ row, index }) => {
const newRow = [...row];
newRow[COLUMNS.VERIFIED_DIMS] = true;
onSaveRow(index, newRow);
});
onCaptureState(`Verified dimensions for ${group.rows.length} products`);
};
const executeNearDupSync = async () => {
if (!pendingNearDupSync) return;
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey);
if (!cluster) return;
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
if (!targetGroup) return;
const sourceRow = targetGroup.rows[0].row;
const innerL = sourceRow[COLUMNS.INNER_L];
const innerW = sourceRow[COLUMNS.INNER_W];
const innerH = sourceRow[COLUMNS.INNER_H];
onCaptureState(`Synced inner dimensions to ${targetGroupKey} cm for ${selectedIndices.length} products`);
setPendingNearDupSync(null);
for (const idx of selectedIndices) {
const row = data[idx];
const updatedRow = [...row];
updatedRow[COLUMNS.INNER_L] = innerL;
updatedRow[COLUMNS.INNER_W] = innerW;
updatedRow[COLUMNS.INNER_H] = innerH;
await onSaveRow(idx, updatedRow);
}
setClusterSelections(prev => {
const next = { ...prev };
delete next[pendingNearDupSync.clusterKey];
return next;
});
};
const executeSync = async () => {
if (!pendingAction) return;
const { group, sourceRow, fieldType } = pendingAction;
const fieldLabel = fieldType === 'outer' ? 'Outer Box Dimensions' : fieldType === 'units' ? 'Units per Outer' : fieldType === 'moq' ? 'MOQ' : 'Full Packaging Data';
onCaptureState(`Bulk synced ${fieldLabel} in group ${group.innerDims}`);
setSyncing({ key: group.key, field: fieldType });
setPendingAction(null);
try {
const outerL = sourceRow[COLUMNS.OUTER_L];
const outerW = sourceRow[COLUMNS.OUTER_W];
const outerH = sourceRow[COLUMNS.OUTER_H];
const unitsOuter = sourceRow[COLUMNS.UNITS_OUTER];
const moq = sourceRow[COLUMNS.MOQ];
for (const { row, index } of group.rows) {
if (row === sourceRow) continue;
const updatedRow = [...row];
if (fieldType === 'outer') {
updatedRow[COLUMNS.OUTER_L] = outerL;
updatedRow[COLUMNS.OUTER_W] = outerW;
updatedRow[COLUMNS.OUTER_H] = outerH;
} else if (fieldType === 'units') {
updatedRow[COLUMNS.UNITS_OUTER] = unitsOuter;
} else if (fieldType === 'moq') {
updatedRow[COLUMNS.MOQ] = moq;
} else if (fieldType === 'all') {
updatedRow[COLUMNS.OUTER_L] = outerL;
updatedRow[COLUMNS.OUTER_W] = outerW;
updatedRow[COLUMNS.OUTER_H] = outerH;
updatedRow[COLUMNS.UNITS_OUTER] = unitsOuter;
updatedRow[COLUMNS.MOQ] = moq;
}
await onSaveRow(index, updatedRow);
}
} finally {
setSyncing(null);
}
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-4 bg-slate-800/50 p-4 rounded-lg border border-slate-700">
<div className="relative flex-1 min-w-[250px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="text"
placeholder="Search SKU or Name in groups..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-10 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<div className="relative">
<button
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
lineFilter.length > 0 ? "bg-blue-600/10 border-blue-500/50 text-blue-400" : "bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600"
)}
>
<Filter className="w-4 h-4" />
Line {lineFilter.length > 0 && `(${lineFilter.length})`}
</button>
{openFilter === 'line' && (
<ColumnFilterPopover
uniqueValues={uniqueLines}
selectedValues={lineFilter}
onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
onSelectAll={setLineFilter}
onClear={() => setLineFilter([])}
onClose={() => setOpenFilter(null)}
title="Filter by Line"
className="left-auto right-0"
/>
)}
</div>
<div className="relative">
<button
onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
classFilter.length > 0 ? "bg-blue-600/10 border-blue-500/50 text-blue-400" : "bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600"
)}
>
<Filter className="w-4 h-4" />
Class {classFilter.length > 0 && `(${classFilter.length})`}
</button>
{openFilter === 'class' && (
<ColumnFilterPopover
uniqueValues={uniqueClasses}
selectedValues={classFilter}
onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
onSelectAll={setClassFilter}
onClear={() => setClassFilter([])}
onClose={() => setOpenFilter(null)}
title="Filter by Classification"
className="left-auto right-0"
/>
)}
</div>
{(lineFilter.length > 0 || classFilter.length > 0 || search) && (
<button
onClick={() => { setSearch(''); setLineFilter([]); setClassFilter([]); }}
className="p-2 text-red-400 hover:text-red-300 transition-colors"
title="Clear all filters"
>
<X className="w-5 h-5" />
</button>
)}
</div>
<div className="h-8 w-px bg-slate-700 mx-2 hidden sm:block" />
<div className="flex items-center gap-4 ml-auto">
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={showOnlyInconsistent}
onChange={e => setShowOnlyInconsistent(e.target.checked)}
className="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500"
/>
Show only inconsistent
</label>
<div className="text-[10px] font-bold text-amber-500 bg-amber-500/10 px-2 py-1 rounded border border-amber-500/20 whitespace-nowrap">
{groups.filter(g => g.isInconsistent).length} ISSUES
</div>
</div>
</div>
{nearDuplicateClusters.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 px-1">
<Link2 className="w-4 h-4 text-violet-400" />
<span className="text-sm font-semibold text-violet-300">Possible data entry errors</span>
<span className="text-xs text-slate-500 bg-slate-900 px-2 py-0.5 rounded-full border border-slate-700">
{nearDuplicateClusters.length} cluster{nearDuplicateClusters.length !== 1 ? 's' : ''} with dimensions differing &lt;1 cm per axis
</span>
</div>
{nearDuplicateClusters.map((cluster, ci) => {
const allClusterRows = cluster.groups.flatMap(g => g.rows);
const clusterKey = cluster.groups[0].key;
const selection = clusterSelections[clusterKey] ?? new Set<number>();
const targetKey = clusterSyncTargets[clusterKey] ?? cluster.groups[0].key;
return (
<div key={clusterKey} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
<button
onClick={() => {
const next = new Set(expandedNearDuplicates);
next.has(ci) ? next.delete(ci) : next.add(ci);
setExpandedNearDuplicates(next);
}}
className="w-full flex items-center gap-4 p-3 hover:bg-violet-500/10 transition-colors text-left"
>
{expandedNearDuplicates.has(ci) ? <ChevronDown className="w-4 h-4 text-slate-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-slate-500 shrink-0" />}
<div className="flex items-center gap-3 flex-wrap flex-1">
{cluster.groups.map((g, gi) => (
<span key={g.key} className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded">
{g.key} cm
<span className="text-slate-500 ml-1">({cluster.volumes[gi].toLocaleString()} cm³)</span>
</span>
))}
<span className="text-xs text-violet-400/70"> max diff {cluster.maxDiffPct.toFixed(1)} cm</span>
</div>
<span className="text-xs text-slate-500 shrink-0">{allClusterRows.length} products</span>
</button>
{expandedNearDuplicates.has(ci) && (
<div className="border-t border-violet-500/20">
<div className="flex items-center gap-3 px-4 py-2.5 bg-violet-500/5 border-b border-violet-500/10 flex-wrap">
<span className="text-xs text-slate-400">Sync selected to:</span>
<select
value={targetKey}
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [clusterKey]: e.target.value }))}
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-violet-500"
>
{cluster.groups.map(g => {
const repr = g.rows[0].row;
return (
<option key={g.key} value={g.key}>
{repr[COLUMNS.INNER_L]} × {repr[COLUMNS.INNER_W]} × {repr[COLUMNS.INNER_H]} cm
</option>
);
})}
</select>
<button
disabled={selection.size === 0}
onClick={() => setPendingNearDupSync({ clusterKey, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Layers className="w-3 h-3" />
Sync {selection.size} selected
</button>
<button
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set(allClusterRows.map(r => r.index)) }))}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Select all
</button>
{selection.size > 0 && (
<button
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set() }))}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Clear
</button>
)}
</div>
<div className="divide-y divide-violet-500/10">
{allClusterRows.map(({ row, index }) => {
const isSelected = selection.has(index);
return (
<div
key={index}
className={cn(
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors",
isSelected && "bg-violet-500/10"
)}
onClick={() => setClusterSelections(prev => {
const current = new Set(prev[clusterKey] ?? []);
if (current.has(index)) current.delete(index); else current.add(index);
return { ...prev, [clusterKey]: current };
})}
>
<input
type="checkbox"
checked={isSelected}
readOnly
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-slate-300 font-medium shrink-0">{row[COLUMNS.ARTICLE_NO]}</span>
<span className="text-xs text-slate-500 truncate">{row[COLUMNS.ARTICLE_NAME]}</span>
</div>
</div>
<span className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded shrink-0">
{row[COLUMNS.INNER_L]} × {row[COLUMNS.INNER_W]} × {row[COLUMNS.INNER_H]} cm
</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
)}
<div className="space-y-3">
{filteredGroups.map(group => {
const hasPending = group.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending');
return (
<div key={group.key} className={cn(
"border rounded-lg overflow-hidden transition-all",
hasPending ? "border-yellow-500/50 bg-yellow-500/5 ring-1 ring-yellow-500/20" :
group.isInconsistent ? "border-amber-500/30 bg-amber-500/5" : "border-slate-700 bg-slate-800/30"
)}>
<div className="flex items-center justify-between bg-slate-800/20 pr-4">
<button
onClick={() => toggleGroup(group.key)}
className="flex-1 flex items-center gap-4 p-4 hover:bg-slate-700/30 transition-colors text-left"
>
{expandedGroups.has(group.key) ? <ChevronDown className="w-5 h-5 text-slate-500" /> : <ChevronRight className="w-5 h-5 text-slate-500" />}
<div>
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-0.5 rounded">
Inner: {group.innerDims} cm
</span>
{hasPending && (
<span className="flex items-center gap-1 text-xs font-medium text-yellow-500 bg-yellow-500/10 px-2 py-0.5 rounded ring-1 ring-yellow-500/20">
Pending Validation
</span>
)}
{group.isInconsistent ? (
<span className="flex items-center gap-1 text-xs font-medium text-amber-500 bg-amber-500/10 px-2 py-0.5 rounded ring-1 ring-amber-500/20">
<AlertTriangle className="w-3 h-3" />
Inconsistent
</span>
) : !hasPending ? (
<span className="flex items-center gap-1 text-xs font-medium text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded ring-1 ring-emerald-500/20">
<CheckCircle2 className="w-3 h-3" />
Consistent
</span>
) : null}
</div>
<div className="text-xs text-slate-500 mt-1">
{group.rows.length} product{group.rows.length !== 1 ? 's' : ''} in this dimension group
</div>
</div>
</button>
<div className="flex items-center gap-6">
<div className="flex items-center gap-4 text-[10px] hidden md:flex">
{group.discrepancies.outer && (
<span className="text-amber-400 flex items-center gap-1">
<Package className="w-3 h-3" /> Outer Dims vary
</span>
)}
{group.discrepancies.units && (
<span className="text-amber-400 flex items-center gap-1">
<Boxes className="w-3 h-3" /> Units/Outer vary
</span>
)}
{group.discrepancies.moq && (
<span className="text-amber-400 flex items-center gap-1">
<Scale className="w-3 h-3" /> MOQ varies
</span>
)}
</div>
{group.isInconsistent && (
<button
onClick={(e) => handleVerifyGroup(e, group)}
className="px-2 py-1 bg-emerald-600/10 hover:bg-emerald-600 hover:text-white text-emerald-500 rounded border border-emerald-600/30 text-[10px] font-bold transition-colors flex items-center gap-1"
title="Mark as correct to hide from inconsistent list"
>
<CheckCircle2 className="w-3 h-3" />
Mark Correct
</button>
)}
{group.isInconsistent && !expandedGroups.has(group.key) && (
<div className="text-[10px] font-bold text-blue-400 bg-blue-400/5 px-2 py-1 rounded border border-blue-400/20">
OPEN TO SYNC FIELDS
</div>
)}
</div>
</div>
{expandedGroups.has(group.key) && (
<div className="border-t border-slate-700 overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-slate-900/50 text-slate-400 uppercase tracking-tight font-semibold">
<tr>
<th className="px-4 py-3">Article No / Name</th>
<th className="px-4 py-3">Inner Box (L/W/H)</th>
<th className={cn("px-4 py-3", group.discrepancies.outer && "text-amber-500")}>Outer Box (L/W/H)</th>
<th className={cn("px-4 py-3", group.discrepancies.units && "text-amber-500")}>Units/Outer</th>
<th className={cn("px-4 py-3", group.discrepancies.moq && "text-amber-500")}>MOQ</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/50">
{group.rows.map(({ row, index }) => {
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
return (
<tr
key={index}
className={cn(
"hover:bg-slate-700/20 group transition-colors",
isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
)}
>
<td className="px-4 py-3">
<div className="font-medium text-slate-200">{row[COLUMNS.ARTICLE_NO]}</div>
<div className="text-[10px] text-slate-500 truncate max-w-[200px]">{row[COLUMNS.ARTICLE_NAME]}</div>
</td>
<td className="px-4 py-3 text-slate-400 font-mono">
{row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? row[COLUMNS.INNER_L] : '-'} × {row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? row[COLUMNS.INNER_W] : '-'} × {row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? row[COLUMNS.INNER_H] : '-'}
</td>
<td className={cn(
"px-4 py-3 font-mono",
group.discrepancies.outer ? "text-amber-300" : "text-slate-400"
)}>
<div className="flex items-center gap-2 group/cell">
<button
onClick={() => handleSyncField(group, row, 'outer')}
disabled={syncing?.key === group.key}
title="Apply these Outer Dims to all in group"
className="p-1 hover:bg-emerald-600/20 text-slate-600 hover:text-emerald-400 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
{syncing?.key === group.key && syncing?.field === 'outer' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
</button>
<span>{row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? row[COLUMNS.OUTER_L] : '-'} × {row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? row[COLUMNS.OUTER_W] : '-'} × {row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? row[COLUMNS.OUTER_H] : '-'}</span>
</div>
</td>
<td className={cn(
"px-4 py-3",
group.discrepancies.units ? "text-amber-300 font-bold" : "text-slate-400"
)}>
<div className="flex items-center gap-2 group/cell">
<button
onClick={() => handleSyncField(group, row, 'units')}
disabled={syncing?.key === group.key}
title="Apply this Units/Outer to all in group"
className="p-1 hover:bg-emerald-600/20 text-slate-600 hover:text-emerald-400 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
{syncing?.key === group.key && syncing?.field === 'units' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
</button>
<span>{row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? row[COLUMNS.UNITS_OUTER] : '-'}</span>
</div>
</td>
<td className={cn(
"px-4 py-3",
group.discrepancies.moq ? "text-amber-300 font-bold" : "text-slate-400"
)}>
<div className="flex items-center gap-2 group/cell">
<button
onClick={() => handleSyncField(group, row, 'moq')}
disabled={syncing?.key === group.key}
title="Apply this MOQ to all in group"
className="p-1 hover:bg-emerald-600/20 text-slate-600 hover:text-emerald-400 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
{syncing?.key === group.key && syncing?.field === 'moq' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
</button>
<span>{row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? row[COLUMNS.MOQ] : '-'}</span>
</div>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleFullSync(group, row)}
title="FULL SYNC: Apply ALL packaging measures labels to all in group"
disabled={syncing?.key === group.key}
className="p-1.5 hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 rounded transition-all opacity-0 group-hover:opacity-100"
>
{syncing?.key === group.key && syncing?.field === 'all' ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
</button>
<button
onClick={() => onRevertRow(String(row[COLUMNS.ARTICLE_NO]))}
title="Undo pending changes"
className="p-1.5 hover:bg-red-600/20 text-yellow-500 hover:text-red-400 rounded transition-all"
>
<Undo2 className="w-4 h-4" />
</button>
<button
onClick={() => onEdit(index)}
title="Edit product"
className="p-1.5 hover:bg-slate-600/20 text-slate-500 hover:text-slate-300 rounded transition-all opacity-0 group-hover:opacity-100"
>
<Edit2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
})}
{filteredGroups.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 bg-slate-800/20 border border-dashed border-slate-700 rounded-xl">
<CheckCircle2 className="w-12 h-12 text-emerald-500/50 mb-3" />
<h3 className="text-slate-300 font-medium">Clear of discrepancies</h3>
<p className="text-slate-500 text-sm mt-1">
{showOnlyInconsistent ? "No inconsistent groups found." : "No dimension data available."}
</p>
</div>
)}
</div>
<ConfirmModal
isOpen={!!pendingAction}
onConfirm={executeSync}
onCancel={() => setPendingAction(null)}
title="Sync Group Data"
message={`Are you sure you want to sync ${pendingAction?.fieldType === 'all' ? 'ALL packaging data' : pendingAction?.fieldType} for the whole group using product ${pendingAction?.sourceRow[COLUMNS.ARTICLE_NO]} as the template?`}
type="warning"
confirmText="Sync Group"
/>
<ConfirmModal
isOpen={!!pendingNearDupSync}
onConfirm={executeNearDupSync}
onCancel={() => setPendingNearDupSync(null)}
title="Sync Inner Dimensions"
message={`Update inner dimensions to ${pendingNearDupSync?.targetGroupKey} cm for ${pendingNearDupSync?.selectedIndices.length} selected product${(pendingNearDupSync?.selectedIndices.length ?? 0) !== 1 ? 's' : ''}?`}
type="warning"
confirmText="Sync Dimensions"
/>
</div>
);
}