From e6207dff6404b6c8d78feb7123f44b88b1c14099 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Fri, 24 Apr 2026 13:23:18 +0200 Subject: [PATCH] feat: add column pinning (freeze) functionality in Pricing Units tab - Add Pin Columns button to select columns to freeze - Implement sticky columns with left positioning for pinned columns - Support pinning dynamic pricing and container columns - Add Pin icon indicator on pinned columns - Include reset and unpin all options in pin panel --- src/components/EditPanel.tsx | 4 +- src/components/PricingView.tsx | 301 ++++++++++++++++++++++++++++----- src/services/gemini.ts | 20 ++- 3 files changed, 278 insertions(+), 47 deletions(-) diff --git a/src/components/EditPanel.tsx b/src/components/EditPanel.tsx index eb53dbb..67e07c5 100644 --- a/src/components/EditPanel.tsx +++ b/src/components/EditPanel.tsx @@ -195,7 +195,6 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed const executeGenerate = async () => { if (!pendingGeminiField) return; const field = pendingGeminiField; - setPendingGeminiField(null); setLoadingField(field); setError(null); @@ -283,8 +282,10 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed const textarea = document.getElementById(`field-${field}`); if (textarea) textarea.focus(); }, 100); + setPendingGeminiField(null); } catch (err: any) { setError(err.message || 'An error occurred during generation.'); + setPendingGeminiField(null); // Close modal on error so error message is visible in panel } finally { setLoadingField(null); } @@ -489,6 +490,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed setPendingGeminiField(null)} title="Confirm AI Action" diff --git a/src/components/PricingView.tsx b/src/components/PricingView.tsx index 57fc0ec..2c9f8c7 100644 --- a/src/components/PricingView.tsx +++ b/src/components/PricingView.tsx @@ -16,6 +16,8 @@ import { Search, MessageSquare, Maximize2, + Pin, + PinOff, } from 'lucide-react'; import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; @@ -101,6 +103,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, const [selectedSearchItems, setSelectedSearchItems] = useState>(new Set()); const searchDropdownRef = useRef(null); + // Pinned columns state + const [pinnedColumns, setPinnedColumns] = useState>(new Set(['articleNo', 'articleName'])); + const [showPinPanel, setShowPinPanel] = useState(false); + // Close search dropdown on click outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -113,8 +119,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, }, []); const searchSuggestions = useMemo(() => { - if (!search && selectedSearchItems.size === 0) return data.slice(0, 50); - if (!search) return []; + if (!search) return data.slice(0, 50); const terms = search.toLowerCase().split(/\s+/).filter(Boolean); if (terms.length === 0) return data.slice(0, 50); @@ -559,6 +564,62 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, ...srpCols, ]; + // ── Pinned columns for freeze ────────────────────────────────────────────── + const allPinnableColumns = useMemo(() => { + const cols: { key: string; label: string; index: number }[] = [ + { key: 'articleNo', label: 'Article No', index: COLUMNS.ARTICLE_NO }, + { key: 'articleName', label: 'Article Name', index: COLUMNS.ARTICLE_NAME }, + { key: 'line', label: 'Line', index: COLUMNS.LINE }, + { key: 'classification', label: 'Classification', index: COLUMNS.CLASSIFICATION }, + { key: 'productType', label: 'Type', index: COLUMNS.PRODUCT_TYPE }, + { key: 'itemToLogistic', label: 'Item to Logistic', index: COLUMNS.ITEM_TO_LOGISTIC }, + { key: 'unitsOuter', label: 'Units/Outer', index: unitsOuterIdx }, + { key: 'outerW', label: 'Outer W', index: COLUMNS.OUTER_W }, + { key: 'outerL', label: 'Outer L', index: COLUMNS.OUTER_L }, + { key: 'outerH', label: 'Outer H', index: COLUMNS.OUTER_H }, + ]; + pricingEditableCols.forEach(col => { + cols.push({ key: `prc_${col.index}`, label: col.name, index: col.index }); + }); + containerCols.forEach(col => { + cols.push({ key: `con_${col.index}`, label: col.name, index: col.index }); + }); + return cols; + }, [COLUMNS, unitsOuterIdx, pricingEditableCols, containerCols]); + + const togglePinnedColumn = (key: string) => { + setPinnedColumns(prev => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + // Column order for sticky positioning + const columnOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH']; + + // Calculate sticky left position for each pinned column + const getStickyLeft = useMemo(() => { + const pinnedOrder = columnOrder.filter(k => pinnedColumns.has(k)); + return (key: string): number | null => { + if (!pinnedColumns.has(key)) return null; + const idx = pinnedOrder.indexOf(key); + if (idx === -1) return null; + let left = 0; + for (let i = 0; i < idx; i++) { + const k = pinnedOrder[i]; + left += columnWidths[k] || 100; + } + return left; + }; + }, [pinnedColumns, columnOrder, columnWidths]); + + const isPinned = (key: string) => pinnedColumns.has(key); + // ═ Unique values for dynamic pricing columns ═════════════════════════════ const dynamicColUniqueValues = useMemo(() => { const result: Record = {}; @@ -844,6 +905,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, return ( + {/* ── Pin columns control ── */} +
+
+ + + {showPinPanel && ( +
+
+

Select columns to pin (freeze)

+
+
+ {allPinnableColumns.map(col => ( + + ))} +
+
+ + +
+
+ )} +
+
+ + Pinned: + {pinnedColumns.size === 0 ? ( + None + ) : ( +
+ {Array.from(pinnedColumns).map(key => { + const col = allPinnableColumns.find(c => c.key === key); + return col ? ( + + {col.label} + + ) : null; + })} +
+ )} +
+
+ {/* ── Table ── */}
{sortedRows.length === 0 ? ( @@ -969,9 +1120,15 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, - - - - - - @@ -1096,9 +1277,14 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, const colKey = `prc_${col.index}`; const width = columnWidths[colKey] ?? 100; return ( - - - - {/* Article Name */} - {/* Line */} - {/* Classification */} - - - - + + + + {/* Container units columns */} - {containerCols.map(col => ( - - ))} + ); + })} {/* Issues column (weight only) */}
handleSort('articleNo')}> + handleSort('articleNo')}>
- Article No + + {isPinned('articleNo') && } + Article No +
handleSort('articleName')}> + handleSort('articleName')}>
- Article Name + + {isPinned('articleName') && } + Article Name
handleSort('line')}> + handleSort('line')}>
- Line + + {isPinned('line') && } + Line
handleSort('classification')}> + handleSort('classification')}>
- Classification + + {isPinned('classification') && } + Classification
handleSort('productType')}> + handleSort('productType')}> + {isPinned('productType') && } Type handleSort('itemToLogistic')}> - Item to Logistic + handleSort('itemToLogistic')}> + + {isPinned('itemToLogistic') && } + Item to Logistic { e.stopPropagation(); handleResizeStart(e, 'itemToLogistic', columnWidths.itemToLogistic); }} /> handleSort(col.index)}> + handleSort(col.index)}>
- {col.name} + + {isPinned(colKey) && } + {col.name}
handleSort('unitsOuter')}> + handleSort('unitsOuter')}>
- Units/Outer + + {isPinned('unitsOuter') && } + Units/Outer
handleSort('outerW')}> + handleSort('outerW')}>
- Outer W + + {isPinned('outerW') && } + Outer W
handleSort('outerL')}> + handleSort('outerL')}>
- Outer L + + {isPinned('outerL') && } + Outer L
handleSort('outerH')}> + handleSort('outerH')}>
- Outer H + + {isPinned('outerH') && } + Outer H
handleSort(col.index)}> + handleSort(col.index)}>
- {col.name} + + {isPinned(colKey) && } + {col.name}
+ {row[COLUMNS.ARTICLE_NO]} + {row[COLUMNS.ARTICLE_NAME] || '—'} + {row[COLUMNS.LINE] || '—'} + {/* TYPE cell */} - + {editingType?.rowIndex === dataIndex ? (
{/* ITEM TO LOGISTIC cell */} -
+ {editingLogistic?.rowIndex === dataIndex ? ( + {isEditing ? (
{unitOuterBadge(row[unitsOuterIdx])}
{row[COLUMNS.OUTER_W] ?? '-'}{row[COLUMNS.OUTER_L] ?? '-'}{row[COLUMNS.OUTER_H] ?? '-'} + {unitOuterBadge(row[unitsOuterIdx])} + + {row[COLUMNS.OUTER_W] ?? '-'} + + {row[COLUMNS.OUTER_L] ?? '-'} + + {row[COLUMNS.OUTER_H] ?? '-'} + + {containerCols.map(col => { + const colKey = `con_${col.index}`; + return ( + {unitBadge(row[col.index], col.name)} diff --git a/src/services/gemini.ts b/src/services/gemini.ts index ab46dc3..fa563da 100644 --- a/src/services/gemini.ts +++ b/src/services/gemini.ts @@ -13,13 +13,12 @@ export async function generateGemini(prompt: string, systemPrompt: string): Prom throw new Error('Gemini API Key not found. Please set VITE_GEMINI_API_KEY in .env or GEMINI_API_KEY in localStorage.'); } - // Model fallback list - updated for March 2026 + // Model fallback list - using current stable and experimental versions const modelOptions = [ - 'gemini-2.5-flash', - 'gemini-2.5-flash-lite', - 'gemini-2.5-pro', - 'gemini-2.0-flash', - 'gemini-1.5-flash' + 'gemini-1.5-flash', + 'gemini-1.5-pro', + 'gemini-2.0-flash-exp', + 'gemini-2.0-flash' ]; let lastError: any = null; @@ -61,5 +60,12 @@ export async function generateGemini(prompt: string, systemPrompt: string): Prom } } - throw new Error(lastError?.error?.message || lastError?.message || 'All Gemini models failed. Check console for details.'); + let errorMessage = lastError?.error?.message || lastError?.message || 'All Gemini models failed.'; + + // Check if it's a quota error (429) + if (lastError?.error?.code === 429 || lastError?.status === 429 || errorMessage.toLowerCase().includes('quota') || errorMessage.toLowerCase().includes('429')) { + errorMessage = 'Gemini API quota exceeded. You may need to wait or switch to a paid billing plan in Google AI Studio.'; + } + + throw new Error(errorMessage + ' Check console for details.'); }