From c0f4f1de0d73d109f79a4607264ed717ab8b2465 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 13 May 2026 11:28:35 -0400 Subject: [PATCH] Update for project move on server, add ability to update existing POs, add spec lookup page, enhance copy down functionality. --- .claude/CLAUDE.md | 4 +- docs/setup-chat.md | 2 +- .../scripts/import-campaign-products.js | 2 +- .../scripts/calculate-metrics-new.js | 2 +- .../scripts/forecast/run_forecast.js | 4 +- inventory-server/src/routes/import.js | 10 +- .../src/routes/reusable-images.js | 2 +- inventory-server/src/routes/spec-lookup.js | 270 +++++++ inventory-server/src/server.js | 4 +- inventory/package.json | 2 +- inventory/src/App.tsx | 8 + .../components/auth/FirstAccessiblePage.tsx | 1 + inventory/src/components/auth/PERMISSIONS.md | 1 + .../components/create-po/ConfirmationView.tsx | 26 +- .../src/components/layout/AppSidebar.tsx | 7 + .../src/components/product-import/config.ts | 20 +- .../components/GenericDropzone.tsx | 13 + .../components/ProductCard/ImageDropzone.tsx | 13 + .../components/CopyDownBanner.tsx | 67 +- .../components/ValidationTable.tsx | 38 +- inventory/src/pages/CreatePurchaseOrder.tsx | 191 ++++- inventory/src/pages/SpecLookup.tsx | 734 ++++++++++++++++++ inventory/src/services/apiv2.ts | 90 +++ inventory/tsconfig.tsbuildinfo | 2 +- inventory/vite.config.ts | 2 +- mountremote netcup.command | 2 +- 26 files changed, 1414 insertions(+), 103 deletions(-) create mode 100644 inventory-server/src/routes/spec-lookup.js create mode 100644 inventory/src/pages/SpecLookup.tsx diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 054de61..01ec6c9 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -84,7 +84,7 @@ npm run setup # Create required directories (logs, uploads) - PostgreSQL with connection pooling (pg library) - Pool initialized in `utils/db.js` via `initPool()` - Pool attached to `app.locals.pool` for route access -- Environment variables loaded from `/var/www/html/inventory/.env` (production path) +- Environment variables loaded from `/var/www/inventory/.env` (production path) **API Routes:** All prefixed with `/api/` - `/api/products` - Product CRUD operations @@ -164,7 +164,7 @@ Run tests for individual components or features: ## Important Notes -- Environment variables must be configured in `/var/www/html/inventory/.env` for production +- Environment variables must be configured in `/var/www/inventory/.env` for production - The frontend expects the backend at `/api` (proxied in dev, served together in production) - PM2 is used for production process management - Database uses PostgreSQL with SSL support (configurable via `DB_SSL` env var) diff --git a/docs/setup-chat.md b/docs/setup-chat.md index 00804c0..fec6e3e 100644 --- a/docs/setup-chat.md +++ b/docs/setup-chat.md @@ -13,7 +13,7 @@ Not all of the information in this database is relevant as it's a direct export Server-side files should use similar conventions and the same technologies as the inventory-server (inventor-server root) and auth-server (inventory-server/auth). I will provide my current pm2 ecosystem file upon request for you to add the configuration for the new "chat-server". I use Caddy on the server and can provide my caddyfile to assist with configuring the api routes. All configuration and routes for the chat-server should go in the inventory-server/chat folder or subfolders you create. -The folder you see as inventory-server is actually a direct mount of the /var/www/html/inventory folder on the server. You can read and write files from there like usual, but any terminal commands for the server I will have to run myself. +The folder you see as inventory-server is actually a direct mount of the /var/www/inventory folder on the server. You can read and write files from there like usual, but any terminal commands for the server I will have to run myself. The "Chat" page should be added to the main application sidebar and a similar page to the others should be created in inventory/src/pages. All other frontend pages should go in inventory/src/components/chat. diff --git a/inventory-server/dashboard/klaviyo-server/scripts/import-campaign-products.js b/inventory-server/dashboard/klaviyo-server/scripts/import-campaign-products.js index 328ae04..b884473 100644 --- a/inventory-server/dashboard/klaviyo-server/scripts/import-campaign-products.js +++ b/inventory-server/dashboard/klaviyo-server/scripts/import-campaign-products.js @@ -25,7 +25,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Load klaviyo .env for API key dotenv.config({ path: path.resolve(__dirname, '../.env') }); // Also load the main inventory-server .env for DB credentials -const mainEnvPath = '/var/www/html/inventory/.env'; +const mainEnvPath = '/var/www/inventory/.env'; if (fs.existsSync(mainEnvPath)) { dotenv.config({ path: mainEnvPath }); } diff --git a/inventory-server/scripts/calculate-metrics-new.js b/inventory-server/scripts/calculate-metrics-new.js index 4920586..8e554a9 100644 --- a/inventory-server/scripts/calculate-metrics-new.js +++ b/inventory-server/scripts/calculate-metrics-new.js @@ -32,7 +32,7 @@ const envPaths = [ path.resolve(__dirname, '../..', '.env'), // Two levels up (inventory/.env) path.resolve(__dirname, '..', '.env'), // One level up (inventory-server/.env) path.resolve(__dirname, '.env'), // Same directory - '/var/www/html/inventory/.env' // Server absolute path + '/var/www/inventory/.env' // Server absolute path ]; let envLoaded = false; diff --git a/inventory-server/scripts/forecast/run_forecast.js b/inventory-server/scripts/forecast/run_forecast.js index 3ea3d32..625edc2 100644 --- a/inventory-server/scripts/forecast/run_forecast.js +++ b/inventory-server/scripts/forecast/run_forecast.js @@ -11,7 +11,7 @@ * * Environment: * Reads DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT from - * /var/www/html/inventory/.env (or current process env). + * /var/www/inventory/.env (or current process env). */ const { spawn } = require('child_process'); @@ -20,7 +20,7 @@ const fs = require('fs'); // Load .env file if it exists (production path) const envPaths = [ - '/var/www/html/inventory/.env', + '/var/www/inventory/.env', path.join(__dirname, '../../.env'), ]; diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index 45399f1..1a7fee2 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -11,8 +11,8 @@ const axios = require('axios'); const net = require('net'); // Create uploads directory if it doesn't exist -const uploadsDir = path.join('/var/www/html/inventory/uploads/products'); -const reusableUploadsDir = path.join('/var/www/html/inventory/uploads/reusable'); +const uploadsDir = path.join('/var/www/inventory/uploads/products'); +const reusableUploadsDir = path.join('/var/www/inventory/uploads/reusable'); fs.mkdirSync(uploadsDir, { recursive: true }); fs.mkdirSync(reusableUploadsDir, { recursive: true }); @@ -513,10 +513,12 @@ const storage = multer.diskStorage({ } }); -const upload = multer({ +const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; + +const upload = multer({ storage: storage, limits: { - fileSize: 15 * 1024 * 1024, // Allow bigger uploads; processing will reduce to 5MB + fileSize: MAX_UPLOAD_BYTES, }, fileFilter: function (req, file, cb) { // Accept only image files diff --git a/inventory-server/src/routes/reusable-images.js b/inventory-server/src/routes/reusable-images.js index 4f9151a..3889d0a 100644 --- a/inventory-server/src/routes/reusable-images.js +++ b/inventory-server/src/routes/reusable-images.js @@ -5,7 +5,7 @@ const path = require('path'); const fs = require('fs'); // Create reusable uploads directory if it doesn't exist -const uploadsDir = path.join('/var/www/html/inventory/uploads/reusable'); +const uploadsDir = path.join('/var/www/inventory/uploads/reusable'); fs.mkdirSync(uploadsDir, { recursive: true }); // Configure multer for file uploads diff --git a/inventory-server/src/routes/spec-lookup.js b/inventory-server/src/routes/spec-lookup.js new file mode 100644 index 0000000..b6caf95 --- /dev/null +++ b/inventory-server/src/routes/spec-lookup.js @@ -0,0 +1,270 @@ +const express = require('express'); +const router = express.Router(); + +const MAX_MATCHES = 500; +const DESCRIPTION_SAMPLE_LIMIT = 8; + +// GET /api/spec-lookup?company=...&term=... +// Returns aggregated specs across products matching company (brand) and term (title). +router.get('/', async (req, res) => { + const company = typeof req.query.company === 'string' ? req.query.company.trim() : ''; + const term = typeof req.query.term === 'string' ? req.query.term.trim() : ''; + + if (!company && !term) { + return res.status(400).json({ error: 'company or term is required' }); + } + + try { + const pool = req.app.locals.pool; + const conditions = []; + const params = []; + + if (company) { + params.push(`%${company}%`); + conditions.push(`brand ILIKE $${params.length}`); + } + if (term) { + params.push(`%${term}%`); + conditions.push(`title ILIKE $${params.length}`); + } + + params.push(MAX_MATCHES); + const limitParam = `$${params.length}`; + + const sql = ` + SELECT + pid::TEXT AS pid, + title, sku, brand, vendor, artist, + country_of_origin, harmonized_tariff_code, + description, categories, + cost_price, regular_price, + moq, weight, length, width, height, + created_at + FROM products + WHERE ${conditions.join(' AND ')} + ORDER BY created_at DESC NULLS LAST + LIMIT ${limitParam} + `; + + const { rows } = await pool.query(sql, params); + + // Resolve category cat_ids → names. products.categories is a comma-separated cat_id string. + const catIds = new Set(); + for (const r of rows) { + if (!r.categories) continue; + for (const tok of String(r.categories).split(',')) { + const trimmed = tok.trim(); + if (trimmed && /^\d+$/.test(trimmed)) catIds.add(trimmed); + } + } + // Map cat_id → {name, type}. Types 10-13 are Section/Category/Subcategory/Sub-Subcategory; 20-21 are Theme/Subtheme. + const catIdToInfo = new Map(); + if (catIds.size > 0) { + const { rows: catRows } = await pool.query( + `SELECT cat_id::TEXT AS cat_id, name, type FROM categories WHERE cat_id = ANY($1::bigint[])`, + [Array.from(catIds)], + ); + for (const c of catRows) catIdToInfo.set(c.cat_id, { name: c.name, type: Number(c.type) }); + } + + const products = rows.map(r => ({ + pid: Number(r.pid), + title: r.title, + sku: r.sku, + brand: r.brand, + vendor: r.vendor, + artist: r.artist, + country_of_origin: r.country_of_origin, + harmonized_tariff_code: r.harmonized_tariff_code, + description: r.description, + categories: r.categories, + cost_price: toNumberOrNull(r.cost_price), + regular_price: toNumberOrNull(r.regular_price), + moq: toNumberOrNull(r.moq), + weight: toNumberOrNull(r.weight), + length: toNumberOrNull(r.length), + width: toNumberOrNull(r.width), + height: toNumberOrNull(r.height), + created_at: r.created_at, + })); + + res.json({ + company, + term, + total: products.length, + truncated: products.length === MAX_MATCHES, + products, + aggregates: { + numeric: { + cost_price: numericAggregate(products, 'cost_price'), + regular_price: numericAggregate(products, 'regular_price'), + moq: numericAggregate(products, 'moq'), + weight: numericAggregate(products, 'weight'), + length: numericAggregate(products, 'length'), + width: numericAggregate(products, 'width'), + height: numericAggregate(products, 'height'), + }, + categorical: { + artist: categoricalAggregate(products, 'artist'), + country_of_origin: categoricalAggregate(products, 'country_of_origin'), + harmonized_tariff_code: categoricalAggregate(products, 'harmonized_tariff_code'), + }, + categories: groupedAggregate(products, catIdToInfo, new Set([10, 11, 12, 13])), + themes: groupedAggregate(products, catIdToInfo, new Set([20, 21])), + description: descriptionAggregate(products), + }, + }); + } catch (error) { + console.error('Error in spec-lookup:', error); + res.status(500).json({ error: 'Failed to compute spec lookup' }); + } +}); + +function toNumberOrNull(v) { + if (v === null || v === undefined) return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +// Aggregate a numeric field. Treats null/0 as unset since 0 is the codebase's "no value" sentinel. +// `products` is assumed to be ordered most-recent-first (created_at DESC) so the head of the +// list is also the recency window we use for trend detection. +function numericAggregate(products, field) { + const values = []; + // Iterate products in order so we know which values came from the most-recent rows. + for (const p of products) { + const v = p[field]; + if (typeof v === 'number' && Number.isFinite(v) && v > 0) values.push(v); + } + + if (!values.length) { + return { count: 0, sample_size: products.length, distribution: [] }; + } + + const sorted = [...values].sort((a, b) => a - b); + const sum = values.reduce((s, v) => s + v, 0); + const avg = sum / values.length; + const mid = Math.floor(sorted.length / 2); + const median = sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; + const variance = values.reduce((s, v) => s + (v - avg) ** 2, 0) / values.length; + const stddev = Math.sqrt(variance); + + const counts = new Map(); + for (const v of values) { + const key = roundForKey(v); + counts.set(key, (counts.get(key) || 0) + 1); + } + const distribution = Array.from(counts.entries()) + .map(([value, count]) => ({ value, count })) + .sort((a, b) => b.count - a.count || a.value - b.value); + + const mode = distribution[0]?.value ?? null; + const mode_count = distribution[0]?.count ?? 0; + + // Trend detection: scan only the most-recent N values. N adapts to sample size so this + // can never look at more than ~20% of the data when the sample is small. + const recentN = Math.min(20, Math.max(5, Math.floor(values.length / 4))); + const recentValues = values.slice(0, recentN); + let recent_mode = null; + let recent_mode_count = 0; + let trending = false; + if (recentValues.length >= 3) { + const recentCounts = new Map(); + for (const v of recentValues) { + const key = roundForKey(v); + recentCounts.set(key, (recentCounts.get(key) || 0) + 1); + } + const recentSorted = Array.from(recentCounts.entries()).sort((a, b) => b[1] - a[1] || a[0] - b[0]); + recent_mode = recentSorted[0][0]; + recent_mode_count = recentSorted[0][1]; + // Trend = recent mode differs from overall AND dominates the window AND has min absolute support. + const majority = recent_mode_count >= Math.ceil(recentValues.length * 0.6); + const minSupport = recent_mode_count >= 3; + trending = recent_mode !== mode && majority && minSupport; + } + + return { + count: values.length, + sample_size: products.length, + avg, + median, + min: sorted[0], + max: sorted[sorted.length - 1], + stddev, + mode, + mode_count, + recent_mode, + recent_mode_count, + recent_window: recentValues.length, + trending, + distribution, + }; +} + +// Round to 4 decimals so JS-FP noise doesn't fragment the histogram. +function roundForKey(v) { + return Math.round(v * 10000) / 10000; +} + +function categoricalAggregate(products, field) { + const counts = new Map(); + for (const p of products) { + const v = p[field]; + if (v === null || v === undefined) continue; + const key = String(v).trim(); + if (!key) continue; + counts.set(key, (counts.get(key) || 0) + 1); + } + return Array.from(counts.entries()) + .map(([value, count]) => ({ value, count })) + .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)); +} + +// Aggregate cat_id token counts, including only entries whose category type is in `acceptedTypes`. +function groupedAggregate(products, catIdToInfo, acceptedTypes) { + const counts = new Map(); + for (const p of products) { + if (!p.categories) continue; + const tokens = String(p.categories).split(',').map(t => t.trim()).filter(Boolean); + for (const t of tokens) { + const info = catIdToInfo.get(t); + if (!info || !acceptedTypes.has(info.type)) continue; + counts.set(info.name, (counts.get(info.name) || 0) + 1); + } + } + return Array.from(counts.entries()) + .map(([value, count]) => ({ value, count })) + .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)); +} + +function descriptionAggregate(products) { + const counts = new Map(); + for (const p of products) { + if (!p.description) continue; + const key = String(p.description).trim(); + if (!key) continue; + counts.set(key, (counts.get(key) || 0) + 1); + } + + const duplicates = Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([value, count]) => ({ value, count })) + .sort((a, b) => b.count - a.count); + + // Recent unique samples (products are already ordered by created_at DESC). + const seen = new Set(); + const samples = []; + for (const p of products) { + const desc = (p.description || '').trim(); + if (!desc || seen.has(desc)) continue; + seen.add(desc); + samples.push({ value: desc, title: p.title, pid: p.pid, sku: p.sku }); + if (samples.length >= DESCRIPTION_SAMPLE_LIMIT) break; + } + + return { duplicates, samples }; +} + +module.exports = router; diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index 10cc3df..d023704 100644 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -23,6 +23,7 @@ const categoriesAggregateRouter = require('./routes/categoriesAggregate'); const vendorsAggregateRouter = require('./routes/vendorsAggregate'); const brandsAggregateRouter = require('./routes/brandsAggregate'); const htsLookupRouter = require('./routes/hts-lookup'); +const specLookupRouter = require('./routes/spec-lookup'); const importSessionsRouter = require('./routes/import-sessions'); const importAuditLogRouter = require('./routes/import-audit-log'); const productEditorAuditLogRouter = require('./routes/product-editor-audit-log'); @@ -31,7 +32,7 @@ const linesAggregateRouter = require('./routes/linesAggregate'); const repeatOrdersRouter = require('./routes/repeat-orders'); // Get the absolute path to the .env file -const envPath = '/var/www/html/inventory/.env'; +const envPath = '/var/www/inventory/.env'; console.log('Looking for .env file at:', envPath); console.log('.env file exists:', fs.existsSync(envPath)); @@ -136,6 +137,7 @@ async function startServer() { app.use('/api/ai-prompts', aiPromptsRouter); app.use('/api/reusable-images', reusableImagesRouter); app.use('/api/hts-lookup', htsLookupRouter); + app.use('/api/spec-lookup', specLookupRouter); app.use('/api/import-sessions', importSessionsRouter); app.use('/api/import-audit-log', importAuditLogRouter); app.use('/api/product-editor-audit-log', productEditorAuditLogRouter); diff --git a/inventory/package.json b/inventory/package.json index ae30840..74de27b 100644 --- a/inventory/package.json +++ b/inventory/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "build:deploy": "tsc -b && COPY_BUILD=true DEPLOY_TARGET=netcup DEPLOY_PATH=/var/www/html/inventory/frontend vite build", + "build:deploy": "tsc -b && COPY_BUILD=true DEPLOY_TARGET=netcup DEPLOY_PATH=/var/www/inventory/frontend vite build", "lint": "eslint .", "preview": "vite preview", "mount": "../mountremote.command" diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index 1fb0f1a..e231c91 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -23,6 +23,7 @@ const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ defau const Forecasting = lazy(() => import('./pages/Forecasting')); const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator')); const HtsLookup = lazy(() => import('./pages/HtsLookup')); +const SpecLookup = lazy(() => import('./pages/SpecLookup')); const Categories = lazy(() => import('./pages/Categories')); const Brands = lazy(() => import('./pages/Brands')); const ProductLines = lazy(() => import('./pages/ProductLines')); @@ -179,6 +180,13 @@ function App() { } /> + + }> + + + + } /> }> diff --git a/inventory/src/components/auth/FirstAccessiblePage.tsx b/inventory/src/components/auth/FirstAccessiblePage.tsx index 6c58186..96ff05e 100644 --- a/inventory/src/components/auth/FirstAccessiblePage.tsx +++ b/inventory/src/components/auth/FirstAccessiblePage.tsx @@ -15,6 +15,7 @@ const PAGES = [ { path: "/analytics", permission: "access:analytics" }, { path: "/discount-simulator", permission: "access:discount_simulator" }, { path: "/hts-lookup", permission: "access:hts_lookup" }, + { path: "/spec-lookup", permission: "access:spec_lookup" }, { path: "/forecasting", permission: "access:forecasting" }, { path: "/import", permission: "access:import" }, { path: "/settings", permission: "access:settings" }, diff --git a/inventory/src/components/auth/PERMISSIONS.md b/inventory/src/components/auth/PERMISSIONS.md index b3c9b10..a66af53 100644 --- a/inventory/src/components/auth/PERMISSIONS.md +++ b/inventory/src/components/auth/PERMISSIONS.md @@ -134,6 +134,7 @@ Admin users automatically have all permissions. | `access:analytics` | Access to Analytics page | | `access:discount_simulator` | Access to Discount Simulator page | | `access:hts_lookup` | Access to HTS Lookup page | +| `access:spec_lookup` | Access to Spec Lookup page | | `access:forecasting` | Access to Forecasting page | | `access:import` | Access to Import page | | `access:settings` | Access to Settings page | diff --git a/inventory/src/components/create-po/ConfirmationView.tsx b/inventory/src/components/create-po/ConfirmationView.tsx index 06fc053..65a661c 100644 --- a/inventory/src/components/create-po/ConfirmationView.tsx +++ b/inventory/src/components/create-po/ConfirmationView.tsx @@ -1,27 +1,40 @@ /** * Post-submit success screen. * - * Shows when the legacy backend has accepted the PO and returned a po_id. + * Shows when the legacy backend has accepted the submission. Supports both + * flows: creating a brand-new PO and adding line items to an existing PO. * The single primary action is the external link to the legacy admin's PO - * editor; secondary action is "Create another" which resets the page. + * editor; the secondary action resets the page for another submission. */ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { CheckCircle2, ExternalLink, Plus } from "lucide-react"; +type Mode = "create" | "add"; + interface ConfirmationViewProps { poId: number; itemCount: number; + mode: Mode; onCreateAnother: () => void; } export function ConfirmationView({ poId, itemCount, + mode, onCreateAnother, }: ConfirmationViewProps) { const externalUrl = `https://backend.acherryontop.com/po/edit/${poId}`; + const heading = + mode === "create" ? "Purchase order created" : "Products added to purchase order"; + const itemNoun = itemCount === 1 ? "item" : "items"; + const subhead = + mode === "create" + ? `PO #${poId} with ${itemCount} ${itemNoun} has been submitted to the backend.` + : `${itemCount} ${itemNoun} added to PO #${poId}.`; + const resetLabel = mode === "create" ? "Create another" : "Add more"; return (
@@ -31,11 +44,8 @@ export function ConfirmationView({
-

Purchase order created

-

- PO #{poId} with {itemCount} {itemCount === 1 ? "item" : "items"} has been - submitted to the backend. -

+

{heading}

+

{subhead}

diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 69c2743..0184201 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -18,6 +18,7 @@ import { Layers, Repeat, ClipboardPlus, + PackageSearch, } from "lucide-react"; import { IconCrystalBall } from "@tabler/icons-react"; import { @@ -161,6 +162,12 @@ const toolsItems = [ url: "/hts-lookup", permission: "access:hts_lookup" }, + { + title: "Spec Lookup", + icon: PackageSearch, + url: "/spec-lookup", + permission: "access:spec_lookup" + }, { title: "Chat Archive", icon: MessageCircle, diff --git a/inventory/src/components/product-import/config.ts b/inventory/src/components/product-import/config.ts index 848d6e3..b637ed4 100644 --- a/inventory/src/components/product-import/config.ts +++ b/inventory/src/components/product-import/config.ts @@ -293,16 +293,6 @@ export const BASE_IMPORT_FIELDS = [ width: 500, validations: [{ rule: "required", errorMessage: "Required", level: "error" }], }, - { - label: "Private Notes", - key: "priv_notes", - description: "Internal notes about the product", - fieldType: { - type: "input", - multiline: true - }, - width: 300, - }, { label: "Categories", key: "categories", @@ -335,6 +325,16 @@ export const BASE_IMPORT_FIELDS = [ }, width: 200, }, + { + label: "Private Notes", + key: "priv_notes", + description: "Internal notes about the product", + fieldType: { + type: "input", + multiline: true + }, + width: 300, + }, ] as const; export type ImportFieldKey = (typeof BASE_IMPORT_FIELDS)[number]["key"]; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx index beeb5cf..b19cacd 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx @@ -1,8 +1,11 @@ import { Button } from "@/components/ui/button"; import { Loader2, Upload } from "lucide-react"; import { useDropzone } from "react-dropzone"; +import { toast } from "sonner"; import { cn } from "@/lib/utils"; +const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; + interface GenericDropzoneProps { processingBulk: boolean; unassignedImages: { previewUrl: string; file: File }[]; @@ -22,7 +25,17 @@ export const GenericDropzone = ({ accept: { 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff'] }, + maxSize: MAX_UPLOAD_BYTES, onDrop, + onDropRejected: (rejections) => { + rejections.forEach((rejection) => { + const tooLarge = rejection.errors.some((e) => e.code === 'file-too-large'); + const reason = tooLarge + ? `larger than ${MAX_UPLOAD_BYTES / 1024 / 1024}MB limit` + : rejection.errors[0]?.message ?? 'rejected'; + toast.error(`${rejection.file.name}: ${reason}`); + }); + }, multiple: true }); diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx index 66d1361..a142a3e 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx @@ -1,7 +1,10 @@ import { Upload } from "lucide-react"; import { useDropzone } from "react-dropzone"; +import { toast } from "sonner"; import { cn } from "@/lib/utils"; +const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; + interface ImageDropzoneProps { productIndex: number; onDrop: (files: File[]) => void; @@ -12,9 +15,19 @@ export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => { accept: { 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff'] }, + maxSize: MAX_UPLOAD_BYTES, onDrop: (acceptedFiles) => { onDrop(acceptedFiles); }, + onDropRejected: (rejections) => { + rejections.forEach((rejection) => { + const tooLarge = rejection.errors.some((e) => e.code === 'file-too-large'); + const reason = tooLarge + ? `larger than ${MAX_UPLOAD_BYTES / 1024 / 1024}MB limit` + : rejection.errors[0]?.message ?? 'rejected'; + toast.error(`${rejection.file.name}: ${reason}`); + }); + }, }); 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 56c3418..b8d15f6 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx @@ -8,9 +8,10 @@ import { memo, useEffect, useState, useRef } from 'react'; import { Button } from '@/components/ui/button'; -import { X } from 'lucide-react'; +import { X, ArrowDownToLine } from 'lucide-react'; import { useValidationStore } from '../store/validationStore'; import { useIsCopyDownActive } from '../store/selectors'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; /** * Copy-down instruction banner @@ -23,6 +24,11 @@ import { useIsCopyDownActive } from '../store/selectors'; */ export const CopyDownBanner = memo(() => { const isActive = useIsCopyDownActive(); + // Subscribe only to the primitives we need to compute "rows below source". + // 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); + 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); @@ -57,13 +63,19 @@ export const CopyDownBanner = memo(() => { const tableRect = tableContainer.getBoundingClientRect(); const cellRect = cellElement.getBoundingClientRect(); - // Calculate position relative to the table container - // Position banner centered horizontally on the cell, above it - const topPosition = cellRect.top - tableRect.top - 55; // 55px above the cell (enough to not cover it) + // Measure actual banner height so it sits the right distance above the cell. + // Fallback covers the very first paint before the ref is attached. + const bannerHeight = bannerRef.current?.offsetHeight ?? 32; + const GAP = 10; + + // Always position above the cell. Clamp to the top of the table container + // so the banner stays visible — a small overlap with the source cell is + // acceptable for top-row sources since the compact pill rarely needs it. + const topPosition = Math.max(cellRect.top - tableRect.top - bannerHeight - GAP, 8); const leftPosition = cellRect.left - tableRect.left + cellRect.width / 2; setPosition({ - top: Math.max(topPosition, 8), // Minimum 8px from top + top: topPosition, left: leftPosition, }); }; @@ -85,6 +97,13 @@ export const CopyDownBanner = memo(() => { useValidationStore.getState().cancelCopyDown(); }; + const handleApplyToAll = () => { + const state = useValidationStore.getState(); + const lastRowIndex = state.rows.length - 1; + if (lastRowIndex <= (state.copyDownMode.sourceRowIndex ?? -1)) return; + state.completeCopyDown(lastRowIndex); + }; + return (
{ }} >
-
-
- - Click on the last row you want to copy to +
+
+ + Click row to copy to + {rowsBelow > 0 && ( + <> +
+ + + + + + {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 a7593e2..1006b1b 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -923,21 +923,29 @@ const CellWrapper = memo(({ {/* Copy-down button - appears on hover, positioned to avoid error icons */} {showCopyDownButton && ( - + + + + + + + Copy value to rows below + + + )} {/* UPC Generate button - appears on hover for empty UPC cells */} diff --git a/inventory/src/pages/CreatePurchaseOrder.tsx b/inventory/src/pages/CreatePurchaseOrder.tsx index 6ea5158..fde45a0 100644 --- a/inventory/src/pages/CreatePurchaseOrder.tsx +++ b/inventory/src/pages/CreatePurchaseOrder.tsx @@ -1,31 +1,42 @@ /** - * Create Purchase Order page. + * Create / Add-to Purchase Order page. * - * Lets the user pick a supplier and assemble a list of products via search, - * paste, or file upload, then submits the PO to the legacy PHP backend via - * the existing /apiv2 proxy. On success, shows a confirmation view with a - * link to the new PO in the legacy admin. + * Supports two flows toggled at the top of the page: + * - "create" → pick a supplier and assemble a list of products, then POST + * to /apiv2/po/new/{supplierId}. + * - "add" → enter an existing PO number and assemble a list of + * additional line items, then POST to + * /apiv2/po/add_products/{poId}. + * + * Both modes use the same product-add UX (Search/Paste/Upload) and the same + * line items table. The "add" mode does NOT pull existing items from the + * target PO — only the newly assembled items are sent to the backend. * * State model: - * - supplierId → controlled string from SupplierSelector - * - lineItems[] → the working list (PoLineItem; local-only fields - * qty + moqOverride live here) - * - selectedPids: Set → checkbox state for the bulk-remove flow - * - addOpen → AddProductsDialog visibility - * - submitting → submit button spinner - * - confirmation → null while building; { poId, itemCount } after - * a successful submit + * - mode → "create" | "add" (toggled via Tabs) + * - supplierId → controlled string, only used in create mode + * - existingPoInput → controlled string, only used in add mode + * - lineItems[] → working list (PoLineItem; local-only fields qty + + * moqOverride live here). Cleared when mode changes. + * - selectedPids: Set → checkbox state for the bulk-remove flow + * - addOpen → AddProductsDialog visibility + * - submitting → submit button spinner + * - confirmation → null while building; { poId, itemCount, mode } + * after a successful submit. * * Dedup is enforced server-naive: when AddProductsDialog returns a list of - * (pid, qty) pairs, we filter out pids that are already on the PO and show - * a brief toast indicating how many were skipped. The user can edit existing - * rows manually if they want to bump quantities — the dialog never mutates - * existing rows. + * (pid, qty) pairs, we filter out pids already on the working list and show + * a brief toast indicating how many were skipped. In "add" mode we do NOT + * dedup against the target PO (we don't fetch its current contents). */ import { useCallback, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + + +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Loader2, Plus } from "lucide-react"; import { toast } from "sonner"; import { SupplierSelector } from "@/components/create-po/SupplierSelector"; @@ -35,10 +46,17 @@ import { AddProductsDialog } from "@/components/create-po/AddProductsDialog"; import { ConfirmationView } from "@/components/create-po/ConfirmationView"; import { fetchBatchProducts } from "@/components/create-po/resolveIdentifiers"; import type { PoLineItem } from "@/components/create-po/types"; -import { submitNewPurchaseOrder } from "@/services/apiv2"; +import { + submitNewPurchaseOrder, + addProductsToPurchaseOrder, +} from "@/services/apiv2"; + +type Mode = "create" | "add"; export default function CreatePurchaseOrder() { + const [mode, setMode] = useState("create"); const [supplierId, setSupplierId] = useState(undefined); + const [existingPoInput, setExistingPoInput] = useState(""); const [lineItems, setLineItems] = useState([]); const [selectedPids, setSelectedPids] = useState>(new Set()); const [addOpen, setAddOpen] = useState(false); @@ -47,8 +65,22 @@ export default function CreatePurchaseOrder() { const [confirmation, setConfirmation] = useState<{ poId: number; itemCount: number; + mode: Mode; } | null>(null); + // ---- Mode toggle ---------------------------------------------------------- + // Switching modes clears the working list and target identifiers — the two + // flows submit to different endpoints with different keys, so mixing state + // would just make for confusing UX. + const handleModeChange = useCallback((next: string) => { + if (next !== "create" && next !== "add") return; + setMode(next); + setSupplierId(undefined); + setExistingPoInput(""); + setLineItems([]); + setSelectedPids(new Set()); + }, []); + // ---- Add products from any tab (Search/Paste/Upload) ---------------------- const handleAddProducts = useCallback( async (items: Array<{ pid: number; qty: number }>) => { @@ -157,14 +189,28 @@ export default function CreatePurchaseOrder() { }, []); // ---- Submit --------------------------------------------------------------- + const validItems = lineItems + .filter((i) => i.qty > 0) + .map((i) => ({ pid: i.pid, qty: i.qty })); + + const parsedPoId = (() => { + const trimmed = existingPoInput.trim(); + if (!trimmed) return undefined; + const n = Number(trimmed); + return Number.isInteger(n) && n > 0 ? n : undefined; + })(); + + const targetReady = mode === "create" ? !!supplierId : parsedPoId !== undefined; + const handleSubmit = useCallback(async () => { - if (!supplierId) { + if (mode === "create" && !supplierId) { toast.error("Pick a supplier first"); return; } - const validItems = lineItems - .filter((i) => i.qty > 0) - .map((i) => ({ pid: i.pid, qty: i.qty })); + if (mode === "add" && parsedPoId === undefined) { + toast.error("Enter a valid PO number first"); + return; + } if (validItems.length === 0) { toast.error("Add at least one product with a positive quantity"); return; @@ -172,27 +218,55 @@ export default function CreatePurchaseOrder() { setSubmitting(true); try { - const res = await submitNewPurchaseOrder({ supplierId, items: validItems }); - if (!res.success || !res.poId) { - const msg = - (typeof res.error === "string" && res.error) || - res.message || - "PO submission failed"; - toast.error(msg); - return; + if (mode === "create") { + const res = await submitNewPurchaseOrder({ + supplierId: supplierId!, + items: validItems, + }); + if (!res.success || !res.poId) { + const msg = + (typeof res.error === "string" && res.error) || + res.message || + "PO submission failed"; + toast.error(msg); + return; + } + setConfirmation({ + poId: res.poId, + itemCount: validItems.length, + mode: "create", + }); + } else { + const res = await addProductsToPurchaseOrder({ + poId: parsedPoId!, + items: validItems, + }); + if (!res.success) { + const msg = + (typeof res.error === "string" && res.error) || + res.message || + "Failed to add products to PO"; + toast.error(msg); + return; + } + setConfirmation({ + poId: parsedPoId!, + itemCount: validItems.length, + mode: "add", + }); } - setConfirmation({ poId: res.poId, itemCount: validItems.length }); } catch (e) { console.error(e); - toast.error(e instanceof Error ? e.message : "PO submission failed"); + toast.error(e instanceof Error ? e.message : "Submission failed"); } finally { setSubmitting(false); } - }, [supplierId, lineItems]); + }, [mode, supplierId, parsedPoId, validItems]); - // ---- Reset for "Create another" ------------------------------------------- + // ---- Reset for "Create another" / "Add more" ------------------------------ const handleCreateAnother = useCallback(() => { setSupplierId(undefined); + setExistingPoInput(""); setLineItems([]); setSelectedPids(new Set()); setConfirmation(null); @@ -205,6 +279,7 @@ export default function CreatePurchaseOrder() {
@@ -219,19 +294,53 @@ export default function CreatePurchaseOrder() { 0 ); + const pageTitle = + mode === "create" ? "Create Purchase Order" : "Add to Purchase Order"; + const targetCardTitle = mode === "create" ? "Supplier" : "Existing PO"; + const submitLabel = + mode === "create" ? "Create purchase order" : "Add products to PO"; + return (
-

Create Purchase Order

+

{pageTitle}

+ + + Create new PO + Add to existing PO + + + - Supplier + {targetCardTitle}
- + {mode === "create" ? ( + + ) : ( +
+ + setExistingPoInput(e.target.value.replace(/[^0-9]/g, "")) + } + /> + {existingPoInput.trim() !== "" && parsedPoId === undefined && ( +

+ Enter a valid positive PO number. +

+ )} +
+ )}
@@ -270,7 +379,7 @@ export default function CreatePurchaseOrder() { {submitting ? ( <> @@ -292,9 +401,7 @@ export default function CreatePurchaseOrder() { Submitting… ) : ( - <> - Create purchase order - + <>{submitLabel} )}
diff --git a/inventory/src/pages/SpecLookup.tsx b/inventory/src/pages/SpecLookup.tsx new file mode 100644 index 0000000..9d15eb3 --- /dev/null +++ b/inventory/src/pages/SpecLookup.tsx @@ -0,0 +1,734 @@ +import { useEffect, useMemo, useRef, useState, type FormEvent, type MouseEvent } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Search, Loader2, PackageOpen, Copy, Check, ChevronsUpDown, X } from "lucide-react"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { useToast } from "@/hooks/use-toast"; +import { cn } from "@/lib/utils"; + +type NumericAggregate = { + count: number; + sample_size: number; + avg?: number; + median?: number; + min?: number; + max?: number; + stddev?: number; + mode?: number | null; + mode_count?: number; + recent_mode?: number | null; + recent_mode_count?: number; + recent_window?: number; + trending?: boolean; + distribution: { value: number; count: number }[]; +}; + +type CategoricalEntry = { value: string; count: number }; + +type DescriptionAggregate = { + duplicates: { value: string; count: number }[]; + samples: { value: string; title: string; pid: number; sku: string }[]; +}; + +type ProductRow = { + pid: number; + title: string; + sku: string; + brand: string | null; + vendor: string | null; + artist: string | null; + country_of_origin: string | null; + harmonized_tariff_code: string | null; + description: string | null; + categories: string | null; + cost_price: number | null; + regular_price: number | null; + moq: number | null; + weight: number | null; + length: number | null; + width: number | null; + height: number | null; + created_at: string | null; +}; + +type SpecLookupResponse = { + company: string; + term: string; + total: number; + truncated: boolean; + products: ProductRow[]; + aggregates: { + numeric: Record; + categorical: Record; + categories: CategoricalEntry[]; + themes: CategoricalEntry[]; + description: DescriptionAggregate; + }; +}; + +type NumericFieldDef = { + key: keyof SpecLookupResponse["aggregates"]["numeric"] | string; + label: string; + format: (n: number) => string; +}; + +const NUMERIC_FIELDS: NumericFieldDef[] = [ + { key: "moq", label: "Min Qty", format: (n) => formatInt(n) }, + { key: "cost_price", label: "Wholesale", format: (n) => formatCurrency(n) }, + { key: "regular_price", label: "MSRP", format: (n) => formatCurrency(n) }, + { key: "weight", label: "Weight (oz)", format: (n) => `${formatNumber(n)} oz` }, + { key: "length", label: "Length (in)", format: (n) => `${formatNumber(n)} in` }, + { key: "width", label: "Width (in)", format: (n) => `${formatNumber(n)} in` }, + { key: "height", label: "Height (in)", format: (n) => `${formatNumber(n)} in` }, +]; + +const CATEGORICAL_FIELDS: { key: string; label: string }[] = [ + { key: "artist", label: "Artist" }, + { key: "country_of_origin", label: "COO" }, + { key: "harmonized_tariff_code", label: "HTS Code" }, +]; + +function formatNumber(n: number): string { + if (Math.abs(n) >= 100) return n.toFixed(1); + if (Math.abs(n) >= 1) return n.toFixed(2); + return n.toFixed(3); +} + +function formatInt(n: number): string { + return Number.isInteger(n) ? String(n) : n.toFixed(2); +} + +function formatCurrency(n: number): string { + return `$${n.toFixed(2)}`; +} + +export default function SpecLookup() { + const { toast } = useToast(); + const [company, setCompany] = useState(""); + const [term, setTerm] = useState(""); + const [companyOpen, setCompanyOpen] = useState(false); + const [submitted, setSubmitted] = useState<{ company: string; term: string } | null>(null); + const [copied, setCopied] = useState(null); + const copyTimerRef = useRef(null); + + const { data: brandsData } = useQuery<{ brands: string[] }>({ + queryKey: ["spec-lookup-brands"], + queryFn: async () => { + const response = await fetch(`/api/brands-aggregate/filter-options`); + if (!response.ok) throw new Error("Failed to load brands"); + return response.json(); + }, + staleTime: 10 * 60 * 1000, + }); + const brands = brandsData?.brands ?? []; + + const queryKey = useMemo( + () => ["spec-lookup", submitted?.company ?? "", submitted?.term ?? ""], + [submitted], + ); + + const { data, error, isFetching, isFetched, refetch } = useQuery({ + queryKey, + enabled: false, + queryFn: async () => { + const params = new URLSearchParams(); + if (submitted?.company) params.set("company", submitted.company); + if (submitted?.term) params.set("term", submitted.term); + + const response = await fetch(`/api/spec-lookup?${params.toString()}`); + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + const message = typeof payload.error === "string" ? payload.error : "Failed to fetch spec lookup"; + throw new Error(message); + } + return payload as SpecLookupResponse; + }, + staleTime: 2 * 60 * 1000, + }); + + useEffect(() => { + if (submitted) void refetch(); + }, [submitted, refetch]); + + useEffect(() => { + return () => { + if (copyTimerRef.current) window.clearTimeout(copyTimerRef.current); + }; + }, []); + + useEffect(() => { + if (error instanceof Error) { + toast({ title: "Search failed", description: error.message, variant: "destructive" }); + } + }, [error, toast]); + + const handleSubmit = (event?: FormEvent) => { + event?.preventDefault(); + const trimmedCompany = company.trim(); + const trimmedTerm = term.trim(); + + if (!trimmedCompany && !trimmedTerm) { + toast({ title: "Enter a company or product type" }); + return; + } + + if (submitted?.company === trimmedCompany && submitted?.term === trimmedTerm) { + void refetch(); + } else { + setSubmitted({ company: trimmedCompany, term: trimmedTerm }); + } + }; + + const handleCopy = async (event: MouseEvent, key: string, value: string) => { + event.preventDefault(); + event.stopPropagation(); + if (!navigator?.clipboard) { + toast({ title: "Clipboard unavailable", variant: "destructive" }); + return; + } + try { + await navigator.clipboard.writeText(value); + if (copyTimerRef.current) window.clearTimeout(copyTimerRef.current); + setCopied(key); + copyTimerRef.current = window.setTimeout(() => setCopied(null), 1200); + } catch (err) { + toast({ + title: "Copy failed", + description: err instanceof Error ? err.message : "Unable to copy", + variant: "destructive", + }); + } + }; + + const renderEmpty = (label: string) => ( +
No {label} data in matches
+ ); + + const renderNumericCard = (def: NumericFieldDef) => { + const agg = data?.aggregates.numeric[def.key]; + const hasData = agg && agg.count > 0; + const modeKey = `num:${def.key}`; + // When we've detected a recent shift, the recommended (headline) value becomes the recent mode + // — but the overall mode and full distribution are still shown below for verification. + const recommendedValue = !hasData + ? null + : agg.trending && agg.recent_mode != null + ? agg.recent_mode + : agg.mode; + const formattedRecommended = + hasData && recommendedValue != null ? def.format(recommendedValue) : "—"; + + return ( + + +
+
+ {def.label} + {hasData && agg.trending && ( + + Recent shift + + )} +
+ {hasData && recommendedValue != null && ( + + )} +
+ {formattedRecommended} +
+ + {hasData ? ( + <> + {agg.trending && agg.recent_mode != null ? ( + <> +
+ Recent: {def.format(agg.recent_mode)} ({agg.recent_mode_count} of last {agg.recent_window}) +
+
+ Overall mode was {def.format(agg.mode!)} ({agg.mode_count} of {agg.count}) +
+ + ) : ( +
+ Mode: {def.format(agg.mode!)} ({agg.mode_count} of {agg.count}) +
+ )} +
+ Median: {def.format(agg.median!)} + {" · "} + Avg: {def.format(agg.avg!)} +
+
+ Range: {def.format(agg.min!)} – {def.format(agg.max!)} +
+
+ {agg.count} of {agg.sample_size} products have this set +
+ {agg.distribution.length > 1 && ( + + + Distribution ({agg.distribution.length} distinct) + +
+ + + + Value + Count + + + + {agg.distribution.map((d) => ( + + {def.format(d.value)} + {d.count} + + ))} + +
+
+
+
+
+ )} + + ) : ( + renderEmpty(def.label) + )} +
+
+ ); + }; + + const renderCategoricalCard = (key: string, label: string) => { + const entries = data?.aggregates.categorical[key] ?? []; + const top = entries[0]; + const cardKey = `cat:${key}`; + + return ( + + +
+ {label} + {top && ( + + )} +
+ {top ? top.value : "—"} + {top && ( +
+ {top.count} of {data?.total ?? 0} products +
+ )} +
+ + {entries.length === 0 ? ( + renderEmpty(label) + ) : entries.length === 1 ? ( +
Only one distinct value across matches.
+ ) : ( + + + All values ({entries.length}) + +
+ + + + Value + Count + + + + {entries.map((e) => ( + + {e.value} + {e.count} + + ))} + +
+
+
+
+
+ )} +
+
+ ); + }; + + const renderTokenCard = ( + title: string, + emptyLabel: string, + entries: CategoricalEntry[], + ) => { + const dominantThreshold = Math.max(2, (data?.total ?? 0) * 0.5); + return ( + + + {title} + + + {entries.length === 0 ? ( + renderEmpty(emptyLabel) + ) : ( +
+ {entries.map((e) => ( + = dominantThreshold ? "default" : "outline"} + className="font-normal" + > + {e.value} + ×{e.count} + + ))} +
+ )} +
+
+ ); + }; + + const renderDescriptionCard = () => { + const desc = data?.aggregates.description; + if (!desc) return null; + + return ( + + + Description + + +
+
+ Used More Than Once {desc.duplicates.length > 0 && `(${desc.duplicates.length})`} +
+ {desc.duplicates.length === 0 ? ( +
No description was used more than once.
+ ) : ( +
+ {desc.duplicates.map((d, i) => ( +
+
+ Used {d.count}× + +
+
{d.value}
+
+ ))} +
+ )} +
+ +
+
+ Recent samples +
+ {desc.samples.length === 0 ? ( +
No descriptions in matches.
+ ) : ( +
+ {desc.samples.map((s, i) => ( +
+
+ + {s.title} + + +
+
{s.value}
+
+ ))} +
+ )} +
+
+
+ ); + }; + + const renderProductsTable = () => { + if (!data || data.products.length === 0) return null; + return ( + + + Matched products ({data.total}) + + {data.truncated ? "Showing first 500 matches (newest first)." : ""} + + + +
+ + + + Product + SKU + Brand + Cost + MSRP + Wt + + + + {data.products.map((p) => ( + + + + {p.title} + + + {p.sku} + {p.brand || "—"} + {p.cost_price != null ? formatCurrency(p.cost_price) : "—"} + {p.regular_price != null ? formatCurrency(p.regular_price) : "—"} + {p.weight != null ? formatNumber(p.weight) : "—"} + + ))} + +
+
+
+
+ + ); + }; + + return ( +
+
+

Spec Lookup

+

+ Use this to compare existing values across similar products when setting up a new product. +

+
+ + + + Search + + +
+
+ + + + + + + + + + No matching company. + + {brands.map((b) => ( + { + setCompany(value === company ? "" : value); + setCompanyOpen(false); + }} + > + + {b} + + ))} + + + + + +
+
+ + setTerm(e.target.value)} + /> +
+
+ + {isFetched && ( + + )} +
+
+
+
+ + {isFetched && data && data.total === 0 && ( + + + +
No products matched.
+
+
+ )} + + {isFetched && data && data.total > 0 && ( + <> +
+ + + Matched products + {data.total} + {data.truncated && ( +
capped at 500 — refine search to narrow
+ )} +
+
+ + + Company filter + {data.company || "(any)"} + + + + + Product type + {data.term || "(any)"} + + +
+ +
+

Numeric fields

+
+ {NUMERIC_FIELDS.map(renderNumericCard)} +
+
+ +
+

Categorical fields

+
+ {CATEGORICAL_FIELDS.map((f) => renderCategoricalCard(f.key, f.label))} +
+
+ +
+ {renderTokenCard( + "Categories", + "category", + data.aggregates.categories, + )} + {renderTokenCard( + "Themes", + "theme", + data.aggregates.themes ?? [], + )} +
+ {renderDescriptionCard()} + {renderProductsTable()} + + )} +
+ ); +} diff --git a/inventory/src/services/apiv2.ts b/inventory/src/services/apiv2.ts index 2693570..85337fd 100644 --- a/inventory/src/services/apiv2.ts +++ b/inventory/src/services/apiv2.ts @@ -30,6 +30,18 @@ export interface SubmitNewPurchaseOrderResponse { raw?: unknown; } +export interface AddProductsToPurchaseOrderArgs { + poId: number | string; + items: PoLineItemSubmit[]; +} + +export interface AddProductsToPurchaseOrderResponse { + success: boolean; + message?: string; + error?: unknown; + raw?: unknown; +} + export interface CreateProductCategoryArgs { masterCatId: string | number; name: string; @@ -322,3 +334,81 @@ export async function submitNewPurchaseOrder({ raw: parsed, }; } + +/** + * Adds line items to an existing purchase order on the legacy PHP backend. + * + * Mirrors `submitNewPurchaseOrder` exactly except the URL takes a po_id path + * param and we don't expect (or use) a po_id in the response. Same + * URL-encoded body shape, same cookie-auth flow, same HTML-response guard. + */ +export async function addProductsToPurchaseOrder({ + poId, + items, +}: AddProductsToPurchaseOrderArgs): Promise { + const poIdNum = Number(poId); + if (!Number.isInteger(poIdNum) || poIdNum <= 0) { + throw new Error("A valid PO number is required"); + } + if (!Array.isArray(items) || items.length === 0) { + throw new Error("At least one item is required"); + } + + const cleanItems = items + .map((i) => ({ pid: Number(i.pid), qty: Number(i.qty) })) + .filter((i) => Number.isInteger(i.pid) && i.pid > 0 && Number.isFinite(i.qty) && i.qty > 0); + + if (cleanItems.length === 0) { + throw new Error("No valid items to submit"); + } + + const targetUrl = `/apiv2/po/add_products/${encodeURIComponent(String(poIdNum))}`; + + const payload = new URLSearchParams(); + payload.append("items", JSON.stringify(cleanItems)); + + let response: Response; + try { + response = await fetch(targetUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + }, + body: payload, + credentials: "include", + }); + } catch (networkError) { + throw new Error(networkError instanceof Error ? networkError.message : "Network request failed"); + } + + const rawBody = await response.text(); + + if (isHtmlResponse(rawBody)) { + throw new Error("Backend authentication required. Please ensure you are logged into the backend system."); + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + throw new Error(`Unexpected response from backend (${response.status}).`); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("Empty response from backend"); + } + + const record = parsed as Record; + const backendSuccess = + record.success === true || + record.success === "true" || + record.success === 1; + const success = response.ok && (backendSuccess || record.success === undefined); + + return { + success, + message: typeof record.message === "string" ? record.message : undefined, + error: record.error ?? record.errors ?? record.error_msg, + raw: parsed, + }; +} diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index b4854fc..0e23e97 100644 --- a/inventory/tsconfig.tsbuildinfo +++ b/inventory/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/create-po/addproductsdialog.tsx","./src/components/create-po/confirmationview.tsx","./src/components/create-po/lineitemstable.tsx","./src/components/create-po/pofloatingselectionbar.tsx","./src/components/create-po/reviewmatchesdialog.tsx","./src/components/create-po/supplierselector.tsx","./src/components/create-po/constants.ts","./src/components/create-po/parsespreadsheet.ts","./src/components/create-po/resolveidentifiers.ts","./src/components/create-po/types.ts","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/createpurchaseorder.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/productlines.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/repeatorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/create-po/addproductsdialog.tsx","./src/components/create-po/confirmationview.tsx","./src/components/create-po/lineitemstable.tsx","./src/components/create-po/pofloatingselectionbar.tsx","./src/components/create-po/reviewmatchesdialog.tsx","./src/components/create-po/supplierselector.tsx","./src/components/create-po/constants.ts","./src/components/create-po/parsespreadsheet.ts","./src/components/create-po/resolveidentifiers.ts","./src/components/create-po/types.ts","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/createpurchaseorder.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/productlines.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/repeatorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/speclookup.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/inventory/vite.config.ts b/inventory/vite.config.ts index 06435c7..cc932fe 100644 --- a/inventory/vite.config.ts +++ b/inventory/vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => { if (useRsync) { // Use rsync over SSH - much faster than sshfs copying const deployTarget = process.env.DEPLOY_TARGET; - const targetPath = process.env.DEPLOY_PATH || '/var/www/html/inventory/inventory-server/frontend'; + const targetPath = process.env.DEPLOY_PATH || '/var/www/inventory/inventory-server/frontend'; try { console.log(`Deploying to ${deployTarget}:${targetPath}...`); diff --git a/mountremote netcup.command b/mountremote netcup.command index 46f6f07..8c6907e 100755 --- a/mountremote netcup.command +++ b/mountremote netcup.command @@ -4,4 +4,4 @@ umount '/Users/matt/Dev/inventory/inventory-server' #Mount -sshfs matt@159.195.13.70:/var/www/html/inventory '/Users/matt/Dev/inventory/inventory-server/' \ No newline at end of file +sshfs matt@159.195.13.70:/var/www/inventory '/Users/matt/Dev/inventory/inventory-server/' \ No newline at end of file