2026-03-29 17:30:04 +02:00
import React , { useState , useMemo } from 'react' ;
import { ExcelRow , COLUMNS } from '../types' ;
2026-04-10 12:39:31 +02:00
import { AlertTriangle , CheckCircle2 , ChevronDown , ChevronRight , Edit2 , Package , Boxes , Scale , Loader2 , RefreshCw , Layers , Link2 , Search , Filter , X , Undo2 } from 'lucide-react' ;
2026-03-29 17:30:04 +02:00
import { cn } from '../lib/utils' ;
2026-03-29 18:26:20 +02:00
import { ConfirmModal } from './ConfirmModal' ;
2026-04-08 16:56:43 +02:00
import { ColumnFilterPopover } from './ColumnFilterPopover' ;
2026-03-29 17:30:04 +02:00
interface DimensionsViewProps {
data : ExcelRow [];
headers : string [];
onEdit : ( index : number ) => void ;
onSaveRow : ( index : number , updatedRow : ExcelRow ) => void ;
2026-03-29 17:35:07 +02:00
onCaptureState : ( message : string ) => void ;
2026-04-08 19:30:41 +02:00
rowStatuses : Record < string , string >;
2026-04-09 09:13:18 +02:00
onRevertRow : ( articleNo : string ) => void ;
2026-03-29 17:30:04 +02:00
}
interface DimensionGroup {
key : string ;
innerDims : string ;
rows : { row : ExcelRow ; index : number }[];
isInconsistent : boolean ;
2026-04-07 10:45:28 +02:00
volume : number ;
2026-03-29 17:30:04 +02:00
discrepancies : {
outer : boolean ;
units : boolean ;
moq : boolean ;
};
}
2026-04-07 10:45:28 +02:00
interface NearDuplicateCluster {
groups : DimensionGroup [];
volumes : number [];
maxDiffPct : number ;
}
2026-04-09 09:13:18 +02:00
export function DimensionsView ({ data , headers , onEdit , onSaveRow , onCaptureState , rowStatuses , onRevertRow } : DimensionsViewProps ) {
2026-03-29 17:30:04 +02:00
const [ expandedGroups , setExpandedGroups ] = useState < Set < string >>( new Set ());
2026-04-07 10:45:28 +02:00
const [ expandedNearDuplicates , setExpandedNearDuplicates ] = useState < Set < number >>( new Set ());
2026-03-29 17:30:04 +02:00
const [ showOnlyInconsistent , setShowOnlyInconsistent ] = useState ( true );
2026-03-29 17:43:57 +02:00
const [ syncing , setSyncing ] = useState < { key : string , field : string } | null > ( null );
2026-03-29 18:26:20 +02:00
const [ pendingAction , setPendingAction ] = useState < {
group : DimensionGroup ,
sourceRow : ExcelRow ,
fieldType : 'outer' | 'units' | 'moq' | 'all'
} | null > ( null );
2026-04-08 16:10:14 +02:00
const [ clusterSelections , setClusterSelections ] = useState < Record < number , Set < number >>>({});
const [ clusterSyncTargets , setClusterSyncTargets ] = useState < Record < number , string >>({});
const [ pendingNearDupSync , setPendingNearDupSync ] = useState < {
2026-04-08 20:05:24 +02:00
clusterKey : string ;
2026-04-08 16:10:14 +02:00
targetGroupKey : string ;
selectedIndices : number [];
} | null > ( null );
2026-04-08 16:56:43 +02:00
const [ search , setSearch ] = useState ( '' );
const [ lineFilter , setLineFilter ] = useState < string [] >([]);
const [ classFilter , setClassFilter ] = useState < string [] >([]);
const [ openFilter , setOpenFilter ] = useState < 'line' | 'class' | null > ( null );
2026-03-29 17:30:04 +02:00
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 ();
2026-04-07 10:37:16 +02:00
// 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' );
2026-03-29 17:30:04 +02:00
if ( ! groupMap . has ( key )) {
groupMap . set ( key , []);
}
groupMap . get ( key ) ! . push ({ row , index });
});
const result : DimensionGroup [] = [];
groupMap . forEach (( rows , key ) => {
2026-03-29 17:43:57 +02:00
// Skip empty/placeholder groups (0x0x0 or empty fields)
if ( key === '0x0x0' || key === 'xx' || key === 'x x' ) return ;
2026-03-29 17:30:04 +02:00
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 ;
});
2026-04-07 10:45:28 +02:00
const parts = key . split ( 'x' ). map ( Number );
const volume = parts [ 0 ] * parts [ 1 ] * parts [ 2 ];
2026-04-11 11:48:56 +02:00
const isVerified = rows . some (({ row }) => row [ COLUMNS . VERIFIED_DIMS ] === true );
2026-04-07 10:45:28 +02:00
2026-03-29 17:30:04 +02:00
result . push ({
key ,
innerDims : key ,
rows ,
2026-04-07 10:45:28 +02:00
volume ,
2026-04-11 11:48:56 +02:00
isInconsistent : ( ! outerMatch || ! unitsMatch || ! moqMatch ) && ! isVerified ,
2026-03-29 17:30:04 +02:00
discrepancies : {
outer : ! outerMatch ,
units : ! unitsMatch ,
moq : ! moqMatch
}
});
});
return result . sort (( a , b ) => ( b . isInconsistent ? 1 : 0 ) - ( a . isInconsistent ? 1 : 0 ));
}, [ data ]);
const filteredGroups = useMemo (() => {
2026-04-08 16:56:43 +02:00
let result = groups ;
2026-04-08 20:05:24 +02:00
if ( showOnlyInconsistent ) {
result = result . filter ( g =>
g . isInconsistent ||
g . rows . some (({ row }) => rowStatuses [ String ( row [ COLUMNS . ARTICLE_NO ])] === 'pending' )
);
}
2026-04-08 16:56:43 +02:00
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 ;
2026-04-08 20:05:24 +02:00
}, [ groups , showOnlyInconsistent , search , lineFilter , classFilter , rowStatuses ]);
2026-04-08 16:56:43 +02:00
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 ]);
2026-03-29 17:30:04 +02:00
2026-04-07 10:45:28 +02:00
const nearDuplicateClusters = useMemo (() : NearDuplicateCluster [] => {
2026-04-07 10:56:58 +02:00
// Two groups are "similar" if every sorted dimension pair differs by < 1 cm absolute
2026-04-07 10:51:10 +02:00
// (keys are already sorted ascending, e.g. "15x20x29")
2026-04-07 10:56:58 +02:00
const MAX_DIFF_CM = 1 ;
2026-04-07 10:51:10 +02:00
2026-04-07 10:56:58 +02:00
const maxAbsDiff = ( keyA : string , keyB : string ) : number => {
2026-04-07 10:51:10 +02:00
const a = keyA . split ( 'x' ). map ( Number );
const b = keyB . split ( 'x' ). map ( Number );
2026-04-07 10:56:58 +02:00
return Math . max (... a . map (( v , i ) => Math . abs ( v - b [ i ])));
2026-04-07 10:51:10 +02:00
};
2026-04-07 10:45:28 +02:00
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 ;
2026-04-07 10:56:58 +02:00
// 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 );
2026-04-07 10:51:10 +02:00
if ( isSimilar ) {
2026-04-07 10:45:28 +02:00
cluster . push ( b );
assignedKeys . add ( b . key );
}
}
if ( cluster . length >= 2 ) {
const volumes = cluster . map ( g => g . volume );
2026-04-07 10:56:58 +02:00
// Max absolute diff (cm) across all pairs in the cluster
2026-04-07 10:51:10 +02:00
let maxDiffPct = 0 ;
for ( let x = 0 ; x < cluster . length ; x ++ ) {
for ( let y = x + 1 ; y < cluster . length ; y ++ ) {
2026-04-07 10:56:58 +02:00
maxDiffPct = Math . max ( maxDiffPct , maxAbsDiff ( cluster [ x ]. key , cluster [ y ]. key ));
2026-04-07 10:51:10 +02:00
}
}
2026-04-07 10:45:28 +02:00
clusters . push ({ groups : cluster , volumes , maxDiffPct });
}
}
return clusters ;
}, [ groups ]);
2026-03-29 17:30:04 +02:00
const toggleGroup = ( key : string ) => {
const next = new Set ( expandedGroups );
if ( next . has ( key )) {
next . delete ( key );
} else {
next . add ( key );
}
setExpandedGroups ( next );
};
2026-03-29 17:43:57 +02:00
const handleSyncField = async ( group : DimensionGroup , sourceRow : ExcelRow , fieldType : 'outer' | 'units' | 'moq' ) => {
2026-03-29 18:26:20 +02:00
setPendingAction ({ group , sourceRow , fieldType });
};
const handleFullSync = async ( group : DimensionGroup , sourceRow : ExcelRow ) => {
setPendingAction ({ group , sourceRow , fieldType : 'all' });
};
2026-04-11 11:48:56 +02:00
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` );
};
2026-04-08 16:10:14 +02:00
const executeNearDupSync = async () => {
if ( ! pendingNearDupSync ) return ;
2026-04-08 20:05:24 +02:00
const { clusterKey , targetGroupKey , selectedIndices } = pendingNearDupSync ;
2026-04-08 16:10:14 +02:00
2026-04-08 20:05:24 +02:00
const cluster = nearDuplicateClusters . find ( c => c . groups [ 0 ]. key === clusterKey );
if ( ! cluster ) return ;
2026-04-08 16:10:14 +02:00
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 };
2026-04-08 20:05:24 +02:00
delete next [ pendingNearDupSync . clusterKey ];
2026-04-08 16:10:14 +02:00
return next ;
});
};
2026-03-29 18:26:20 +02:00
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' ;
2026-03-29 17:30:04 +02:00
2026-03-29 17:43:57 +02:00
onCaptureState ( `Bulk synced ${ fieldLabel } in group ${ group . innerDims } ` );
setSyncing ({ key : group.key , field : fieldType });
2026-03-29 18:26:20 +02:00
setPendingAction ( null );
2026-03-29 17:43:57 +02:00
2026-03-29 17:30:04 +02:00
try {
2026-03-29 17:41:03 +02:00
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 ];
2026-03-29 17:30:04 +02:00
for ( const { row , index } of group . rows ) {
2026-03-29 17:41:03 +02:00
if ( row === sourceRow ) continue ;
2026-03-29 17:43:57 +02:00
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 ;
2026-03-29 18:26:20 +02:00
} 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 ;
2026-03-29 17:43:57 +02:00
}
await onSaveRow ( index , updatedRow );
}
} finally {
setSyncing ( null );
}
};
2026-03-29 17:41:03 +02:00
2026-03-29 17:30:04 +02:00
return (
< div className = "space-y-6" >
2026-04-08 16:56:43 +02:00
< 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 )}
2026-04-10 12:39:31 +02:00
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"
2026-04-08 16:56:43 +02:00
/>
2026-04-10 12:39:31 +02:00
{ 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 >
)}
2026-03-29 17:30:04 +02:00
</ div >
2026-04-08 16:56:43 +02:00
< 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"
>
2026-04-10 12:39:31 +02:00
< X className = "w-5 h-5" />
2026-04-08 16:56:43 +02:00
</ 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" >
2026-03-29 17:30:04 +02:00
< 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"
/>
2026-04-08 16:56:43 +02:00
Show only inconsistent
2026-03-29 17:30:04 +02:00
</ label >
2026-04-08 16:56:43 +02:00
< 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
2026-03-29 17:30:04 +02:00
</ div >
</ div >
</ div >
2026-04-07 10:45:28 +02:00
{ 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" >
2026-04-07 10:56:58 +02:00
{ nearDuplicateClusters . length } cluster { nearDuplicateClusters . length !== 1 ? 's' : '' } with dimensions differing & lt ; 1 cm per axis
2026-04-07 10:45:28 +02:00
</ span >
</ div >
2026-04-08 16:11:37 +02:00
{ nearDuplicateClusters . map (( cluster , ci ) => {
const allClusterRows = cluster . groups . flatMap ( g => g . rows );
2026-04-08 20:05:24 +02:00
const clusterKey = cluster . groups [ 0 ]. key ;
const selection = clusterSelections [ clusterKey ] ?? new Set < number >();
const targetKey = clusterSyncTargets [ clusterKey ] ?? cluster . groups [ 0 ]. key ;
2026-04-08 16:11:37 +02:00
return (
2026-04-08 20:05:24 +02:00
< div key = { clusterKey } className = "border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden" >
2026-04-08 16:11:37 +02:00
< 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 }
2026-04-08 20:05:24 +02:00
onChange = { e => setClusterSyncTargets ( prev => ({ ... prev , [ clusterKey ] : e . target . value }))}
2026-04-08 16:11:37 +02:00
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 }
2026-04-08 20:05:24 +02:00
onClick = {() => setPendingNearDupSync ({ clusterKey , targetGroupKey : targetKey , selectedIndices : Array.from ( selection ) })}
2026-04-08 16:11:37 +02:00
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
2026-04-08 20:05:24 +02:00
onClick = {() => setClusterSelections ( prev => ({ ... prev , [ clusterKey ] : new Set ( allClusterRows . map ( r => r . index )) }))}
2026-04-08 16:11:37 +02:00
className = "text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Select all
</ button >
{ selection . size > 0 && (
< button
2026-04-08 20:05:24 +02:00
onClick = {() => setClusterSelections ( prev => ({ ... prev , [ clusterKey ] : new Set () }))}
2026-04-08 16:11:37 +02:00
className = "text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Clear
</ button >
)}
2026-04-07 10:45:28 +02:00
</ div >
2026-04-08 16:11:37 +02:00
< 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 => {
2026-04-08 20:05:24 +02:00
const current = new Set ( prev [ clusterKey ] ?? []);
2026-04-08 16:11:37 +02:00
if ( current . has ( index )) current . delete ( index ); else current . add ( index );
2026-04-08 20:05:24 +02:00
return { ... prev , [ clusterKey ] : current };
2026-04-08 16:11:37 +02:00
})}
>
< input
type = "checkbox"
checked = { isSelected }
2026-04-08 20:05:24 +02:00
readOnly
className = "rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
2026-04-08 16:11:37 +02:00
/>
< 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 >
);
})}
2026-04-07 10:45:28 +02:00
</ div >
)}
2026-03-29 17:30:04 +02:00
< div className = "space-y-3" >
2026-04-08 20:05:24 +02:00
{ 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"
)}>
2026-03-29 17:30:04 +02:00
< 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 >
2026-04-08 20:05:24 +02:00
{ 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 >
)}
2026-03-29 17:30:04 +02:00
{ 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 >
2026-04-08 20:05:24 +02:00
) : ! hasPending ? (
2026-03-29 17:30:04 +02:00
< 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 >
2026-04-08 20:05:24 +02:00
) : null }
2026-03-29 17:30:04 +02:00
</ 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 >
2026-03-29 17:41:03 +02:00
< div className = "flex items-center gap-6" >
2026-03-29 17:30:04 +02:00
< 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 >
2026-03-29 17:41:03 +02:00
2026-04-11 11:48:56 +02:00
{ 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 >
)}
2026-03-29 17:41:03 +02:00
{ 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" >
2026-03-29 17:43:57 +02:00
OPEN TO SYNC FIELDS
2026-03-29 17:41:03 +02:00
</ div >
2026-03-29 17:30:04 +02:00
)}
</ 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" >
2026-04-08 19:30:41 +02:00
{ 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" : ""
)}
>
2026-03-29 17:30:04 +02:00
< 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" >
2026-04-10 12:53:47 +02:00
{ 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 ] : '-' }
2026-03-29 17:30:04 +02:00
</ td >
< td className = { cn (
2026-03-29 17:54:22 +02:00
"px-4 py-3 font-mono" ,
2026-03-29 17:30:04 +02:00
group . discrepancies . outer ? "text-amber-300" : "text-slate-400"
)}>
2026-03-29 17:54:22 +02:00
< 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 >
2026-04-10 12:53:47 +02:00
< 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 >
2026-03-29 17:54:22 +02:00
</ div >
2026-03-29 17:30:04 +02:00
</ td >
< td className = { cn (
2026-03-29 17:54:22 +02:00
"px-4 py-3" ,
2026-03-29 17:30:04 +02:00
group . discrepancies . units ? "text-amber-300 font-bold" : "text-slate-400"
)}>
2026-03-29 17:54:22 +02:00
< 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 >
2026-04-10 12:53:47 +02:00
< span >{ row [ COLUMNS . UNITS_OUTER ] !== undefined && row [ COLUMNS . UNITS_OUTER ] !== null ? row [ COLUMNS . UNITS_OUTER ] : '-' }</ span >
2026-03-29 17:54:22 +02:00
</ div >
2026-03-29 17:30:04 +02:00
</ td >
< td className = { cn (
2026-03-29 17:54:22 +02:00
"px-4 py-3" ,
2026-03-29 17:30:04 +02:00
group . discrepancies . moq ? "text-amber-300 font-bold" : "text-slate-400"
)}>
2026-03-29 17:54:22 +02:00
< 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 >
2026-04-10 12:53:47 +02:00
< span >{ row [ COLUMNS . MOQ ] !== undefined && row [ COLUMNS . MOQ ] !== null ? row [ COLUMNS . MOQ ] : '-' }</ span >
2026-03-29 17:54:22 +02:00
</ div >
2026-03-29 17:30:04 +02:00
</ td >
< td className = "px-4 py-3 text-right" >
2026-04-09 09:17:11 +02:00
< div className = "flex items-center justify-end gap-1" >
2026-03-29 17:41:03 +02:00
< button
2026-03-29 17:43:57 +02:00
onClick = {() => handleFullSync ( group , row )}
title = "FULL SYNC: Apply ALL packaging measures labels to all in group"
disabled = { syncing ? . key === group . key }
2026-04-09 09:17:11 +02:00
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"
2026-03-29 17:41:03 +02:00
>
2026-03-29 17:43:57 +02:00
{ syncing ? . key === group . key && syncing ? . field === 'all' ? < Loader2 className = "w-4 h-4 animate-spin" /> : < RefreshCw className = "w-4 h-4" />}
2026-03-29 17:41:03 +02:00
</ button >
2026-04-09 09:17:11 +02:00
< 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 >
2026-03-29 17:41:03 +02:00
< button
onClick = {() => onEdit ( index )}
title = "Edit product"
2026-04-09 09:17:11 +02:00
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"
2026-03-29 17:41:03 +02:00
>
< Edit2 className = "w-4 h-4" />
</ button >
</ div >
2026-03-29 17:30:04 +02:00
</ td >
</ tr >
2026-04-08 19:30:41 +02:00
);
})}
2026-03-29 17:30:04 +02:00
</ tbody >
</ table >
</ div >
)}
</ div >
2026-04-08 20:05:24 +02:00
);
})}
2026-03-29 17:30:04 +02:00
{ 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 >
2026-03-29 18:26:20 +02:00
< 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"
/>
2026-04-08 16:11:37 +02:00
< 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"
/>
2026-03-29 17:30:04 +02:00
</ div >
);
}