From 11d0555eeb7790397d16df39b08f67c716bb2ac0 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 26 Jan 2026 23:54:46 -0500 Subject: [PATCH] Product import fixes/enhancements --- .../components/ProductCard/SortableImage.tsx | 2 +- .../hooks/useProductImagesInit.ts | 2 +- .../components/product-import/steps/Steps.tsx | 18 +- .../product-import/steps/UploadFlow.tsx | 3 +- .../components/ValidationTable.tsx | 427 +++++++++++++++--- .../components/ValidationToolbar.tsx | 50 +- .../components/cells/InputCell.tsx | 52 ++- .../components/cells/MultilineInput.tsx | 103 ++++- .../hooks/useValidationActions.ts | 29 +- .../steps/ValidationStep/index.tsx | 63 ++- .../ValidationStep/store/validationStore.ts | 30 ++ .../steps/ValidationStep/utils/upcUtils.ts | 19 +- inventory/src/config/dashboard.ts | 2 +- inventory/vite.config.ts | 26 +- 14 files changed, 660 insertions(+), 166 deletions(-) diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx index 3132ee9..c708b79 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx @@ -25,7 +25,7 @@ const getFullImageUrl = (url: string): string => { } // Otherwise, it's a relative URL, prepend the domain - const baseUrl = 'https://acot.site'; + const baseUrl = 'https://tools.acherryontop.com'; // Make sure url starts with / for path const path = url.startsWith('/') ? url : `/${url}`; return `${baseUrl}${path}`; diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useProductImagesInit.ts b/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useProductImagesInit.ts index 79d80a0..07b9e7a 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useProductImagesInit.ts +++ b/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useProductImagesInit.ts @@ -74,7 +74,7 @@ export const useProductImagesInit = (data: Product[]) => { } // Otherwise, it's a relative URL, prepend the domain - const baseUrl = 'https://acot.site'; + const baseUrl = 'https://tools.acherryontop.com'; // Make sure url starts with / for path const path = url.startsWith('/') ? url : `/${url}`; return `${baseUrl}${path}`; diff --git a/inventory/src/components/product-import/steps/Steps.tsx b/inventory/src/components/product-import/steps/Steps.tsx index d007f32..49dbd77 100644 --- a/inventory/src/components/product-import/steps/Steps.tsx +++ b/inventory/src/components/product-import/steps/Steps.tsx @@ -30,7 +30,14 @@ export const Steps = () => { const onClickStep = (stepIndex: number) => { const type = stepIndexToStepType(stepIndex) - const historyIdx = history.current.findIndex((v) => v.type === type) + let historyIdx = history.current.findIndex((v) => v.type === type) + + // Special case: step index 0 could be either upload or selectSheet + // If we didn't find upload, also check for selectSheet + if (historyIdx === -1 && stepIndex === 0) { + historyIdx = history.current.findIndex((v) => v.type === StepType.selectSheet) + } + if (historyIdx === -1) return const nextHistory = history.current.slice(0, historyIdx + 1) history.current = nextHistory @@ -39,7 +46,14 @@ export const Steps = () => { } const onBack = () => { - onClickStep(Math.max(activeStep - 1, 0)) + // For back navigation, we want to go to the previous entry in history + // rather than relying on step index, since selectSheet shares index with upload + if (history.current.length > 0) { + const previousState = history.current[history.current.length - 1] + history.current = history.current.slice(0, -1) + setState(previousState) + setActiveStep(stepTypeToStepIndex(previousState.type)) + } } const onNext = (v: StepState) => { diff --git a/inventory/src/components/product-import/steps/UploadFlow.tsx b/inventory/src/components/product-import/steps/UploadFlow.tsx index fc3aca0..992740a 100644 --- a/inventory/src/components/product-import/steps/UploadFlow.tsx +++ b/inventory/src/components/product-import/steps/UploadFlow.tsx @@ -123,11 +123,12 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { // Keep track of global selections across steps const [persistedGlobalSelections, setPersistedGlobalSelections] = useState( state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns - ? state.globalSelections + ? state.globalSelections : undefined ) + switch (state.type) { case StepType.upload: return ( diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx index 57f191f..0b8fd96 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Checkbox } from '@/components/ui/checkbox'; -import { ArrowDown, Wand2, Loader2, Calculator, Scale } from 'lucide-react'; +import { ArrowDown, Wand2, Loader2, Calculator, Scale, Pin, PinOff } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -1210,8 +1210,10 @@ interface VirtualRowProps { columns: ColumnDef[]; fields: Field[]; totalRowCount: number; - /** Whether table is scrolled horizontally - used for sticky column shadow */ - isScrolledHorizontally: boolean; + /** Whether the name column sticky behavior is enabled */ + nameColumnSticky: boolean; + /** Direction for sticky name column: 'left', 'right', or null (not sticky) */ + stickyDirection: 'left' | 'right' | null; } const VirtualRow = memo(({ @@ -1221,7 +1223,8 @@ const VirtualRow = memo(({ columns, fields, totalRowCount, - isScrolledHorizontally, + nameColumnSticky, + stickyDirection, }: VirtualRowProps) => { // Subscribe to row data - this is THE subscription for all cell values in this row const rowData = useValidationStore( @@ -1317,13 +1320,18 @@ const VirtualRow = memo(({
{/* Selection checkbox cell */}
{ - const rowCount = useValidationStore((state) => state.rows.length); - const selectedCount = useValidationStore((state) => state.selectedRows.size); + const filters = useFilters(); - const allSelected = rowCount > 0 && selectedCount === rowCount; - const someSelected = selectedCount > 0 && selectedCount < rowCount; + // Compute which rows are visible based on current filters + const { visibleRowIds, visibleCount } = useMemo(() => { + const { rows, errors } = useValidationStore.getState(); + const isFiltering = filters.searchText || filters.showErrorsOnly; + + if (!isFiltering) { + // No filtering - all rows are visible + const ids = new Set(rows.map((row) => row.__index)); + return { visibleRowIds: ids, visibleCount: rows.length }; + } + + // Apply filters to get visible row IDs + const ids = new Set(); + rows.forEach((row, index) => { + // Apply search filter + if (filters.searchText) { + const searchLower = filters.searchText.toLowerCase(); + const matches = Object.values(row).some((value) => + String(value ?? '').toLowerCase().includes(searchLower) + ); + if (!matches) return; + } + + // Apply errors-only filter + if (filters.showErrorsOnly) { + const rowErrors = errors.get(index); + if (!rowErrors || Object.keys(rowErrors).length === 0) return; + } + + ids.add(row.__index); + }); + + return { visibleRowIds: ids, visibleCount: ids.size }; + }, [filters.searchText, filters.showErrorsOnly]); + + // Check selection state against visible rows only + const selectedRows = useValidationStore((state) => state.selectedRows); + const selectedVisibleCount = useMemo(() => { + let count = 0; + visibleRowIds.forEach((id) => { + if (selectedRows.has(id)) count++; + }); + return count; + }, [visibleRowIds, selectedRows]); + + const allVisibleSelected = visibleCount > 0 && selectedVisibleCount === visibleCount; + const someVisibleSelected = selectedVisibleCount > 0 && selectedVisibleCount < visibleCount; const handleChange = useCallback((value: boolean | 'indeterminate') => { - const { setSelectedRows, rows } = useValidationStore.getState(); + const { setSelectedRows, selectedRows: currentSelected } = useValidationStore.getState(); if (value) { - const allIds = new Set(rows.map((row) => row.__index)); - setSelectedRows(allIds); + // Add all visible rows to selection (keep existing selections of non-visible rows) + const newSelection = new Set(currentSelected); + visibleRowIds.forEach((id) => newSelection.add(id)); + setSelectedRows(newSelection); } else { - setSelectedRows(new Set()); + // Remove all visible rows from selection (keep selections of non-visible rows) + const newSelection = new Set(currentSelected); + visibleRowIds.forEach((id) => newSelection.delete(id)); + setSelectedRows(newSelection); } - }, []); + }, [visibleRowIds]); return ( ); @@ -1536,20 +1611,19 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead : 'Fill empty cells with MSRP ÷ 2'; // Check if there are any cells that can be filled (called on hover) + // Now returns true if ANY row has a valid source value (allows overwriting existing values) const checkFillableCells = useCallback(() => { const { rows } = useValidationStore.getState(); return rows.some((row) => { - const currentValue = row[fieldKey]; const sourceValue = row[sourceField]; - const isEmpty = currentValue === undefined || currentValue === null || currentValue === ''; const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; - if (isEmpty && hasSource) { + if (hasSource) { const sourceNum = parseFloat(String(sourceValue)); return !isNaN(sourceNum) && sourceNum > 0; } return false; }); - }, [fieldKey, sourceField]); + }, [sourceField]); // Update fillable check on hover const handleMouseEnter = useCallback(() => { @@ -1563,29 +1637,28 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead // Use setState() for efficient batch update with Immer useValidationStore.setState((draft) => { draft.rows.forEach((row, index) => { - const currentValue = row[fieldKey]; const sourceValue = row[sourceField]; - // Only fill if current field is empty and source has a value - const isEmpty = currentValue === undefined || currentValue === null || currentValue === ''; + // Fill if source has a value (overwrite existing values based on source) const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; - if (isEmpty && hasSource) { + if (hasSource) { const sourceNum = parseFloat(String(sourceValue)); if (!isNaN(sourceNum) && sourceNum > 0) { let msrp = sourceNum * multiplier; if (multiplier === 2.0) { - // For 2x: auto-adjust by ±1 cent to get to .99 if close + // For 2x: auto-adjust by ±1 cent ONLY if result ends in .99 const cents = Math.round((msrp % 1) * 100); - if (cents === 0) { - // .00 → subtract 1 cent to get .99 - msrp -= 0.01; - } else if (cents === 98) { - // .98 → add 1 cent to get .99 - msrp += 0.01; + if (cents === 0 || cents === 98) { + const adjustment = cents === 0 ? -0.01 : 0.01; + const adjusted = (msrp + adjustment).toFixed(2); + // Only apply if the adjusted value actually ends in .99 + if (adjusted.endsWith('.99')) { + msrp = parseFloat(adjusted); + } } - // Otherwise leave as-is + // Otherwise leave as-is (exact 2x) } else if (roundNine) { // For >2x with checkbox: round to nearest .X9 msrp = roundToNine(msrp); @@ -1616,13 +1689,12 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead useValidationStore.setState((draft) => { draft.rows.forEach((row, index) => { - const currentValue = row[fieldKey]; const sourceValue = row[sourceField]; - const isEmpty = currentValue === undefined || currentValue === null || currentValue === ''; + // Fill if source has a value (overwrite existing values based on source) const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; - if (isEmpty && hasSource) { + if (hasSource) { const sourceNum = parseFloat(String(sourceValue)); if (!isNaN(sourceNum) && sourceNum > 0) { draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2); @@ -1928,6 +2000,165 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader'; +/** + * DefaultValueColumnHeader Component + * + * Renders a column header with a hover button that sets a default value for all rows. + * Used for Tax Category ("Not Specifically Set") and Shipping Restrictions ("None"). + * + * PERFORMANCE: Uses local hover state and getState() for bulk updates. + */ +interface DefaultValueColumnHeaderProps { + fieldKey: 'tax_cat' | 'ship_restrictions'; + label: string; + isRequired: boolean; +} + +const DEFAULT_VALUE_CONFIG: Record = { + tax_cat: { value: '0', displayName: 'Not Specifically Set', buttonLabel: 'Set All Default' }, + ship_restrictions: { value: '0', displayName: 'None', buttonLabel: 'Set All None' }, +}; + +const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultValueColumnHeaderProps) => { + const [isHovered, setIsHovered] = useState(false); + const [hasEmptyCells, setHasEmptyCells] = useState(false); + + const config = DEFAULT_VALUE_CONFIG[fieldKey]; + + // Check if there are any empty cells that can be filled + const checkEmptyCells = useCallback(() => { + const { rows } = useValidationStore.getState(); + return rows.some((row) => { + const value = row[fieldKey]; + return value === undefined || value === null || value === ''; + }); + }, [fieldKey]); + + // Update empty check on hover + const handleMouseEnter = useCallback(() => { + setIsHovered(true); + setHasEmptyCells(checkEmptyCells()); + }, [checkEmptyCells]); + + const handleSetDefault = useCallback(() => { + const updatedIndices: number[] = []; + + useValidationStore.setState((draft) => { + draft.rows.forEach((row, index) => { + const value = row[fieldKey]; + const isEmpty = value === undefined || value === null || value === ''; + + if (isEmpty) { + draft.rows[index][fieldKey] = config.value; + updatedIndices.push(index); + } + }); + }); + + if (updatedIndices.length > 0) { + const { clearFieldError } = useValidationStore.getState(); + updatedIndices.forEach((rowIndex) => { + clearFieldError(rowIndex, fieldKey); + }); + + toast.success(`Set ${updatedIndices.length} row${updatedIndices.length === 1 ? '' : 's'} to "${config.displayName}"`); + } + }, [fieldKey, config]); + + return ( +
setIsHovered(false)} + > + {label} + {isRequired && ( + * + )} + {isHovered && hasEmptyCells && ( + + + + + + +

Fill empty cells with "{config.displayName}"

+
+
+
+ )} +
+ ); +}); + +DefaultValueColumnHeader.displayName = 'DefaultValueColumnHeader'; + +/** + * NameColumnHeader Component + * + * Renders the Name column header with a sticky toggle button. + * Pin icon toggles whether the name column sticks to edges when scrolling. + */ +interface NameColumnHeaderProps { + label: string; + isRequired: boolean; + isSticky: boolean; + onToggleSticky: () => void; +} + +const NameColumnHeader = memo(({ label, isRequired, isSticky, onToggleSticky }: NameColumnHeaderProps) => { + return ( +
+ {label} + {isRequired && ( + * + )} + + + + + + +

{isSticky ? 'Unpin column' : 'Pin column'}

+
+
+
+
+ ); +}); + +NameColumnHeader.displayName = 'NameColumnHeader'; + /** * Main table component * @@ -1958,18 +2189,46 @@ export const ValidationTable = () => { return offset; }, [fields]); - // Track horizontal scroll for sticky column shadow - const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false); + // Name column sticky toggle - when enabled, column sticks to left/right edges based on scroll + const [nameColumnSticky, setNameColumnSticky] = useState(true); - // Sync header scroll with body scroll + track horizontal scroll state + // Track scroll direction relative to name column: 'left' (stick to left) or 'right' (stick to right) + const [stickyDirection, setStickyDirection] = useState<'left' | 'right' | null>(null); + + // Calculate name column width + const nameColumnWidth = useMemo(() => { + const nameField = fields.find(f => f.key === 'name'); + return nameField?.width || 400; + }, [fields]); + + // Sync header scroll with body scroll + track sticky direction const handleScroll = useCallback(() => { if (tableContainerRef.current && headerRef.current) { const scrollLeft = tableContainerRef.current.scrollLeft; + const viewportWidth = tableContainerRef.current.clientWidth; headerRef.current.scrollLeft = scrollLeft; - // Only show shadow when scrolled past the name column's natural position - setIsScrolledHorizontally(scrollLeft > nameColumnLeftOffset); + + // Calculate name column's position relative to viewport + const namePositionInViewport = nameColumnLeftOffset - scrollLeft; + const nameRightEdge = namePositionInViewport + nameColumnWidth; + + // Determine sticky direction for name column + if (nameColumnSticky) { + if (scrollLeft > nameColumnLeftOffset) { + // Scrolled right past name column - stick to left + setStickyDirection('left'); + } else if (nameRightEdge > viewportWidth) { + // Name column extends beyond viewport to the right - stick to right + setStickyDirection('right'); + } else { + // Name column is fully visible - no sticky needed + setStickyDirection(null); + } + } else { + setStickyDirection(null); + } } - }, [nameColumnLeftOffset]); + }, [nameColumnLeftOffset, nameColumnWidth, nameColumnSticky]); // Compute filtered indices AND row IDs in a single pass // This avoids calling getState() during render for each row @@ -2012,6 +2271,11 @@ export const ValidationTable = () => { return { filteredIndices: indices, rowIdMap: idMap }; }, [rowCount, filters.searchText, filters.showErrorsOnly]); + // Toggle for sticky name column + const toggleNameColumnSticky = useCallback(() => { + setNameColumnSticky(prev => !prev); + }, []); + // Build columns - ONLY depends on fields, NOT selection state // Selection state is handled by isolated HeaderCheckbox component const columns = useMemo[]>(() => { @@ -2034,9 +2298,21 @@ export const ValidationTable = () => { const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false; const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each'; const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height'; + const isDefaultValueColumn = field.key === 'tax_cat' || field.key === 'ship_restrictions'; + const isNameColumn = field.key === 'name'; // Determine which header component to render const renderHeader = () => { + if (isNameColumn) { + return ( + + ); + } if (isPriceColumn) { return ( { /> ); } + if (isDefaultValueColumn) { + return ( + + ); + } return (
{field.label} @@ -2073,7 +2358,7 @@ export const ValidationTable = () => { }); return [selectionColumn, templateColumn, ...dataColumns]; - }, [fields]); // CRITICAL: No selection-related deps! + }, [fields, nameColumnSticky, toggleNameColumnSticky]); // Added nameColumnSticky dependencies // Calculate total table width for horizontal scrolling const totalTableWidth = useMemo(() => { @@ -2109,20 +2394,31 @@ export const ValidationTable = () => { > {columns.map((column, index) => { const isNameColumn = column.id === 'name'; + // Determine sticky behavior for header name column + const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null; + const stickyLeft = shouldBeSticky && stickyDirection === 'left'; + const stickyRight = shouldBeSticky && stickyDirection === 'right'; + return (
{typeof column.header === 'function' @@ -2159,7 +2455,8 @@ export const ValidationTable = () => { columns={columns} fields={fields} totalRowCount={rowCount} - isScrolledHorizontally={isScrolledHorizontally} + nameColumnSticky={nameColumnSticky} + stickyDirection={stickyDirection} /> ); })} diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx index 3a8f5b4..0672f11 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx @@ -10,7 +10,7 @@ */ import { useMemo, useCallback, useState } from 'react'; -import { Search, Plus, FolderPlus, Edit3 } from 'lucide-react'; +import { Search, Plus, FolderPlus, Edit3, X } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; @@ -39,6 +39,38 @@ export const ValidationToolbar = ({ const filters = useFilters(); const fields = useFields(); + // Compute filtered count when filtering is active + const filteredCount = useMemo(() => { + const isFiltering = filters.searchText || filters.showErrorsOnly; + if (!isFiltering) return rowCount; + + const { rows, errors } = useValidationStore.getState(); + let count = 0; + + rows.forEach((row, index) => { + // Apply search filter + if (filters.searchText) { + const searchLower = filters.searchText.toLowerCase(); + const matches = Object.values(row).some((value) => + String(value ?? '').toLowerCase().includes(searchLower) + ); + if (!matches) return; + } + + // Apply errors-only filter + if (filters.showErrorsOnly) { + const rowErrors = errors.get(index); + if (!rowErrors || Object.keys(rowErrors).length === 0) return; + } + + count++; + }); + + return count; + }, [filters.searchText, filters.showErrorsOnly, rowCount]); + + const isFiltering = filters.searchText || filters.showErrorsOnly; + // State for the product search template dialog const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); @@ -112,12 +144,24 @@ export const ValidationToolbar = ({ placeholder="Filter products..." value={filters.searchText} onChange={(e) => setSearchText(e.target.value)} - className="pl-9" + className={filters.searchText ? "pl-9 pr-8" : "pl-9"} /> + {filters.searchText && ( + + )}
{/* Product count */} - {rowCount} products + + {isFiltering ? `${filteredCount} of ${rowCount} products shown` : `${rowCount} products`} + {/* Action buttons */}
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx index 4a24e75..f8ede1b 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx @@ -3,9 +3,14 @@ * * Editable input cell for text, numbers, and price values. * Memoized to prevent unnecessary re-renders when parent table updates. + * + * PRICE PRECISION: For price fields, we store FULL precision internally + * (e.g., "3.625") but display rounded to 2 decimals when not focused. + * This allows calculations like 2x to use full precision while showing + * user-friendly rounded values in the UI. */ -import { useState, useCallback, useEffect, useRef, memo } from 'react'; +import { useState, useCallback, useEffect, useRef, memo, useMemo } from 'react'; import { Input } from '@/components/ui/input'; import { AlertCircle } from 'lucide-react'; import { @@ -21,6 +26,17 @@ import type { ValidationError } from '../../store/types'; import { ErrorType } from '../../store/types'; import { useValidationStore } from '../../store/validationStore'; +/** + * Format a price value for display (2 decimal places) + * Returns the original string if it's not a valid number + */ +const formatPriceForDisplay = (value: string): string => { + if (!value) return value; + const num = parseFloat(value); + if (isNaN(num)) return value; + return num.toFixed(2); +}; + /** Time window (ms) during which this cell should not focus after a popover closes */ const POPOVER_CLOSE_DELAY = 150; @@ -43,10 +59,14 @@ const InputCellComponent = ({ errors, onBlur, }: InputCellProps) => { + // Store the full precision value internally const [localValue, setLocalValue] = useState(String(value ?? '')); const [isFocused, setIsFocused] = useState(false); const inputRef = useRef(null); + // Check if this is a price field + const isPriceField = 'price' in field.fieldType && field.fieldType.price; + // Get store state for coordinating with popover close behavior const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); @@ -57,6 +77,14 @@ const InputCellComponent = ({ } }, [value, isFocused]); + // For price fields: show formatted value when not focused, full precision when focused + const displayValue = useMemo(() => { + if (isPriceField && !isFocused && localValue) { + return formatPriceForDisplay(localValue); + } + return localValue; + }, [isPriceField, isFocused, localValue]); + // PERFORMANCE: Only update local state while typing, NOT the store // The store is updated on blur, which prevents thousands of subscription // checks per keystroke @@ -86,23 +114,13 @@ const InputCellComponent = ({ }, [cellPopoverClosedAt]); // Update store only on blur - this is when validation runs too - // Round price fields to 2 decimal places + // IMPORTANT: We store FULL precision for price fields to allow accurate calculations + // The display formatting happens separately via displayValue const handleBlur = useCallback(() => { setIsFocused(false); - - let valueToSave = localValue; - - // Round price fields to 2 decimal places - if ('price' in field.fieldType && field.fieldType.price && localValue) { - const numValue = parseFloat(localValue); - if (!isNaN(numValue)) { - valueToSave = numValue.toFixed(2); - setLocalValue(valueToSave); - } - } - - onBlur(valueToSave); - }, [localValue, onBlur, field.fieldType]); + // Store the full precision value - no rounding here + onBlur(localValue); + }, [localValue, onBlur]); // Process errors - show icon only for non-required errors when field has value // Don't show error icon while user is actively editing (focused) @@ -129,7 +147,7 @@ const InputCellComponent = ({
(null); const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false); const [editedSuggestion, setEditedSuggestion] = useState(''); + const [popoverWidth, setPopoverWidth] = useState(400); const cellRef = useRef(null); const preventReopenRef = useRef(false); // Tracks intentional closes (close button, accept/dismiss) vs click-outside closes const intentionalCloseRef = useRef(false); + const mainTextareaRef = useRef(null); + const suggestionTextareaRef = useRef(null); + // Tracks the value when popover opened, to detect actual changes + const initialEditValueRef = useRef(''); // Get store state and actions for coordinating popover close behavior across cells const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); @@ -112,11 +117,44 @@ const MultilineInputComponent = ({ } }, [aiSuggestion?.suggestion]); + // Auto-resize a textarea to fit its content + const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => { + if (!textarea) return; + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + }, []); + + // Auto-resize main textarea when value changes or popover opens + useEffect(() => { + if (popoverOpen) { + // Small delay to ensure textarea is rendered + requestAnimationFrame(() => { + autoResizeTextarea(mainTextareaRef.current); + }); + } + }, [popoverOpen, editValue, autoResizeTextarea]); + + // Auto-resize suggestion textarea when expanded or value changes + useEffect(() => { + if (aiSuggestionExpanded) { + requestAnimationFrame(() => { + autoResizeTextarea(suggestionTextareaRef.current); + }); + } + }, [aiSuggestionExpanded, editedSuggestion, autoResizeTextarea]); + // Check if another cell's popover was recently closed (prevents immediate focus on click-outside) const wasPopoverRecentlyClosed = useCallback(() => { return Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY; }, [cellPopoverClosedAt]); + // Calculate and set popover width based on cell width + const updatePopoverWidth = useCallback(() => { + if (cellRef.current) { + setPopoverWidth(Math.max(cellRef.current.offsetWidth, 200)); + } + }, []); + // Handle trigger click to toggle the popover const handleTriggerClick = useCallback( (e: React.MouseEvent) => { @@ -136,23 +174,26 @@ const MultilineInputComponent = ({ // Only process if not already open if (!popoverOpen) { + updatePopoverWidth(); setPopoverOpen(true); - // Initialize edit value from the current display - setEditValue(localDisplayValue || String(value ?? '')); + // Initialize edit value from the current display and track it for change detection + const initValue = localDisplayValue || String(value ?? ''); + setEditValue(initValue); + initialEditValueRef.current = initValue; } }, - [popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed] + [popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed, updatePopoverWidth] ); // Handle immediate close of popover (used by close button and actions - intentional closes) const handleClosePopover = useCallback(() => { - // Only process if we have changes - if (editValue !== value || editValue !== localDisplayValue) { + // Only process if the user actually changed the value + if (editValue !== initialEditValueRef.current) { // Update local display immediately setLocalDisplayValue(editValue); - // Queue up the change - onChange(editValue); + // onBlur handles both cell update and validation (don't call onChange first + // as it would update the store before onBlur can capture previousValue) onBlur(editValue); } @@ -168,7 +209,7 @@ const MultilineInputComponent = ({ setTimeout(() => { preventReopenRef.current = false; }, 100); - }, [editValue, value, localDisplayValue, onChange, onBlur]); + }, [editValue, onBlur]); // Handle popover open/close (called by Radix for click-outside and escape key) const handlePopoverOpenChange = useCallback( @@ -183,10 +224,10 @@ const MultilineInputComponent = ({ return; } - // This is a click-outside close - save changes and signal other cells - if (editValue !== value || editValue !== localDisplayValue) { + // This is a click-outside close - only save if user actually changed the value + if (editValue !== initialEditValueRef.current) { setLocalDisplayValue(editValue); - onChange(editValue); + // onBlur handles both cell update and validation onBlur(editValue); } @@ -205,28 +246,33 @@ const MultilineInputComponent = ({ if (wasPopoverRecentlyClosed()) { return; } - setEditValue(localDisplayValue || String(value ?? '')); + updatePopoverWidth(); + // Initialize edit value and track it for change detection + const initValue = localDisplayValue || String(value ?? ''); + setEditValue(initValue); + initialEditValueRef.current = initValue; setPopoverOpen(true); } }, - [value, popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onChange, onBlur, setCellPopoverClosed] + [popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onBlur, setCellPopoverClosed, updatePopoverWidth, value] ); // Handle direct input change const handleChange = useCallback((e: React.ChangeEvent) => { setEditValue(e.target.value); - }, []); + autoResizeTextarea(e.target); + }, [autoResizeTextarea]); // Handle accepting the AI suggestion (possibly edited) const handleAcceptSuggestion = useCallback(() => { // Use the edited suggestion setEditValue(editedSuggestion); setLocalDisplayValue(editedSuggestion); - onChange(editedSuggestion); + // onBlur handles both cell update and validation onBlur(editedSuggestion); onDismissAiSuggestion?.(); // Clear the suggestion after accepting setAiSuggestionExpanded(false); - }, [editedSuggestion, onChange, onBlur, onDismissAiSuggestion]); + }, [editedSuggestion, onBlur, onDismissAiSuggestion]); // Handle dismissing the AI suggestion const handleDismissSuggestion = useCallback(() => { @@ -243,7 +289,7 @@ const MultilineInputComponent = ({ return (
- + @@ -270,9 +316,13 @@ const MultilineInputComponent = ({ if (wasPopoverRecentlyClosed()) { return; } + updatePopoverWidth(); setAiSuggestionExpanded(true); setPopoverOpen(true); - setEditValue(localDisplayValue || String(value ?? '')); + // Initialize edit value and track it for change detection + const initValue = localDisplayValue || String(value ?? ''); + setEditValue(initValue); + initialEditValueRef.current = initValue; }} className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors" title="View AI suggestion" @@ -303,7 +353,7 @@ const MultilineInputComponent = ({ @@ -379,10 +430,14 @@ const MultilineInputComponent = ({ Suggested (editable):