diff --git a/src/App.tsx b/src/App.tsx index cfdce67..fbea57f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { getAllSyncedRows, saveRowToSupabase } from './lib/supabase'; import { getStoredSession, signOut, type AuthSession } from './lib/auth'; import { LoginPage } from './components/LoginPage'; import { DimensionsView } from './components/DimensionsView'; +import { PricingView } from './components/PricingView'; import { UndoToast } from './components/UndoToast'; export default function App() { @@ -31,7 +32,7 @@ export default function App() { fileDate: null, hasUnsavedChanges: false }); - const [activeModule, setActiveModule] = useState<'descriptions' | 'matrix' | 'dimensions'>('descriptions'); + const [activeModule, setActiveModule] = useState<'descriptions' | 'matrix' | 'dimensions' | 'pricing'>('descriptions'); const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]); const [editingRowIndex, setEditingRowIndex] = useState(null); const [isLoadingDefault, setIsLoadingDefault] = useState(true); @@ -354,14 +355,24 @@ export default function App() { )} {activeModule === 'dimensions' && ( - setEditingRowIndex(index)} onSaveRow={handleSaveRow} onCaptureState={captureState} /> )} + + {activeModule === 'pricing' && ( + setEditingRowIndex(index)} + /> + )} )} diff --git a/src/components/PricingView.tsx b/src/components/PricingView.tsx new file mode 100644 index 0000000..152b65f --- /dev/null +++ b/src/components/PricingView.tsx @@ -0,0 +1,515 @@ +import React, { useState, useMemo, useRef, useCallback } from 'react'; +import { ExcelRow, COLUMNS } from '../types'; +import { + AlertTriangle, + AlertCircle, + CheckCircle2, + DollarSign, + Package, + Save, + X, + ChevronDown, + Edit2, +} from 'lucide-react'; +import { cn } from '../lib/utils'; + +interface PricingViewProps { + data: ExcelRow[]; + headers: string[]; + onSaveRow: (index: number, updatedRow: ExcelRow) => void; + onCaptureState: (message: string) => void; + onEdit: (index: number) => void; +} + +interface DetectedCol { + index: number; + name: string; +} + +type FilterMode = 'all' | 'all_errors' | 'pricing_errors' | 'units_errors'; + +interface EditingCell { + rowIndex: number; + colIndex: number; + value: string; +} + +// Flexible multi-keyword column finder +function findCol(headers: string[], ...keywords: string[]): number { + return headers.findIndex(h => + keywords.every(kw => (h || '').toLowerCase().includes(kw.toLowerCase())) + ); +} + +export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }: PricingViewProps) { + const [filterMode, setFilterMode] = useState('all_errors'); + const [editingCell, setEditingCell] = useState(null); + const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + const inputRef = useRef(null); + + // ── Dynamic column detection ────────────────────────────────────────────── + const { uvpIdx, srpCols, containerCols } = useMemo(() => { + const uvpIdx = findCol(headers, 'uvp'); + + // All SRP columns, sorted: INT first, UK second, then alphabetically + const srp: DetectedCol[] = headers + .map((h, i) => ({ index: i, name: h || '' })) + .filter(({ name }) => /srp/i.test(name)) + .sort((a, b) => { + const order = (n: string) => { + if (/int/i.test(n)) return 0; + if (/uk/i.test(n)) return 1; + return 2; + }; + return order(a.name) - order(b.name); + }); + + // All 40' container columns (HC, HQ, etc.) + const container: DetectedCol[] = headers + .map((h, i) => ({ index: i, name: h || '' })) + .filter(({ name }) => /40/i.test(name) && /h[cq]/i.test(name)); + + return { uvpIdx, srpCols: srp, containerCols: container }; + }, [headers]); + + // ── Error analysis per row ──────────────────────────────────────────────── + const analyzedRows = useMemo(() => { + return data.map((row, dataIndex) => { + const pricingErrors: string[] = []; + const unitErrors: string[] = []; + + // UVP check + if (uvpIdx >= 0) { + const v = row[uvpIdx]; + if (v === undefined || v === null || v === '' || Number(v) === 0) { + pricingErrors.push('UVP missing'); + } + } + + // SRP checks + srpCols.forEach(({ index, name }) => { + const v = row[index]; + if (v === undefined || v === null || v === '' || Number(v) === 0) { + pricingErrors.push(`${name} missing`); + } + }); + + // Units per Outer check + const unitsOuter = Number(row[COLUMNS.UNITS_OUTER]); + if (!unitsOuter || unitsOuter === 0) { + unitErrors.push('Units/Outer: missing'); + } + + // 40' container checks + containerCols.forEach(({ index, name }) => { + const v = Number(row[index]); + if (!v || v === 0) unitErrors.push(`${name}: empty`); + else if (v === 1) unitErrors.push(`${name}: value is 1`); + }); + + return { + row, + dataIndex, + pricingErrors, + unitErrors, + hasErrors: pricingErrors.length > 0 || unitErrors.length > 0, + isCritical: unitErrors.length > 0, + }; + }); + }, [data, uvpIdx, srpCols, containerCols]); + + // ── Stats ───────────────────────────────────────────────────────────────── + const stats = useMemo(() => { + const withPricing = analyzedRows.filter(r => r.pricingErrors.length > 0).length; + const withUnits = analyzedRows.filter(r => r.unitErrors.length > 0).length; + const withAny = analyzedRows.filter(r => r.hasErrors).length; + const allOk = analyzedRows.length - withAny; + return { total: analyzedRows.length, withPricing, withUnits, withAny, allOk }; + }, [analyzedRows]); + + // ── Filtered rows ───────────────────────────────────────────────────────── + const filteredRows = useMemo(() => { + switch (filterMode) { + case 'all_errors': return analyzedRows.filter(r => r.hasErrors); + case 'pricing_errors': return analyzedRows.filter(r => r.pricingErrors.length > 0); + case 'units_errors': return analyzedRows.filter(r => r.unitErrors.length > 0); + default: return analyzedRows; + } + }, [analyzedRows, filterMode]); + + // ── Inline edit helpers ─────────────────────────────────────────────────── + const startEdit = (rowIndex: number, colIndex: number, currentValue: string) => { + setEditingCell({ rowIndex, colIndex, value: currentValue }); + setTimeout(() => inputRef.current?.focus(), 0); + }; + + const commitEdit = useCallback(async () => { + if (!editingCell) return; + const { rowIndex, colIndex, value } = editingCell; + const original = data[rowIndex]; + const newRow = [...original]; + newRow[colIndex] = value; + setSavingCell({ rowIndex, colIndex }); + setEditingCell(null); + onCaptureState(`Updated pricing for ${original[COLUMNS.ARTICLE_NO]}`); + await onSaveRow(rowIndex, newRow); + setSavingCell(null); + }, [editingCell, data, onSaveRow, onCaptureState]); + + const cancelEdit = () => setEditingCell(null); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') commitEdit(); + if (e.key === 'Escape') cancelEdit(); + }; + + // ── Column helpers ──────────────────────────────────────────────────────── + const pricingEditableCols: DetectedCol[] = [ + ...(uvpIdx >= 0 ? [{ index: uvpIdx, name: headers[uvpIdx] || 'UVP' }] : []), + ...srpCols, + ]; + + const formatPrice = (val: any) => { + if (val === undefined || val === null || val === '') return ''; + const n = parseFloat(String(val).replace(',', '.')); + return isNaN(n) ? String(val) : n.toFixed(2); + }; + + const formatUnits = (val: any) => { + if (val === undefined || val === null || val === '') return '—'; + return String(val); + }; + + const unitBadge = (val: any, label: string) => { + const n = Number(val); + if (!val || n === 0) return ( + + 0 + + ); + if (n === 1) return ( + + 1 + + ); + return ( + + {n.toLocaleString()} + + ); + }; + + const unitOuterBadge = (val: any) => { + const n = Number(val); + if (!val || n === 0) return ( + + Missing + + ); + return ( + + {n} + + ); + }; + + // ── Column detection notice ─────────────────────────────────────────────── + const missingCols: string[] = []; + if (uvpIdx < 0) missingCols.push('UVP'); + if (srpCols.length === 0) missingCols.push('SRP'); + if (containerCols.length === 0) missingCols.push('Units/40\''); + + const FILTERS: { id: FilterMode; label: string; count: number; color: string }[] = [ + { id: 'all', label: 'All Products', count: stats.total, color: 'text-slate-300' }, + { id: 'all_errors', label: 'All Issues', count: stats.withAny, color: 'text-red-400' }, + { id: 'pricing_errors', label: 'Pricing Issues', count: stats.withPricing, color: 'text-amber-400' }, + { id: 'units_errors', label: 'Units Issues', count: stats.withUnits, color: 'text-orange-400' }, + ]; + + return ( +
+ + {/* ── Column detection warning ── */} + {missingCols.length > 0 && ( +
+ + + Could not auto-detect columns for: {missingCols.join(', ')}. + Check that the Excel headers contain "UVP", "SRP", or "40HC"/"40HQ". + +
+ )} + + {/* ── Stat cards ── */} +
+ } color="slate" /> + } color="amber" /> + } color="red" /> + } color="emerald" /> +
+ + {/* ── Filter tabs ── */} +
+ {FILTERS.map(f => ( + + ))} +
+ + {/* ── Table ── */} +
+ {filteredRows.length === 0 ? ( +
+ +

No issues found

+

All products are correctly configured.

+
+ ) : ( + + + + + + + + {/* Editable pricing columns */} + {pricingEditableCols.map(col => ( + + ))} + + {/* Units/Outer */} + + + {/* Container columns */} + {containerCols.map(col => ( + + ))} + + {/* Status */} + + + {/* Actions */} + + + + {filteredRows.map(({ row, dataIndex, pricingErrors, unitErrors, isCritical }) => { + const hasAnyError = pricingErrors.length > 0 || unitErrors.length > 0; + return ( + 0 + ? 'bg-amber-950/10' + : '' + )} + > + {/* Article No */} + + + {/* Article Name */} + + + {/* Line */} + + + {/* Editable pricing cells */} + {pricingEditableCols.map(col => { + const isEditing = editingCell?.rowIndex === dataIndex && editingCell?.colIndex === col.index; + const isSaving = savingCell?.rowIndex === dataIndex && savingCell?.colIndex === col.index; + const val = formatPrice(row[col.index]); + const isEmpty = !row[col.index] || row[col.index] === '' || Number(row[col.index]) === 0; + + return ( + + ); + })} + + {/* Units/Outer */} + + + {/* Container unit columns */} + {containerCols.map(col => ( + + ))} + + {/* Status badge */} + + + {/* Edit button */} + + + ); + })} + +
+ Art. No. + + Article Name + + Line + + + + {col.name} + + + Units/Outer + + {col.name} + + Status + +
+ {row[COLUMNS.ARTICLE_NO]} + + + {row[COLUMNS.ARTICLE_NAME] || '—'} + + + {row[COLUMNS.LINE] || '—'} + + {isEditing ? ( +
+ setEditingCell(prev => prev ? { ...prev, value: e.target.value } : null)} + onKeyDown={handleKeyDown} + onBlur={commitEdit} + className="w-24 bg-slate-900 border border-blue-500 rounded px-2 py-1 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + + +
+ ) : ( + + )} +
+ {unitOuterBadge(row[COLUMNS.UNITS_OUTER])} + + {unitBadge(row[col.index], col.name)} + + {!hasAnyError ? ( + + OK + + ) : isCritical ? ( +
+ + Critical + + {unitErrors.map((e, i) => ( + {e} + ))} +
+ ) : ( +
+ + Incomplete + + {pricingErrors.map((e, i) => ( + {e} + ))} +
+ )} +
+ +
+ )} +
+ + {/* ── Footer count ── */} +

