feat: reorganize tabs, add Article Details, and enhance filters/search

This commit is contained in:
Christian Vidal Wolf
2026-04-08 16:10:14 +02:00
parent 935cd5b510
commit 24042a6aed
7 changed files with 397 additions and 61 deletions
+8 -1
View File
@@ -11,6 +11,7 @@ import { getStoredSession, signOut, type AuthSession } from './lib/auth';
import { LoginPage } from './components/LoginPage'; import { LoginPage } from './components/LoginPage';
import { DimensionsView } from './components/DimensionsView'; import { DimensionsView } from './components/DimensionsView';
import { PricingView } from './components/PricingView'; import { PricingView } from './components/PricingView';
import { ArticleDetails } from './components/ArticleDetails';
import { UndoToast } from './components/UndoToast'; import { UndoToast } from './components/UndoToast';
export default function App() { export default function App() {
@@ -32,7 +33,7 @@ export default function App() {
fileDate: null, fileDate: null,
hasUnsavedChanges: false hasUnsavedChanges: false
}); });
const [activeModule, setActiveModule] = useState<'descriptions' | 'matrix' | 'dimensions' | 'pricing'>('descriptions'); const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing'>('descriptions');
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]); const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null); const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
const [isLoadingDefault, setIsLoadingDefault] = useState(true); const [isLoadingDefault, setIsLoadingDefault] = useState(true);
@@ -373,6 +374,12 @@ export default function App() {
onEdit={(index) => setEditingRowIndex(index)} onEdit={(index) => setEditingRowIndex(index)}
/> />
)} )}
{activeModule === 'article_details' && (
<ArticleDetails
data={appState.data}
onEdit={(index) => setEditingRowIndex(index)}
/>
)}
</> </>
)} )}
</main> </main>
+256
View File
@@ -0,0 +1,256 @@
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package } from 'lucide-react';
import { cn } from '../lib/utils';
interface ArticleDetailsProps {
data: ExcelRow[];
onEdit: (index: number) => void;
}
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock' | 'missingTariff';
export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
const [activeTab, setActiveTab] = useState<TabType>('all');
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState('');
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false);
const [pageSize, setPageSize] = useState(25);
const [page, setPage] = useState(1);
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index }));
// Tab filter
if (activeTab === 'missingDetailsDE') result = result.filter(r => !r.row[COLUMNS.DETAILS_DE]);
if (activeTab === 'missingDetailsEN') result = result.filter(r => !r.row[COLUMNS.DETAILS_EN]);
if (activeTab === 'missingAnyDetails') result = result.filter(r => !r.row[COLUMNS.DETAILS_DE] || !r.row[COLUMNS.DETAILS_EN]);
if (activeTab === 'lowStock') result = result.filter(r => Number(r.row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0);
if (activeTab === 'missingTariff') result = result.filter(r => !r.row[COLUMNS.TARIFF_CODE]);
// Search filter
if (search) {
const s = search.toLowerCase();
result = result.filter(r =>
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.BARCODE] || '').toLowerCase().includes(s)
);
}
// Dropdown filters
if (lineFilter) result = result.filter(r => r.row[COLUMNS.LINE] === lineFilter);
// Sorting
if (sortCol !== null) {
result.sort((a, b) => {
const valA = a.row[sortCol];
const valB = b.row[sortCol];
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB;
}
const sA = String(valA || '');
const sB = String(valB || '');
return sortDesc ? sB.localeCompare(sA) : sA.localeCompare(sB);
});
}
return result;
}, [data, activeTab, search, lineFilter, sortCol, sortDesc]);
const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredData.slice(start, start + pageSize);
}, [filteredData, page, pageSize]);
const totalPages = Math.ceil(filteredData.length / pageSize);
const handleSort = (col: number) => {
if (sortCol === col) {
setSortDesc(!sortDesc);
} else {
setSortCol(col);
setSortDesc(false);
}
};
const getBadge = (val: any, type: 'success' | 'warning' | 'error' | 'info' = 'info') => {
if (!val) return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>;
const styles = {
success: "bg-green-500/10 text-green-400 border-green-500/20",
warning: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
error: "bg-red-500/10 text-red-400 border-red-500/20",
info: "bg-blue-500/10 text-blue-400 border-blue-500/20"
};
return <span className={cn("inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border", styles[type])}>{val}</span>;
};
const tabs: { id: TabType; label: string }[] = [
{ id: 'all', label: 'All Articles' },
{ id: 'missingDetailsDE', label: 'No Details DE' },
{ id: 'missingDetailsEN', label: 'No Details EN' },
{ id: 'missingAnyDetails', label: 'Missing Details' },
{ id: 'lowStock', label: 'Out of Stock' },
{ id: 'missingTariff', label: 'No Tariff Code' },
];
return (
<div className="flex flex-col h-full">
<div className="flex flex-wrap gap-2 mb-6">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); setPage(1); }}
className={cn(
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
activeTab === tab.id
? "bg-indigo-600 text-white shadow-md"
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
)}
>
{tab.label}
</button>
))}
</div>
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
<div className="flex-1 min-w-[200px] relative">
<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, Name, Barcode..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-4 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
</div>
<select
value={lineFilter}
onChange={e => { setLineFilter(e.target.value); setPage(1); }}
className="bg-slate-900 border border-slate-700 rounded-md px-4 py-2 text-sm text-white focus:outline-none focus:border-indigo-500"
>
<option value="">All Lines</option>
{lines.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
</select>
</div>
<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">
<table className="w-full text-left text-xs">
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
<tr>
{[
{ col: COLUMNS.ARTICLE_NO, label: 'SKU' },
{ col: COLUMNS.ARTICLE_NAME, label: 'Name' },
{ col: COLUMNS.BARCODE, label: 'Barcode' },
{ col: COLUMNS.TARIFF_CODE, label: 'Tariff' },
{ col: COLUMNS.COUNTRY_ORIGIN, label: 'Origin' },
{ col: COLUMNS.CLASSIFICATION, label: 'Class' },
{ col: COLUMNS.ITEM_AVAILABLE, label: 'Stock' },
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
].map(({ col, label }) => (
<th
key={col}
className="px-3 py-3 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort(col)}
>
<div className="flex items-center gap-1">
{label}
{sortCol === col && (
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
)}
</div>
</th>
))}
<th className="px-3 py-3 font-medium text-right">Edit</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/30">
{paginatedData.map(({ row, index }) => (
<tr key={index} className="hover:bg-slate-700/20 transition-colors">
<td className="px-3 py-2 font-mono text-indigo-400">{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]}>
{row[COLUMNS.ARTICLE_NAME]}
</td>
<td className="px-3 py-2 text-slate-400">{row[COLUMNS.BARCODE] || '—'}</td>
<td className="px-3 py-2 text-slate-400">{row[COLUMNS.TARIFF_CODE] || '—'}</td>
<td className="px-3 py-2 text-slate-400">{row[COLUMNS.COUNTRY_ORIGIN] || '—'}</td>
<td className="px-3 py-2">
<span className={cn(
"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"
)}>
{row[COLUMNS.CLASSIFICATION] || '—'}
</span>
</td>
<td className="px-3 py-2">
<span className={cn(
"font-mono font-bold",
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
)}>
{row[COLUMNS.ITEM_AVAILABLE] || 0}
</span>
</td>
<td className="px-3 py-2">
{row[COLUMNS.DETAILS_DE] ? (
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
) : getBadge(null)}
</td>
<td className="px-3 py-2">
{row[COLUMNS.DETAILS_EN] ? (
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
) : getBadge(null)}
</td>
<td className="px-3 py-2 text-right">
<button
onClick={() => onEdit(index)}
className="p-1.5 text-slate-500 hover:text-indigo-400 hover:bg-indigo-400/10 rounded transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
{paginatedData.length === 0 && (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-slate-500">
No articles found.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-xs text-slate-500">
<div>Showing {paginatedData.length} of {filteredData.length} articles</div>
<div className="flex items-center gap-2">
<button
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
Previous
</button>
<span className="text-slate-300">Page {page} of {totalPages || 1}</span>
<button
disabled={page === totalPages || totalPages === 0}
onClick={() => setPage(p => p + 1)}
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
Next
</button>
</div>
</div>
</div>
</div>
);
}
+39
View File
@@ -41,6 +41,13 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
sourceRow: ExcelRow, sourceRow: ExcelRow,
fieldType: 'outer' | 'units' | 'moq' | 'all' fieldType: 'outer' | 'units' | 'moq' | 'all'
} | null>(null); } | null>(null);
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
clusterIndex: number;
targetGroupKey: string;
selectedIndices: number[];
} | null>(null);
const groups = useMemo(() => { const groups = useMemo(() => {
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>(); const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
@@ -177,6 +184,38 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
setPendingAction({ group, sourceRow, fieldType: 'all' }); setPendingAction({ group, sourceRow, fieldType: 'all' });
}; };
const executeNearDupSync = async () => {
if (!pendingNearDupSync) return;
const { clusterIndex, targetGroupKey, selectedIndices } = pendingNearDupSync;
const cluster = nearDuplicateClusters[clusterIndex];
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[clusterIndex];
return next;
});
};
const executeSync = async () => { const executeSync = async () => {
if (!pendingAction) return; if (!pendingAction) return;
const { group, sourceRow, fieldType } = pendingAction; const { group, sourceRow, fieldType } = pendingAction;
+13 -3
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { X, Sparkles, Save, Loader2, Languages, Package } from 'lucide-react'; import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2 } from 'lucide-react';
import { generateGemini } from '../services/gemini'; import { generateGemini } from '../services/gemini';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
@@ -35,6 +35,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null); const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
const isModified = (field: keyof typeof formData) => { const isModified = (field: keyof typeof formData) => {
const colMap: Record<string, number> = { const colMap: Record<string, number> = {
@@ -107,7 +108,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
if (formData.shortEn) { if (formData.shortEn) {
prompt = `Translate exactly this short English product description into professional German for the toy market:\n\n${formData.shortEn}`; prompt = `Translate exactly this short English product description into professional German for the toy market:\n\n${formData.shortEn}`;
} else if (formData.longDe) { } else if (formData.longDe) {
prompt = `Create a short version (2-4 sentences max) of the following German product description:\n\n${formData.longDe}`; const targetChars = Math.round(formData.longDe.length * 0.3);
prompt = `Create a concise summary of the following German product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longDe}`;
} else { } else {
prompt = `Based on the following product details, generate a short commercial description in German (2-4 sentences max).\n\n${baseContext}`; prompt = `Based on the following product details, generate a short commercial description in German (2-4 sentences max).\n\n${baseContext}`;
} }
@@ -115,7 +117,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
if (formData.shortDe) { if (formData.shortDe) {
prompt = `Translate exactly this short German product description into professional English for the toy market:\n\n${formData.shortDe}`; prompt = `Translate exactly this short German product description into professional English for the toy market:\n\n${formData.shortDe}`;
} else if (formData.longEn) { } else if (formData.longEn) {
prompt = `Create a short version (2-4 sentences max) of the following English product description:\n\n${formData.longEn}`; const targetChars = Math.round(formData.longEn.length * 0.3);
prompt = `Create a concise summary of the following English product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longEn}`;
} else { } else {
prompt = `Based on the following product details, generate a short commercial description in English (2-4 sentences max).\n\n${baseContext}`; prompt = `Based on the following product details, generate a short commercial description in English (2-4 sentences max).\n\n${baseContext}`;
} }
@@ -123,6 +126,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
const generatedText = await generateGemini(prompt, systemPrompt); const generatedText = await generateGemini(prompt, systemPrompt);
setFormData(prev => ({ ...prev, [field]: generatedText.trim() })); setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
setGeneratedFields(prev => new Set(prev).add(field));
} catch (err: any) { } catch (err: any) {
setError(err.message || 'An error occurred during generation.'); setError(err.message || 'An error occurred during generation.');
} finally { } finally {
@@ -193,6 +197,12 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
{((field === 'longDe' && formData.longEn) || (field === 'longEn' && formData.longDe) || (field === 'shortDe' && formData.shortEn) || (field === 'shortEn' && formData.shortDe)) ? 'Translate with Gemini' : 'Generate with Gemini'} {((field === 'longDe' && formData.longEn) || (field === 'longEn' && formData.longDe) || (field === 'shortDe' && formData.shortEn) || (field === 'shortEn' && formData.shortDe)) ? 'Translate with Gemini' : 'Generate with Gemini'}
</button> </button>
</div> </div>
{generatedFields.has(field) && (
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
<CheckCircle2 className="w-3 h-3" />
AI Generated - You can still edit manually
</div>
)}
</div> </div>
<textarea <textarea
value={formData[field]} value={formData[field]}
+60 -31
View File
@@ -43,6 +43,7 @@ function findCol(headers: string[], ...keywords: string[]): number {
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }: PricingViewProps) { export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }: PricingViewProps) {
const [filterMode, setFilterMode] = useState<FilterMode>('all_errors'); const [filterMode, setFilterMode] = useState<FilterMode>('all_errors');
const [search, setSearch] = useState('');
const [editingCell, setEditingCell] = useState<EditingCell | null>(null); const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -129,13 +130,26 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
// ── Filtered rows ───────────────────────────────────────────────────────── // ── Filtered rows ─────────────────────────────────────────────────────────
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
let result = analyzedRows;
// Mode filter
switch (filterMode) { switch (filterMode) {
case 'all_errors': return analyzedRows.filter(r => r.hasErrors); case 'all_errors': result = analyzedRows.filter(r => r.hasErrors); break;
case 'pricing_errors': return analyzedRows.filter(r => r.pricingErrors.length > 0); case 'pricing_errors': result = analyzedRows.filter(r => r.pricingErrors.length > 0); break;
case 'units_errors': return analyzedRows.filter(r => r.unitErrors.length > 0); case 'units_errors': result = analyzedRows.filter(r => r.unitErrors.length > 0); break;
default: return analyzedRows;
} }
}, [analyzedRows, filterMode]);
// Search filter
if (search) {
const s = search.toLowerCase();
result = result.filter(r =>
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
}
return result;
}, [analyzedRows, filterMode, search]);
// ── Inline edit helpers ─────────────────────────────────────────────────── // ── Inline edit helpers ───────────────────────────────────────────────────
const startEdit = (rowIndex: number, colIndex: number, currentValue: string) => { const startEdit = (rowIndex: number, colIndex: number, currentValue: string) => {
@@ -228,6 +242,47 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
return ( return (
<div className="flex flex-col h-full gap-4"> <div className="flex flex-col h-full gap-4">
<div className="flex items-center justify-between gap-4">
{/* ── Search bar ── */}
<div className="flex-1 max-w-md relative">
<input
type="text"
placeholder="Search SKU or Name..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-4 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500">
<Package className="w-4 h-4" />
</div>
</div>
{/* ── Filter tabs ── */}
<div className="flex items-center gap-1 bg-slate-800/60 rounded-lg p-1 border border-slate-700/50 w-fit">
{FILTERS.map(f => (
<button
key={f.id}
onClick={() => setFilterMode(f.id)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
filterMode === f.id
? 'bg-slate-700 text-white shadow-sm'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/40'
)}
>
{f.label}
<span className={cn(
'text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[22px] text-center',
filterMode === f.id
? 'bg-slate-600 text-white'
: f.count > 0 ? `${f.color} bg-current/10` : 'text-slate-500'
)}>
{f.count}
</span>
</button>
))}
</div>
</div>
{/* ── Column detection warning ── */} {/* ── Column detection warning ── */}
{missingCols.length > 0 && ( {missingCols.length > 0 && (
@@ -248,32 +303,6 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
<StatCard label="All OK" value={stats.allOk} icon={<CheckCircle2 className="w-4 h-4" />} color="emerald" /> <StatCard label="All OK" value={stats.allOk} icon={<CheckCircle2 className="w-4 h-4" />} color="emerald" />
</div> </div>
{/* ── Filter tabs ── */}
<div className="flex items-center gap-1 bg-slate-800/60 rounded-lg p-1 border border-slate-700/50 w-fit">
{FILTERS.map(f => (
<button
key={f.id}
onClick={() => setFilterMode(f.id)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
filterMode === f.id
? 'bg-slate-700 text-white shadow-sm'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/40'
)}
>
{f.label}
<span className={cn(
'text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[22px] text-center',
filterMode === f.id
? 'bg-slate-600 text-white'
: f.count > 0 ? `${f.color} bg-current/10` : 'text-slate-500'
)}>
{f.count}
</span>
</button>
))}
</div>
{/* ── Table ── */} {/* ── Table ── */}
<div className="flex-1 overflow-auto bg-slate-800 rounded-xl border border-slate-700 shadow-xl"> <div className="flex-1 overflow-auto bg-slate-800 rounded-xl border border-slate-700 shadow-xl">
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
+18 -24
View File
@@ -8,7 +8,7 @@ interface ProductDescriptionsProps {
onEdit: (index: number) => void; onEdit: (index: number) => void;
} }
type TabType = 'all' | 'missingDeLong' | 'missingEnLong' | 'missingDeShort' | 'missingEnShort' | 'missingDeDetails' | 'missingEnDetails' | 'complete' | 'incomplete'; type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingLongAny' | 'missingShortDE' | 'missingShortEN' | 'missingShortAny' | 'complete' | 'incomplete';
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) { export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) {
const [activeTab, setActiveTab] = useState<TabType>('all'); const [activeTab, setActiveTab] = useState<TabType>('all');
@@ -27,21 +27,20 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
let result = data.map((row, index) => ({ row, index })); let result = data.map((row, index) => ({ row, index }));
// Tab filter // Tab filter
if (activeTab === 'missingDeLong') result = result.filter(r => !r.row[COLUMNS.LONG_DE]); if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
if (activeTab === 'missingEnLong') result = result.filter(r => !r.row[COLUMNS.LONG_EN]); if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
if (activeTab === 'missingDeShort') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]); if (activeTab === 'missingLongAny') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN]);
if (activeTab === 'missingEnShort') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]); if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
if (activeTab === 'missingDeDetails') result = result.filter(r => !r.row[COLUMNS.DETAILS_DE]); if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
if (activeTab === 'missingEnDetails') result = result.filter(r => !r.row[COLUMNS.DETAILS_EN]); if (activeTab === 'missingShortAny') result = result.filter(r => !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]);
if (activeTab === 'complete') result = result.filter(r => if (activeTab === 'complete') result = result.filter(r =>
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] && r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
r.row[COLUMNS.SHORT_DE] && r.row[COLUMNS.SHORT_EN] && r.row[COLUMNS.SHORT_DE] && r.row[COLUMNS.SHORT_EN]
r.row[COLUMNS.DETAILS_DE] && r.row[COLUMNS.DETAILS_EN]
); );
if (activeTab === 'incomplete') result = result.filter(r => if (activeTab === 'incomplete') result = result.filter(r =>
!r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN] || !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN] ||
!r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN] || !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]
!r.row[COLUMNS.DETAILS_DE] || !r.row[COLUMNS.DETAILS_EN]
); );
// Search filter // Search filter
@@ -96,11 +95,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
const fields = [ const fields = [
row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN], row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN],
row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN], row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]
row[COLUMNS.DETAILS_DE], row[COLUMNS.DETAILS_EN]
]; ];
const filled = fields.filter(Boolean).length; const filled = fields.filter(Boolean).length;
if (filled === 6) return 'bg-green-900/10 hover:bg-green-900/20'; if (filled === 4) return 'bg-green-900/10 hover:bg-green-900/20';
if (filled === 0) return 'bg-red-900/10 hover:bg-red-900/20'; if (filled === 0) return 'bg-red-900/10 hover:bg-red-900/20';
return 'bg-yellow-900/10 hover:bg-yellow-900/20'; return 'bg-yellow-900/10 hover:bg-yellow-900/20';
}; };
@@ -121,12 +119,12 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
const tabs: { id: TabType; label: string }[] = [ const tabs: { id: TabType; label: string }[] = [
{ id: 'all', label: 'All Products' }, { id: 'all', label: 'All Products' },
{ id: 'missingDeLong', label: 'Missing DE Long' }, { id: 'missingLongDE', label: 'Missing Long DE' },
{ id: 'missingEnLong', label: 'Missing EN Long' }, { id: 'missingLongEN', label: 'Missing Long EN' },
{ id: 'missingDeShort', label: 'Missing DE Short' }, { id: 'missingLongAny', label: 'Missing Long DE/EN' },
{ id: 'missingEnShort', label: 'Missing EN Short' }, { id: 'missingShortDE', label: 'Missing Short DE' },
{ id: 'missingDeDetails', label: 'Missing DE Details' }, { id: 'missingShortEN', label: 'Missing Short EN' },
{ id: 'missingEnDetails', label: 'Missing EN Details' }, { id: 'missingShortAny', label: 'Missing Short DE/EN' },
{ id: 'complete', label: 'Complete' }, { id: 'complete', label: 'Complete' },
{ id: 'incomplete', label: 'Incomplete' }, { id: 'incomplete', label: 'Incomplete' },
]; ];
@@ -188,8 +186,6 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
{ col: COLUMNS.ARTICLE_NO, label: 'Article No.' }, { col: COLUMNS.ARTICLE_NO, label: 'Article No.' },
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' }, { col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
{ col: COLUMNS.LINE, label: 'Line' }, { col: COLUMNS.LINE, label: 'Line' },
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
{ col: COLUMNS.LONG_DE, label: 'Long DE' }, { col: COLUMNS.LONG_DE, label: 'Long DE' },
{ col: COLUMNS.LONG_EN, label: 'Long EN' }, { col: COLUMNS.LONG_EN, label: 'Long EN' },
{ col: COLUMNS.SHORT_DE, label: 'Short DE' }, { col: COLUMNS.SHORT_DE, label: 'Short DE' },
@@ -217,8 +213,6 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
<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">{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 max-w-[200px] truncate" 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">{row[COLUMNS.LINE]}</td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.DETAILS_DE]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.DETAILS_EN]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td> <td className="px-4 py-3"><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"><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"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
+3 -2
View File
@@ -1,16 +1,17 @@
import React from 'react'; import React from 'react';
import { FileText, Table, Box, DollarSign } from 'lucide-react'; import { FileText, Table, Box, DollarSign, Package } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
interface SidebarProps { interface SidebarProps {
activeModule: string; activeModule: string;
setActiveModule: (m: 'descriptions' | 'completeness' | 'matrix' | 'dimensions' | 'pricing') => void; setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing') => void;
} }
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) { export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
const navItems = [ const navItems = [
{ id: 'matrix', label: 'Matrix', icon: Table }, { id: 'matrix', label: 'Matrix', icon: Table },
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText }, { id: 'descriptions', label: 'Product Descriptions', icon: FileText },
{ id: 'article_details', label: 'Article Details', icon: Package },
{ id: 'dimensions', label: 'Dimensions', icon: Box }, { id: 'dimensions', label: 'Dimensions', icon: Box },
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign }, { id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
] as const; ] as const;