feat: resizable columns in ProductDescriptions and ArticleDetails

This commit is contained in:
Christian Vidal Wolf
2026-04-08 20:21:36 +02:00
parent 968547c8c7
commit da4673f7c4
2 changed files with 102 additions and 31 deletions
+49 -15
View File
@@ -22,6 +22,14 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({}); const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null); const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
[COLUMNS.ARTICLE_NO]: 100,
[COLUMNS.ARTICLE_NAME]: 200,
[COLUMNS.CLASSIFICATION]: 80,
[COLUMNS.ITEM_AVAILABLE]: 80,
[COLUMNS.DETAILS_DE]: 150,
[COLUMNS.DETAILS_EN]: 150,
});
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]); const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
@@ -89,6 +97,25 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
} }
}; };
const handleResize = (colIndex: number, e: React.MouseEvent) => {
e.preventDefault();
const startX = e.pageX;
const startWidth = columnWidths[colIndex] || 100;
const onMouseMove = (moveEvent: MouseEvent) => {
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const getUniqueValues = (col: number) => { const getUniqueValues = (col: number) => {
const values = data.map(r => String(r[col] || '')); const values = data.map(r => String(r[col] || ''));
return Array.from(new Set(values)).sort(); return Array.from(new Set(values)).sort();
@@ -185,7 +212,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col"> <div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
<div className="overflow-x-auto flex-1"> <div className="overflow-x-auto flex-1">
<table className="w-full text-left text-xs"> <table className="w-full text-left text-xs" style={{ tableLayout: 'fixed' }}>
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10"> <thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
<tr> <tr>
{[ {[
@@ -198,13 +225,14 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
].map(({ col, label }) => ( ].map(({ col, label }) => (
<th <th
key={col} key={col}
className="px-3 py-3 font-medium transition-colors select-none group relative" className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
> >
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1 overflow-hidden">
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}> <div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
{label} {label}
{sortCol === col && ( {sortCol === col && (
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" /> sortDesc ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronUp className="w-3 h-3 shrink-0" />
)} )}
</div> </div>
<button <button
@@ -213,7 +241,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
setOpenFilterCol(openFilterCol === col ? null : col); setOpenFilterCol(openFilterCol === col ? null : col);
}} }}
className={cn( className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors", "p-1 rounded hover:bg-slate-700 transition-colors shrink-0",
(columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100" (columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
)} )}
> >
@@ -221,6 +249,12 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
</button> </button>
</div> </div>
{/* Resizer handle */}
<div
onMouseDown={(e) => handleResize(col, e)}
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
/>
{openFilterCol === col && ( {openFilterCol === col && (
<ColumnFilterPopover <ColumnFilterPopover
uniqueValues={getUniqueValues(col)} uniqueValues={getUniqueValues(col)}
@@ -240,7 +274,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
)} )}
</th> </th>
))} ))}
<th className="px-3 py-3 font-medium text-right">Edit</th> <th className="px-3 py-3 font-medium text-right" style={{ width: 80 }}>Edit</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-700/30"> <tbody className="divide-y divide-slate-700/30">
@@ -254,11 +288,11 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : "" isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
)} )}
> >
<td className="px-3 py-2 font-mono text-indigo-400">{row[COLUMNS.ARTICLE_NO]}</td> <td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-3 py-2 font-medium text-slate-200 max-w-[150px] truncate" title={row[COLUMNS.ARTICLE_NAME]}> <td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>
{row[COLUMNS.ARTICLE_NAME]} {row[COLUMNS.ARTICLE_NAME]}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
<span className={cn( <span className={cn(
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border", "px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
String(row[COLUMNS.CLASSIFICATION]).includes('OOC') ? "bg-amber-500/10 text-amber-500 border-amber-500/20" : "bg-slate-700/50 text-slate-400 border-slate-600/50" String(row[COLUMNS.CLASSIFICATION]).includes('OOC') ? "bg-amber-500/10 text-amber-500 border-amber-500/20" : "bg-slate-700/50 text-slate-400 border-slate-600/50"
@@ -266,7 +300,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
{row[COLUMNS.CLASSIFICATION] || '—'} {row[COLUMNS.CLASSIFICATION] || '—'}
</span> </span>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.ITEM_AVAILABLE] }}>
<span className={cn( <span className={cn(
"font-mono font-bold", "font-mono font-bold",
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400" Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
@@ -274,14 +308,14 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
{row[COLUMNS.ITEM_AVAILABLE] || 0} {row[COLUMNS.ITEM_AVAILABLE] || 0}
</span> </span>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_DE] }}>
{row[COLUMNS.DETAILS_DE] ? ( {row[COLUMNS.DETAILS_DE] ? (
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div> <div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
) : getBadge(null)} ) : getBadge(null)}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_EN] }}>
{row[COLUMNS.DETAILS_EN] ? ( {row[COLUMNS.DETAILS_EN] ? (
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div> <div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
) : getBadge(null)} ) : getBadge(null)}
</td> </td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
+53 -16
View File
@@ -26,6 +26,17 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({}); const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null); const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
[COLUMNS.ARTICLE_NO]: 100,
[COLUMNS.ARTICLE_NAME]: 250,
[COLUMNS.LINE]: 80,
[COLUMNS.LICENSE]: 120,
[COLUMNS.CLASSIFICATION]: 100,
[COLUMNS.LONG_DE]: 80,
[COLUMNS.LONG_EN]: 80,
[COLUMNS.SHORT_DE]: 80,
[COLUMNS.SHORT_EN]: 80,
});
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]); const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]); const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
@@ -116,6 +127,25 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
} }
}; };
const handleResize = (colIndex: number, e: React.MouseEvent) => {
e.preventDefault();
const startX = e.pageX;
const startWidth = columnWidths[colIndex] || 100;
const onMouseMove = (moveEvent: MouseEvent) => {
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const getUniqueValues = (col: number) => { const getUniqueValues = (col: number) => {
// For description columns, return only 'Present' and 'Missing' // For description columns, return only 'Present' and 'Missing'
if (DESCRIPTION_COLUMNS.includes(col)) { if (DESCRIPTION_COLUMNS.includes(col)) {
@@ -252,7 +282,7 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col"> <div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
<div className="overflow-x-auto flex-1"> <div className="overflow-x-auto flex-1">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm" style={{ tableLayout: 'fixed' }}>
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10"> <thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
<tr> <tr>
{[ {[
@@ -268,13 +298,14 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
].map(({ col, label }) => ( ].map(({ col, label }) => (
<th <th
key={col} key={col}
className="px-4 py-3 font-medium transition-colors select-none group relative" className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
> >
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1 overflow-hidden">
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}> <div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
{label} {label}
{sortCol === col && ( {sortCol === col && (
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" /> sortDesc ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronUp className="w-4 h-4 shrink-0" />
)} )}
</div> </div>
<button <button
@@ -283,7 +314,7 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
setOpenFilterCol(openFilterCol === col ? null : col); setOpenFilterCol(openFilterCol === col ? null : col);
}} }}
className={cn( className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors", "p-1 rounded hover:bg-slate-700 transition-colors shrink-0",
(columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100" (columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
)} )}
> >
@@ -291,6 +322,12 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
</button> </button>
</div> </div>
{/* Resizer handle */}
<div
onMouseDown={(e) => handleResize(col, e)}
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
/>
{openFilterCol === col && ( {openFilterCol === col && (
<ColumnFilterPopover <ColumnFilterPopover
uniqueValues={getUniqueValues(col)} uniqueValues={getUniqueValues(col)}
@@ -310,7 +347,7 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
)} )}
</th> </th>
))} ))}
<th className="px-4 py-3 font-medium text-right">Actions</th> <th className="px-4 py-3 font-medium text-right" style={{ width: 100 }}>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-700/50"> <tbody className="divide-y divide-slate-700/50">
@@ -325,11 +362,11 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : "" isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
)} )}
> >
<td className="px-4 py-3 font-mono text-slate-300 text-xs">{row[COLUMNS.ARTICLE_NO]}</td> <td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-4 py-3 font-medium text-white max-w-[200px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td> <td className="px-4 py-3 font-medium text-white truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
<td className="px-4 py-3 text-slate-300 text-xs">{row[COLUMNS.LINE]}</td> <td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LINE] }}>{row[COLUMNS.LINE]}</td>
<td className="px-4 py-3 text-slate-300 text-xs truncate max-w-[120px]" title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td> <td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
<span className={cn( <span className={cn(
"px-2 py-0.5 rounded text-[10px] font-bold border", "px-2 py-0.5 rounded text-[10px] font-bold border",
String(row[COLUMNS.CLASSIFICATION]).includes('OOC') String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
@@ -339,10 +376,10 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
{row[COLUMNS.CLASSIFICATION] || '—'} {row[COLUMNS.CLASSIFICATION] || '—'}
</span> </span>
</td> </td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td> <td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_DE] }}><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td> <td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_EN] }}><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td> <td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_DE] }}><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td> <td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_EN] }}><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<button <button
onClick={() => onEdit(index)} onClick={() => onEdit(index)}