+ Showing {filteredRows.length} of {stats.total} products + {pricingEditableCols.length > 0 && ( + <> · Click any price cell to edit inline + )} +

+
+ ); +} + +// ── Stat card ───────────────────────────────────────────────────────────────── +function StatCard({ + label, + value, + icon, + color, +}: { + label: string; + value: number; + icon: React.ReactNode; + color: 'slate' | 'amber' | 'red' | 'emerald'; +}) { + const colors = { + slate: { bg: 'bg-slate-800', border: 'border-slate-700', text: 'text-slate-200', icon: 'text-slate-400' }, + amber: { bg: 'bg-amber-950/30', border: 'border-amber-700/40', text: 'text-amber-300', icon: 'text-amber-500' }, + red: { bg: 'bg-red-950/30', border: 'border-red-700/40', text: 'text-red-300', icon: 'text-red-500' }, + emerald: { bg: 'bg-emerald-950/20', border: 'border-emerald-700/30', text: 'text-emerald-300', icon: 'text-emerald-500' }, + }; + const c = colors[color]; + return ( +
+ {icon} +
+

{value}

+

{label}

+
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ea27642..9704acf 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { FileSpreadsheet, FileText, CheckSquare, Table, Box } from 'lucide-react'; +import { FileText, Table, Box, DollarSign } from 'lucide-react'; import { cn } from '../lib/utils'; interface SidebarProps { activeModule: string; - setActiveModule: (m: 'descriptions' | 'completeness' | 'matrix' | 'dimensions') => void; + setActiveModule: (m: 'descriptions' | 'completeness' | 'matrix' | 'dimensions' | 'pricing') => void; } export function Sidebar({ activeModule, setActiveModule }: SidebarProps) { @@ -12,6 +12,7 @@ export function Sidebar({ activeModule, setActiveModule }: SidebarProps) { { id: 'matrix', label: 'Matrix', icon: Table }, { id: 'descriptions', label: 'Product Descriptions', icon: FileText }, { id: 'dimensions', label: 'Dimensions', icon: Box }, + { id: 'pricing', label: 'Pricing & Units', icon: DollarSign }, ] as const; return (