From 24042a6aedd091d62eb7d1131475af390b6fbb64 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Wed, 8 Apr 2026 16:10:14 +0200 Subject: [PATCH] feat: reorganize tabs, add Article Details, and enhance filters/search --- src/App.tsx | 9 +- src/components/ArticleDetails.tsx | 256 +++++++++++++++++++++++++ src/components/DimensionsView.tsx | 39 ++++ src/components/EditPanel.tsx | 16 +- src/components/PricingView.tsx | 91 ++++++--- src/components/ProductDescriptions.tsx | 42 ++-- src/components/Sidebar.tsx | 5 +- 7 files changed, 397 insertions(+), 61 deletions(-) create mode 100644 src/components/ArticleDetails.tsx diff --git a/src/App.tsx b/src/App.tsx index fbea57f..ecad282 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { getStoredSession, signOut, type AuthSession } from './lib/auth'; import { LoginPage } from './components/LoginPage'; import { DimensionsView } from './components/DimensionsView'; import { PricingView } from './components/PricingView'; +import { ArticleDetails } from './components/ArticleDetails'; import { UndoToast } from './components/UndoToast'; export default function App() { @@ -32,7 +33,7 @@ export default function App() { fileDate: null, 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 [editingRowIndex, setEditingRowIndex] = useState(null); const [isLoadingDefault, setIsLoadingDefault] = useState(true); @@ -373,6 +374,12 @@ export default function App() { onEdit={(index) => setEditingRowIndex(index)} /> )} + {activeModule === 'article_details' && ( + setEditingRowIndex(index)} + /> + )} )} diff --git a/src/components/ArticleDetails.tsx b/src/components/ArticleDetails.tsx new file mode 100644 index 0000000..d42894d --- /dev/null +++ b/src/components/ArticleDetails.tsx @@ -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('all'); + const [search, setSearch] = useState(''); + const [lineFilter, setLineFilter] = useState(''); + const [sortCol, setSortCol] = useState(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 Empty; + + 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 {val}; + }; + + 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 ( +
+
+ {tabs.map(tab => ( + + ))} +
+ +
+
+ + { 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" + /> +
+ +
+ +
+
+ + + + {[ + { 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 }) => ( + + ))} + + + + + {paginatedData.map(({ row, index }) => ( + + + + + + + + + + + + + ))} + {paginatedData.length === 0 && ( + + + + )} + +
handleSort(col)} + > +
+ {label} + {sortCol === col && ( + sortDesc ? : + )} +
+
Edit
{row[COLUMNS.ARTICLE_NO]} + {row[COLUMNS.ARTICLE_NAME]} + {row[COLUMNS.BARCODE] || '—'}{row[COLUMNS.TARIFF_CODE] || '—'}{row[COLUMNS.COUNTRY_ORIGIN] || '—'} + + {row[COLUMNS.CLASSIFICATION] || '—'} + + + + {row[COLUMNS.ITEM_AVAILABLE] || 0} + + + {row[COLUMNS.DETAILS_DE] ? ( +
{row[COLUMNS.DETAILS_DE]}
+ ) : getBadge(null)} +
+ {row[COLUMNS.DETAILS_EN] ? ( +
{row[COLUMNS.DETAILS_EN]}
+ ) : getBadge(null)} +
+ +
+ No articles found. +
+
+ +
+
Showing {paginatedData.length} of {filteredData.length} articles
+
+ + Page {page} of {totalPages || 1} + +
+
+
+
+ ); +} diff --git a/src/components/DimensionsView.tsx b/src/components/DimensionsView.tsx index d1a5680..0d597e2 100644 --- a/src/components/DimensionsView.tsx +++ b/src/components/DimensionsView.tsx @@ -41,6 +41,13 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat sourceRow: ExcelRow, fieldType: 'outer' | 'units' | 'moq' | 'all' } | null>(null); + const [clusterSelections, setClusterSelections] = useState>>({}); + const [clusterSyncTargets, setClusterSyncTargets] = useState>({}); + const [pendingNearDupSync, setPendingNearDupSync] = useState<{ + clusterIndex: number; + targetGroupKey: string; + selectedIndices: number[]; + } | null>(null); const groups = useMemo(() => { const groupMap = new Map(); @@ -177,6 +184,38 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat 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 () => { if (!pendingAction) return; const { group, sourceRow, fieldType } = pendingAction; diff --git a/src/components/EditPanel.tsx b/src/components/EditPanel.tsx index 8100132..c4f54eb 100644 --- a/src/components/EditPanel.tsx +++ b/src/components/EditPanel.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; 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 { cn } from '../lib/utils'; import { ConfirmModal } from './ConfirmModal'; @@ -35,6 +35,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed const [error, setError] = useState(null); const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [pendingGeminiField, setPendingGeminiField] = useState(null); + const [generatedFields, setGeneratedFields] = useState>(new Set()); const isModified = (field: keyof typeof formData) => { const colMap: Record = { @@ -107,7 +108,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed if (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) { - 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 { 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) { prompt = `Translate exactly this short German product description into professional English for the toy market:\n\n${formData.shortDe}`; } 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 { 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); setFormData(prev => ({ ...prev, [field]: generatedText.trim() })); + setGeneratedFields(prev => new Set(prev).add(field)); } catch (err: any) { setError(err.message || 'An error occurred during generation.'); } 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'} + {generatedFields.has(field) && ( +
+ + AI Generated - You can still edit manually +
+ )}