diff --git a/CLAUDE.md b/CLAUDE.md index cdeb895..8fbfa5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,3 @@ * Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead. * If you use the task tool to have an agent investigate something, make sure to let it know to avoid using glob -* Prefer solving tasks in a single session. Only spawn subagents for genuinely independent workstreams. * The postgres/query tool is not working and not connected to the current version of the database. If you need to query the database for any reason you can use "ssh netcup" and use psql on the server with inventory_readonly 6D3GUkxuFgi2UghwgnUd \ No newline at end of file diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index cc7ff37..fbbfa71 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -1439,6 +1439,41 @@ router.get('/search-products', async (req, res) => { } }); +// Current warehouse locations for a batch of pids — a single-table read kept +// deliberately tiny. Added for the email app's printable count sheet: shelf +// locations move after putaway, so the sheet pulls them LIVE at print time +// instead of trusting its receipt mirror's snapshot. GET = authenticated-only, +// same as the other read endpoints here. +router.get('/product-locations', async (req, res) => { + const pids = String(req.query.pids || '') + .split(',') + .map(Number) + .filter((n) => Number.isInteger(n) && n > 0); + if (pids.length === 0) { + return res.status(400).json({ error: 'pids (comma-separated positive integers) is required' }); + } + if (pids.length > 1000) { + return res.status(400).json({ error: 'Too many pids (max 1000)' }); + } + + try { + const { connection } = await getDbConnection(); + const [rows] = await connection.query( + `SELECT pid, location FROM products WHERE pid IN (${pids.map((p) => connection.escape(p)).join(',')})` + ); + // Empty-string locations (unshelved products) are omitted rather than sent + // as '' so callers can treat presence as "has a shelf location". + const locations = {}; + for (const row of rows) { + if (row.location) locations[row.pid] = row.location; + } + res.json({ locations }); + } catch (error) { + console.error('Error fetching product locations:', error); + res.status(500).json({ error: 'Failed to fetch product locations' }); + } +}); + // Shared SELECT for product queries (matches search-products fields) const PRODUCT_SELECT = ` SELECT diff --git a/inventory/package-lock.json b/inventory/package-lock.json index 6d38adb..0ffe8ce 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -4278,9 +4278,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { diff --git a/inventory/src/components/dashboard/MiniSalesChart.jsx b/inventory/src/components/dashboard/MiniSalesChart.jsx index 7f55db2..988094a 100644 --- a/inventory/src/components/dashboard/MiniSalesChart.jsx +++ b/inventory/src/components/dashboard/MiniSalesChart.jsx @@ -16,6 +16,7 @@ import { formatCurrency } from "./SalesChart.jsx"; import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases"; import config from "@/config"; import { apiFetch } from "@/utils/api"; +import { formatBusinessDay } from "@/utils/businessTime"; import { DashboardStatCardMini, DashboardStatCardMiniSkeleton, @@ -93,11 +94,8 @@ const MiniSalesChart = ({ className = "" }) => { refetchInterval: 300000, }); - const formatXAxis = (value) => { - if (!value) return ""; - const date = new Date(value); - return date.toLocaleDateString([], { month: "numeric", day: "numeric" }); - }; + const formatXAxis = (value) => + formatBusinessDay(value, { month: "numeric", day: "numeric" }); if (error) { return ( @@ -212,7 +210,11 @@ const MiniSalesChart = ({ className = "" }) => { content={({ active, payload }) => { if (!active || !payload?.length) return null; const dateStr = payload[0]?.payload?.date; - const date = dateStr ? new Date(dateStr) : null; + const dateLabel = formatBusinessDay(dateStr, { + weekday: "short", + month: "short", + day: "numeric", + }); const styles = TOOLTIP_THEMES.stone; const items = payload .filter((entry) => entry.value > 0) @@ -220,15 +222,7 @@ const MiniSalesChart = ({ className = "" }) => { const total = items.reduce((sum, entry) => sum + (entry.value || 0), 0); return (
- {date && ( -

- {date.toLocaleDateString([], { - weekday: "short", - month: "short", - day: "numeric", - })} -

- )} + {dateLabel &&

{dateLabel}

}
{items.map((entry, index) => { const cfg = PHASE_CONFIG[entry.dataKey] || {}; diff --git a/inventory/src/components/dashboard/SalesChart.jsx b/inventory/src/components/dashboard/SalesChart.jsx index e349721..89d0cec 100644 --- a/inventory/src/components/dashboard/SalesChart.jsx +++ b/inventory/src/components/dashboard/SalesChart.jsx @@ -43,6 +43,7 @@ import { DashboardErrorState, MetricPill, } from "@/components/dashboard/shared"; +import { formatBusinessDay } from "@/utils/businessTime"; // Chart color mapping — Sorbet Studio series hues (prev-* stay dashed in the // chart; the lighter prev tones are only legal alongside that dash) @@ -77,14 +78,12 @@ const salesValueFormatter = (value, name) => { }; // Sales chart label formatter - formats timestamp as readable date -const salesLabelFormatter = (label) => { - const date = new Date(label); - return date.toLocaleDateString("en-US", { +const salesLabelFormatter = (label) => + formatBusinessDay(label, { weekday: "short", month: "short", day: "numeric", }); -}; const calculate7DayAverage = (data) => { if (!Array.isArray(data) || data.length === 0) return []; @@ -391,11 +390,8 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales" }) => { }; }, [selectedTimeRange, fetchData]); - const formatXAxis = (value) => { - if (!value) return ""; - const date = new Date(value); - return date.toLocaleDateString([], { month: "short", day: "numeric" }); - }; + const formatXAxis = (value) => + formatBusinessDay(value, { month: "short", day: "numeric" }); const averageRevenue = data.length > 0 diff --git a/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx b/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx index d369d47..6c3ddcb 100644 --- a/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx +++ b/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx @@ -21,7 +21,8 @@ import { acotService } from "@/services/dashboard/acotService"; import { apiClient } from "@/utils/apiClient"; import { apiFetch } from "@/utils/api"; import config from "@/config"; -import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases"; +import { PHASE_CONFIG } from "@/utils/lifecyclePhases"; +import { formatBusinessDay } from "@/utils/businessTime"; // @ts-expect-error - JSX module without type declarations import { processBasicData } from "@/components/dashboard/RealtimeAnalytics"; // @ts-expect-error - JSX module without type declarations @@ -176,6 +177,7 @@ interface Projection { } interface DailyRow { + date?: string; revenue?: number; orders?: number; prevRevenue?: number; @@ -183,6 +185,15 @@ interface DailyRow { periodProgress?: number; } +/** Period totals summed across DailyRow[] — every field is always populated. */ +interface DailyTotals { + revenue: number; + orders: number; + prevRevenue: number; + prevOrders: number; + periodProgress: number; +} + interface FinancialsResponse { totals?: Record; previousTotals?: Record; @@ -249,7 +260,7 @@ const NightboardSmall = () => { daily: true, })) as { stats?: DailyRow[] }; const rows = Array.isArray(response.stats) ? response.stats : []; - const t = rows.reduce>( + const t = rows.reduce( (acc, day) => ({ revenue: acc.revenue + (Number(day.revenue) || 0), orders: acc.orders + (Number(day.orders) || 0), @@ -264,6 +275,9 @@ const NightboardSmall = () => { ...t, avgPerDay: t.revenue / days, prevAvgPerDay: t.prevRevenue / days, + // keep the per-day rows — the chart below plots them, so the curve and + // the totals in the header are the same numbers from the same fetch + daily: rows, }; }, refetchInterval: 300_000, @@ -276,9 +290,13 @@ const NightboardSmall = () => { refetchInterval: 300_000, }); - // 30-day chart + phase mix (same endpoint as MiniSalesChart) + // Lifecycle-phase mix ONLY. The daily curve comes from the live acot feed + // (see sum30) — this endpoint reads the Postgres mirror, which is a copy the + // import refreshes on a schedule, so it must never drive a "live" number. + // Phase mix has no live equivalent: lifecycle_phase lives in product_metrics, + // which is computed inventory-side and has no counterpart in the ACOT DB. const { data: chartData } = useQuery({ - queryKey: ["nightboard-sales-chart-30d"], + queryKey: ["nightboard-phase-mix-30d"], queryFn: async () => { const now = new Date(); const thirtyDaysAgo = new Date(now); @@ -439,12 +457,10 @@ const NightboardSmall = () => { : sum30?.orders; const orders30Trend = sum30 ? trendPct(orders30Current ?? 0, sum30.prevOrders) : null; - const dailyTotals: { date: string; total: number }[] = (chartData?.dailySalesByPhase || []).map( - (day: { date: string } & Record) => ({ - date: day.date, - total: PHASE_KEYS.reduce((sum, key) => sum + (Number(day[key]) || 0), 0), - }) - ); + const dailyTotals: { date: string; total: number }[] = (sum30?.daily || []).map((day) => ({ + date: day.date ?? "", + total: Number(day.revenue) || 0, + })); const activePhases: PhaseSlice[] = (chartData?.phaseBreakdown || []) .filter((p: PhaseSlice) => p.revenue > 0) .sort((a: PhaseSlice, b: PhaseSlice) => b.revenue - a.revenue); @@ -655,7 +671,7 @@ const NightboardSmall = () => { - new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" }) + formatBusinessDay(v, { month: "numeric", day: "numeric" }) } tick={{ fill: NB.mut, fontSize: 16 }} tickLine={false} @@ -680,7 +696,7 @@ const NightboardSmall = () => { style={{ background: NB.card, border: `1px solid ${NB.line}`, color: NB.txt }} >
- {new Date(row.date).toLocaleDateString([], { + {formatBusinessDay(row.date, { weekday: "short", month: "short", day: "numeric", diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useDragAndDrop.ts b/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useDragAndDrop.ts index 8f3d076..2007ceb 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useDragAndDrop.ts +++ b/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useDragAndDrop.ts @@ -279,50 +279,65 @@ export const useDragAndDrop = ({ // Effect to register browser-level drag events on product containers useEffect(() => { + // Collect the cleanups: returning from inside forEach() does nothing, so the + // listeners used to accumulate on every re-run instead of being removed. + const cleanups: Array<() => void> = []; + // For each product container data.forEach((_, index) => { const container = document.getElementById(`product-${index}`); - + if (container) { // Define handlers for native browser drag events const handleNativeDragOver = (e: DragEvent) => { e.preventDefault(); setActiveDroppableId(`product-${index}`); }; - + + // Functional update so this effect doesn't need activeDroppableId as a + // dependency - otherwise it re-binds every listener on each hover change + // in the middle of a drag. const handleNativeDragLeave = () => { - if (activeDroppableId === `product-${index}`) { - setActiveDroppableId(null); - } + setActiveDroppableId(current => (current === `product-${index}` ? null : current)); }; - + // Add these handlers container.addEventListener('dragover', handleNativeDragOver); container.addEventListener('dragleave', handleNativeDragLeave); - - // Return cleanup function - return () => { + + cleanups.push(() => { container.removeEventListener('dragover', handleNativeDragOver); container.removeEventListener('dragleave', handleNativeDragLeave); - }; + }); } }); - }, [data, productImages, activeDroppableId]); // Re-run when data or productImages change + + return () => cleanups.forEach(cleanup => cleanup()); + }, [data, productImages]); // Re-run when data or productImages change // Function to add more visual indication when dragging + // + // IMPORTANT: the drag affordance must never change an element's box size. + // dnd-kit measures every droppable exactly once, in an effect that runs right + // after the drag starts, and it only re-measures on *resize* (ResizeObserver), + // never on *position* changes. A border added here on drag start grows every + // card by 2px, pushing everything below it down after the measurement was + // taken - which silently offsets every drop target by 2px x (cards above) and + // makes reordering drop into the wrong row. Outlines are painted outside the + // box model, so they highlight without moving anything. const getProductContainerClasses = (index: number) => { const isValidDropTarget = activeId && findContainer(activeId) !== index.toString(); const isActiveDropTarget = activeDroppableId === `product-${index}`; - + return [ - "flex-1 min-h-[6rem] rounded-md p-2 transition-all", - // Only show borders during active drag operations - isValidDropTarget && isActiveDropTarget - ? "border-2 border-dashed border-primary bg-primary/10" + "flex-1 min-h-[6rem] rounded-md p-2 outline-dashed outline-2 -outline-offset-2 transition-colors", + // Only show the outline during active drag operations + isValidDropTarget && isActiveDropTarget + ? "outline-primary bg-primary/10" : isValidDropTarget - ? "border border-dashed border-muted-foreground/30" - : "" - ].filter(Boolean).join(" "); + ? "outline-muted-foreground/30" + : "outline-transparent" + ].join(" "); }; return { diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx index 8466b48..f30dbb9 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx @@ -8,7 +8,8 @@ import { memo, useEffect, useState, useRef } from 'react'; import { Button } from '@/components/ui/button'; -import { X, ArrowDownToLine } from 'lucide-react'; +import { X, ArrowDownToLine, Eraser } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { useValidationStore } from '../store/validationStore'; import { useIsCopyDownActive } from '../store/selectors'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -28,6 +29,8 @@ export const CopyDownBanner = memo(() => { // These are cheap to compare and only change while copy-down is active. const rowCount = useValidationStore((state) => state.rows.length); const sourceRowIndex = useValidationStore((state) => state.copyDownMode.sourceRowIndex); + // Empty source = this run clears the target cells instead of filling them + const isClearing = useValidationStore((state) => state.copyDownMode.isClearing); const rowsBelow = sourceRowIndex !== null ? Math.max(0, rowCount - 1 - sourceRowIndex) : 0; const [position, setPosition] = useState<{ top: number; left: number } | null>(null); const bannerRef = useRef(null); @@ -114,42 +117,65 @@ export const CopyDownBanner = memo(() => { }} >
-
-
- - Click row to copy to +
+
+ + {isClearing ? 'Click row to clear to' : 'Click row to copy to'} {rowsBelow > 0 && ( <> -
+
- {rowsBelow === 1 - ? "Copy to 1 row below" - : `Copy to all ${rowsBelow} rows below`} + {isClearing + ? (rowsBelow === 1 + ? 'Clear this field on 1 row below' + : `Clear this field on all ${rowsBelow} rows below`) + : (rowsBelow === 1 + ? 'Copy to 1 row below' + : `Copy to all ${rowsBelow} rows below`)} )} - - - + 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 e2f7ca4..70559ed 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -17,7 +17,7 @@ import { apiFetch } from '@/utils/api'; 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, Pin, PinOff } from 'lucide-react'; +import { ArrowDown, Eraser, 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'; @@ -134,6 +134,8 @@ interface CellWrapperProps { isCopyDownSource: boolean; isInCopyDownRange: boolean; isCopyDownTarget: boolean; + /** Active copy-down has an empty source, i.e. it clears the targets */ + isCopyDownClearing: boolean; totalRowCount: number; // Inline AI validation (Groq-powered) inlineAiSuggestion?: InlineAiSuggestion; @@ -162,6 +164,7 @@ const CellWrapper = memo(({ isCopyDownSource, isInCopyDownRange, isCopyDownTarget, + isCopyDownClearing, totalRowCount, inlineAiSuggestion, isInlineAiValidating = false, @@ -200,19 +203,41 @@ const CellWrapper = memo(({ // Check if cell has a value (for showing copy-down button) const hasValue = value !== undefined && value !== null && value !== ''; - // Check if field has unique validation rule (copy-down should be disabled) + // Blank source = copy-down CLEARS the cells below. Matches isBlankCellValue() + // in the store, so the button's look always matches what the copy will do. + const isBlankValue = !hasValue || + (typeof value === 'string' && value.trim() === '') || + (Array.isArray(value) && value.length === 0); + + // Check if field has unique validation rule (values can't be duplicated down it) const hasUniqueValidation = field.validations?.some((v: Validation) => v.rule === 'unique') ?? false; // Check if cell has errors (for positioning copy-down button) const hasErrors = errors.length > 0; + // The cells themselves suppress the error icon on an empty cell whose only error + // is "required" (see showErrorIcon in InputCell/SelectCell/MultiSelectCell) - the + // red border carries the message instead. Mirror that rule, or the copy/clear + // button dodges an icon that was never drawn and drifts toward the middle. + const showsErrorIcon = hasErrors && + !(isBlankValue && errors.length === 1 && errors[0]?.type === ErrorType.Required); + // Show copy-down button when: // - Cell is hovered - // - Cell has a value // - Not already in copy-down mode // - There are rows below this one - // - Field does NOT have unique validation (can't copy unique values) - const showCopyDownButton = isHovered && hasValue && !isCopyDownActive && rowIndex < totalRowCount - 1 && !hasUniqueValidation; + // - Field does NOT have unique validation, OR we're clearing + // + // An EMPTY cell is a valid source: copying a blank down clears the cells below, + // which is the only bulk way to wipe a column of values that shouldn't be there. + // Clearing is safe even on unique fields (emptying rows can't create duplicates) + // and on required fields (the cleared cells simply flag as required again). + const showCopyDownButton = isHovered && !isCopyDownActive && rowIndex < totalRowCount - 1 && + (!hasUniqueValidation || isBlankValue); + + // Keep the copy/clear button in the cell's usual hover slot at the right edge, + // stepping left only when an error icon is actually occupying that slot. + const copyDownRightClass = showsErrorIcon ? 'right-7' : 'right-0.5'; // UPC Generation logic const isUpcField = field.key === 'upc'; @@ -223,6 +248,14 @@ const CellWrapper = memo(({ const hasValidSupplier = /^\d+$/.test(supplierIdString); const showGenerateUpcButton = isHovered && upcIsEmpty && !isValidating && !isCopyDownActive && !isGeneratingUpc; + // An empty UPC cell hosts both hover buttons; the copy/clear button owns the + // right edge, so the wider generate-UPC button parks to its left. + const generateUpcRightClass = !showCopyDownButton + ? 'right-1' + : showsErrorIcon + ? 'right-14' + : 'right-7'; + // Handle starting copy-down const handleStartCopyDown = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -868,11 +901,17 @@ const CellWrapper = memo(({ }, [needsCompany, needsLine, company, line]); // Determine cell highlighting classes + // Amber for a clearing run (empty source) so the preview range reads as + // "these get wiped", not "these get filled" const cellHighlightClass = cn( 'relative w-full group', - isCopyDownSource && 'ring-2 ring-blue-500 ring-inset rounded', - isInCopyDownRange && 'bg-blue-100', - isCopyDownTarget && !isInCopyDownRange && 'hover:bg-blue-50 cursor-pointer' + isCopyDownSource && (isCopyDownClearing + ? 'ring-2 ring-amber-500 ring-inset rounded' + : 'ring-2 ring-blue-500 ring-inset rounded'), + isInCopyDownRange && (isCopyDownClearing ? 'bg-amber-100' : 'bg-blue-100'), + isCopyDownTarget && !isInCopyDownRange && (isCopyDownClearing + ? 'hover:bg-amber-50 cursor-pointer' + : 'hover:bg-blue-50 cursor-pointer') ); // When in copy-down mode for this field, make cell non-interactive so clicks go to parent @@ -932,18 +971,22 @@ const CellWrapper = memo(({ onClick={handleStartCopyDown} className={cn( 'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full', - 'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600', - '', 'shadow-sm', - // Position further left if there are errors to avoid overlap - hasErrors ? 'right-7' : 'right-0.5' + // Clearing is destructive - give it its own colour so it can't be + // mistaken for a normal copy at a glance + isBlankValue + ? 'bg-amber-50 hover:bg-amber-100 text-amber-600 hover:text-amber-700' + : 'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600', + copyDownRightClass )} > - + {isBlankValue + ? + : } - Copy value to rows below + {isBlankValue ? 'Clear this field on rows below' : 'Copy value to rows below'} @@ -959,7 +1002,8 @@ const CellWrapper = memo(({ onClick={handleGenerateUpc} disabled={!hasValidSupplier} className={cn( - 'absolute right-1 top-1/2 -translate-y-1/2 z-10 flex items-center gap-1', + 'absolute top-1/2 -translate-y-1/2 z-10 flex items-center gap-1', + generateUpcRightClass, 'rounded-md border border-input bg-background px-2 py-1 text-xs shadow-sm', 'opacity-0 group-hover:opacity-100 transition-opacity', hasValidSupplier @@ -1568,6 +1612,7 @@ const VirtualRow = memo(({ isCopyDownSource={isCopyDownSource} isInCopyDownRange={isInCopyDownRange} isCopyDownTarget={isCopyDownTarget} + isCopyDownClearing={copyDownMode.isClearing} totalRowCount={totalRowCount} inlineAiSuggestion={inlineAiSuggestion} isInlineAiValidating={ diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts index 30d51a5..e585f54 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts @@ -2,6 +2,8 @@ * useCopyDownValidation Hook * * Watches for copy-down operations and triggers appropriate validations: + * - Every copied field -> re-run its field rules on the touched rows + * (so copying a BLANK down raises "required" errors on the cleared cells) * - UPC-related fields (supplier, upc, barcode) -> UPC validation * - Line field -> Inline AI validation for rows that gain sufficient context * @@ -13,6 +15,8 @@ import { useEffect } from 'react'; import { apiFetch } from '@/utils/api'; import { useValidationStore } from '../store/validationStore'; import { useUpcValidation } from './useUpcValidation'; +import { validateFieldValue } from './useValidationActions'; +import { ErrorSource, ErrorType } from '../store/types'; import type { Field } from '../../../types'; import { buildNameValidationPayload, @@ -80,10 +84,59 @@ export const useCopyDownValidation = () => { // Subscribe to pending validations const pendingUpcValidation = useValidationStore((state) => state.pendingCopyDownValidation); + const pendingRevalidation = useValidationStore((state) => state.pendingCopyDownRevalidation); const pendingInlineAiValidation = useValidationStore((state) => state.pendingInlineAiValidation); const clearPendingCopyDownValidation = useValidationStore((state) => state.clearPendingCopyDownValidation); + const clearPendingCopyDownRevalidation = useValidationStore((state) => state.clearPendingCopyDownRevalidation); const clearPendingInlineAiValidation = useValidationStore((state) => state.clearPendingInlineAiValidation); + // Re-run field rules on the copied rows. + // Copying a value down clears errors optimistically in the store; this pass is + // what handles the opposite direction — a copied BLANK must re-raise "required" + // errors and downgrade the row status, exactly as clearing the cell by hand does. + // PERFORMANCE: results are collected first and written in ONE store update, + // since "Apply to All" can touch every row in the sheet. + useEffect(() => { + if (!pendingRevalidation) return; + + const { fieldKey, affectedRows } = pendingRevalidation; + const { rows, fields, errors, applyFieldValidationResults } = useValidationStore.getState(); + + const field = fields.find((f) => f.key === fieldKey); + if (field) { + // Uniqueness is a cross-row rule: clearing a cell can resolve a duplicate that + // was flagged on a row we never touched. Re-check those rows too, but ONLY the + // ones showing an in-sheet duplicate error — never rows carrying a UPC-sourced + // error, which reflects the live catalog and can't be settled from the sheet. + const isUniqueField = field.validations?.some((v) => v.rule === 'unique') ?? false; + const rowsToCheck = isUniqueField + ? Array.from(new Set([ + ...affectedRows, + ...rows.reduce((acc, _row, rowIndex) => { + const stale = errors.get(rowIndex)?.[fieldKey]?.some( + (e) => e.type === ErrorType.Unique && e.source !== ErrorSource.Upc + ); + if (stale) acc.push(rowIndex); + return acc; + }, []), + ])).sort((a, b) => a - b) + : affectedRows; + + const results = rowsToCheck + .filter((rowIndex) => rows[rowIndex]) + .map((rowIndex) => ({ + rowIndex, + error: validateFieldValue(rows[rowIndex][fieldKey], field, rows, rowIndex), + })); + + if (results.length > 0) { + applyFieldValidationResults(fieldKey, results); + } + } + + clearPendingCopyDownRevalidation(); + }, [pendingRevalidation, clearPendingCopyDownRevalidation]); + // Handle UPC validation useEffect(() => { if (!pendingUpcValidation) return; diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useValidationActions.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useValidationActions.ts index 6655872..c0a2085 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useValidationActions.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useValidationActions.ts @@ -30,8 +30,11 @@ const isEmpty = (value: unknown): boolean => { /** * Validate a single field value against its validation rules + * + * Exported so bulk callers (e.g. copy-down re-validation) can reuse the exact + * same rule evaluation without pulling in the per-row store writes. */ -const validateFieldValue = ( +export const validateFieldValue = ( value: unknown, field: Field, allRows: RowData[], diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts index 3c80b9b..a7bc85a 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts @@ -151,6 +151,8 @@ export interface CopyDownState { sourceRowIndex: number | null; sourceFieldKey: string | null; targetRowIndex: number | null; // Hover preview - which row the user is hovering on + /** Source cell is empty, so completing the copy CLEARS the target cells */ + isClearing: boolean; } /** @@ -162,6 +164,16 @@ export interface PendingCopyDownValidation { affectedRows: number[]; } +/** + * Tracks rows that need their field re-validated after copy-down completes. + * Unlike the optimistic error-clearing done inline, this runs the real rule set, + * so copying a BLANK value down surfaces "required" errors on the cleared cells. + */ +export interface PendingCopyDownRevalidation { + fieldKey: string; + affectedRows: number[]; +} + /** * Tracks rows that need inline AI validation after line copy-down. * When line is copied to rows that already have company + name/description, @@ -390,6 +402,7 @@ export interface ValidationState { // === Copy-Down Mode === copyDownMode: CopyDownState; pendingCopyDownValidation: PendingCopyDownValidation | null; + pendingCopyDownRevalidation: PendingCopyDownRevalidation | null; pendingInlineAiValidation: PendingInlineAiValidation | null; // === Dialogs === @@ -450,6 +463,10 @@ export interface ValidationActions { allErrors: Map>, allStatuses: Map ) => void; + applyFieldValidationResults: ( + fieldKey: string, + results: Array<{ rowIndex: number; error: ValidationError | null }> + ) => void; clearRowErrors: (rowIndex: number) => void; clearFieldError: (rowIndex: number, field: string) => void; setRowValidationStatus: (rowIndex: number, status: RowValidationStatus) => void; @@ -496,6 +513,7 @@ export interface ValidationActions { completeCopyDown: (targetRowIndex: number) => void; setTargetRowHover: (rowIndex: number | null) => void; clearPendingCopyDownValidation: () => void; + clearPendingCopyDownRevalidation: () => void; clearPendingInlineAiValidation: () => void; // === Dialogs === diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts index 92ac2ed..9e0bb21 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts @@ -57,8 +57,16 @@ const initialCopyDownState: CopyDownState = { sourceRowIndex: null, sourceFieldKey: null, targetRowIndex: null, + isClearing: false, }; +/** A cell counts as empty (and so clears its targets) when it has no usable value */ +const isBlankCellValue = (value: unknown): boolean => + value === undefined || + value === null || + (typeof value === 'string' && value.trim() === '') || + (Array.isArray(value) && value.length === 0); + // Fields that require UPC validation when changed via copy-down const UPC_VALIDATION_FIELDS = ['supplier', 'upc', 'barcode']; @@ -111,6 +119,7 @@ const getInitialState = (): ValidationState => ({ // Copy-Down Mode copyDownMode: { ...initialCopyDownState }, pendingCopyDownValidation: null, + pendingCopyDownRevalidation: null, pendingInlineAiValidation: null, // Dialogs @@ -450,6 +459,46 @@ export const useValidationStore = create()( }); }, + /** + * PERFORMANCE: Apply one field's validation outcome across many rows in a + * SINGLE store update. Used by copy-down, where "Apply to All" can touch + * hundreds of rows and per-row setError() calls would re-clone the errors + * Map every time. + */ + applyFieldValidationResults: ( + fieldKey: string, + results: Array<{ rowIndex: number; error: ValidationError | null }> + ) => { + set((state) => { + for (const { rowIndex, error } of results) { + const rowErrors = state.errors.get(rowIndex); + + if (error) { + state.errors.set(rowIndex, { ...(rowErrors ?? {}), [fieldKey]: [error] }); + state.rowValidationStatus.set(rowIndex, 'error'); + continue; + } + + if (rowErrors && rowErrors[fieldKey]) { + const remaining = { ...rowErrors }; + delete remaining[fieldKey]; + if (Object.keys(remaining).length === 0) { + state.errors.delete(rowIndex); + } else { + state.errors.set(rowIndex, remaining); + } + } + + // Only promote rows that were already carrying a status — never mark a + // never-validated row "validated" off the back of a single field. + const stillHasErrors = Object.keys(state.errors.get(rowIndex) ?? {}).length > 0; + if (!stillHasErrors && state.rowValidationStatus.has(rowIndex)) { + state.rowValidationStatus.set(rowIndex, 'validated'); + } + } + }); + }, + clearRowErrors: (rowIndex: number) => { set((state) => { state.errors.delete(rowIndex); @@ -677,6 +726,9 @@ export const useValidationStore = create()( sourceRowIndex: rowIndex, sourceFieldKey: fieldKey, targetRowIndex: null, + // Resolved once at start so the banner and range highlight can warn + // that this run wipes the target cells rather than filling them + isClearing: isBlankCellValue(state.rows[rowIndex]?.[fieldKey]), }; }); }, @@ -698,8 +750,10 @@ export const useValidationStore = create()( // First, perform the copy operation set((state) => { - const sourceValue = state.rows[sourceRowIndex]?.[fieldKey]; - if (sourceValue === undefined) return; + const sourceRow = state.rows[sourceRowIndex]; + if (!sourceRow) return; + + const sourceValue = sourceRow[fieldKey]; // Clone value for arrays/objects to prevent reference sharing const cloneValue = (val: unknown): unknown => { @@ -709,15 +763,23 @@ export const useValidationStore = create()( }; // Check if value is non-empty (for clearing required errors) - const hasValue = sourceValue !== null && sourceValue !== '' && - !(Array.isArray(sourceValue) && sourceValue.length === 0); + const hasValue = !isBlankCellValue(sourceValue); + + // Copying a BLANK source clears the target cells. "Blank" arrives in several + // shapes here (undefined for an unmapped column, null, '', []), and writing + // undefined/null back would leave the target cell uncontrolled — so write the + // canonical empty value for whatever shape the target already holds. + const valueForTarget = (targetValue: unknown): unknown => { + if (hasValue) return cloneValue(sourceValue); + return Array.isArray(targetValue) ? [] : ''; + }; // Track affected rows for UPC validation const affectedRows: number[] = []; for (let i = sourceRowIndex + 1; i <= targetRowIndex; i++) { if (state.rows[i]) { - state.rows[i][fieldKey] = cloneValue(sourceValue); + state.rows[i][fieldKey] = valueForTarget(state.rows[i][fieldKey]); affectedRows.push(i); // Clear validation errors for this field if value is non-empty @@ -739,6 +801,29 @@ export const useValidationStore = create()( // Reset copy-down mode state.copyDownMode = { ...initialCopyDownState }; + // Re-run the field's validation rules on every touched row. This is what + // makes a blank copy-down behave like manually clearing each cell: + // "required" errors appear instead of the cells looking silently valid. + if (affectedRows.length > 0) { + state.pendingCopyDownRevalidation = { fieldKey, affectedRows }; + } + + // A cleared name/description invalidates any AI suggestion built from the + // old text, so drop those suggestions rather than leave them stranded. + if (!hasValue && (fieldKey === 'name' || fieldKey === 'description')) { + const field = fieldKey as 'name' | 'description'; + for (const rowIdx of affectedRows) { + const productIndex = state.rows[rowIdx]?.__index; + if (!productIndex) continue; + const existing = state.inlineAi.suggestions.get(productIndex); + if (!existing?.[field]) continue; + state.inlineAi.suggestions.set(productIndex, { + ...existing, + [field]: undefined, + }); + } + } + // If this field affects UPC validation, store the affected rows // so a hook can trigger validation using the existing validateUpc function if (UPC_VALIDATION_FIELDS.includes(fieldKey) && affectedRows.length > 0) { @@ -801,6 +886,12 @@ export const useValidationStore = create()( }); }, + clearPendingCopyDownRevalidation: () => { + set((state) => { + state.pendingCopyDownRevalidation = null; + }); + }, + clearPendingInlineAiValidation: () => { set((state) => { state.pendingInlineAiValidation = null; diff --git a/inventory/src/utils/businessTime.ts b/inventory/src/utils/businessTime.ts index 22eb269..b5791a6 100644 --- a/inventory/src/utils/businessTime.ts +++ b/inventory/src/utils/businessTime.ts @@ -31,3 +31,45 @@ export function toDateOnly(date: Date): string { const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } + +/** + * Parse a server-supplied business date for DISPLAY, without shifting a day. + * + * The APIs hand back business days as bare 'YYYY-MM-DD' (pg via formatDateCol, + * mysql2 via dateStrings) — and `new Date('2026-07-28')` is spec'd to parse as + * UTC midnight, which renders as Jul 27 in any zone west of UTC. Appending a + * time part makes the same string parse as LOCAL midnight, so the calendar date + * round-trips through toLocaleDateString(). + * + * Midnight-Z timestamps ('2026-07-28T00:00:00.000Z') stand for a whole business + * day too, so they get the same treatment. Anything else is a real instant and + * is parsed as-is. + */ +export function parseBusinessDay(value: string | number | Date | null | undefined): Date | null { + if (value == null) return null; + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + if (typeof value === 'number') return new Date(value); + + const trimmed = value.trim(); + if (!trimmed) return null; + + // Bare business date, or a midnight-Z timestamp standing in for one. + const dayOnly = /^(\d{4}-\d{2}-\d{2})(?:T00:00:00(?:\.000)?Z)?$/.exec(trimmed); + if (dayOnly) { + const parsed = new Date(`${dayOnly[1]}T00:00:00`); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + const parsed = new Date(trimmed); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +/** Format a server-supplied business day for display; '' when unparseable. */ +export function formatBusinessDay( + value: string | number | Date | null | undefined, + options: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }, + locales: Intl.LocalesArgument = 'en-US' +): string { + const parsed = parseBusinessDay(value); + return parsed ? parsed.toLocaleDateString(locales, options) : ''; +}