Update for project move on server, add ability to update existing POs, add spec lookup page, enhance copy down functionality.

This commit is contained in:
2026-05-13 11:28:35 -04:00
parent 38f4db3d15
commit c0f4f1de0d
26 changed files with 1414 additions and 103 deletions
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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.
@@ -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 });
}
@@ -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;
@@ -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'),
];
+6 -4
View File
@@ -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
@@ -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
+270
View File
@@ -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;
+3 -1
View File
@@ -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);
+1 -1
View File
@@ -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"
+8
View File
@@ -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() {
</Suspense>
</Protected>
} />
<Route path="/spec-lookup" element={
<Protected page="spec_lookup">
<Suspense fallback={<PageLoading />}>
<SpecLookup />
</Suspense>
</Protected>
} />
<Route path="/forecasting" element={
<Protected page="forecasting">
<Suspense fallback={<PageLoading />}>
@@ -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" },
@@ -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 |
@@ -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 (
<div className="max-w-2xl mx-auto pt-12">
@@ -31,11 +44,8 @@ export function ConfirmationView({
<div className="rounded-full bg-emerald-100 p-3 mb-4">
<CheckCircle2 className="h-8 w-8 text-emerald-600" />
</div>
<h2 className="text-2xl font-semibold mb-1">Purchase order created</h2>
<p className="text-muted-foreground mb-6">
PO #{poId} with {itemCount} {itemCount === 1 ? "item" : "items"} has been
submitted to the backend.
</p>
<h2 className="text-2xl font-semibold mb-1">{heading}</h2>
<p className="text-muted-foreground mb-6">{subhead}</p>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<Button asChild size="lg">
@@ -46,7 +56,7 @@ export function ConfirmationView({
</Button>
<Button variant="outline" size="lg" onClick={onCreateAnother}>
<Plus className="h-4 w-4 mr-2" />
Create another
{resetLabel}
</Button>
</div>
</div>
@@ -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,
@@ -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"];
@@ -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
});
@@ -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 (
@@ -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<HTMLDivElement>(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 (
<div
className="absolute z-30 pointer-events-none"
@@ -95,18 +114,44 @@ export const CopyDownBanner = memo(() => {
}}
>
<div ref={bannerRef} className="pointer-events-auto">
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-xl shadow-lg px-4 py-2.5 flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
Click on the last row you want to copy to
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-full shadow-lg pl-3 pr-1 py-1 flex items-center gap-2 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
<span className="text-xs font-medium text-blue-700 dark:text-blue-300 whitespace-nowrap">
Click row to copy to
</span>
{rowsBelow > 0 && (
<>
<div className="h-4 w-px bg-blue-200 dark:bg-blue-800" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleApplyToAll}
className="h-6 px-2 text-xs font-medium text-blue-700 hover:text-blue-900 hover:bg-blue-100 dark:text-blue-300 dark:hover:bg-blue-900 dark:hover:text-blue-100"
>
<ArrowDownToLine className="h-3 w-3 mr-1" />
All
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="center">
{rowsBelow === 1
? "Copy to 1 row below"
: `Copy to all ${rowsBelow} rows below`}
</TooltipContent>
</Tooltip>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
className="h-7 w-7 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900"
className="h-6 w-6 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
@@ -923,21 +923,29 @@ const CellWrapper = memo(({
{/* Copy-down button - appears on hover, positioned to avoid error icons */}
{showCopyDownButton && (
<button
type="button"
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',
'dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400',
'shadow-sm',
// Position further left if there are errors to avoid overlap
hasErrors ? 'right-7' : 'right-0.5'
)}
title="Copy value to rows below"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
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',
'dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400',
'shadow-sm',
// Position further left if there are errors to avoid overlap
hasErrors ? 'right-7' : 'right-0.5'
)}
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
Copy value to rows below
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* UPC Generate button - appears on hover for empty UPC cells */}
+149 -42
View File
@@ -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:
* - supplierIdcontrolled string from SupplierSelector
* - lineItems[] → the working list (PoLineItem; local-only fields
* qty + moqOverride live here)
* - selectedPids: Setcheckbox 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<Mode>("create");
const [supplierId, setSupplierId] = useState<string | undefined>(undefined);
const [existingPoInput, setExistingPoInput] = useState<string>("");
const [lineItems, setLineItems] = useState<PoLineItem[]>([]);
const [selectedPids, setSelectedPids] = useState<Set<number>>(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() {
<ConfirmationView
poId={confirmation.poId}
itemCount={confirmation.itemCount}
mode={confirmation.mode}
onCreateAnother={handleCreateAnother}
/>
</div>
@@ -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 (
<div className="container mx-auto p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Create Purchase Order</h1>
<h1 className="text-2xl font-semibold">{pageTitle}</h1>
</div>
<Tabs value={mode} onValueChange={handleModeChange}>
<TabsList>
<TabsTrigger value="create">Create new PO</TabsTrigger>
<TabsTrigger value="add">Add to existing PO</TabsTrigger>
</TabsList>
</Tabs>
<Card>
<CardHeader>
<CardTitle>Supplier</CardTitle>
<CardTitle>{targetCardTitle}</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-md">
<SupplierSelector value={supplierId} onChange={setSupplierId} />
{mode === "create" ? (
<SupplierSelector value={supplierId} onChange={setSupplierId} />
) : (
<div className="space-y-2">
<Input
id="existing-po-id"
type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder="PO number"
value={existingPoInput}
onChange={(e) =>
setExistingPoInput(e.target.value.replace(/[^0-9]/g, ""))
}
/>
{existingPoInput.trim() !== "" && parsedPoId === undefined && (
<p className="text-sm text-destructive">
Enter a valid positive PO number.
</p>
)}
</div>
)}
</div>
</CardContent>
</Card>
@@ -270,7 +379,7 @@ export default function CreatePurchaseOrder() {
<LineItemsTable
items={lineItems}
selectedPids={selectedPids}
supplierId={supplierId ? Number(supplierId) : undefined}
supplierId={mode === "create" && supplierId ? Number(supplierId) : undefined}
onToggleSelect={handleToggleSelect}
onToggleSelectAll={handleToggleSelectAll}
onChangeQty={handleChangeQty}
@@ -284,7 +393,7 @@ export default function CreatePurchaseOrder() {
<Button
size="lg"
onClick={handleSubmit}
disabled={submitting || lineItems.length === 0 || !supplierId}
disabled={submitting || lineItems.length === 0 || !targetReady}
>
{submitting ? (
<>
@@ -292,9 +401,7 @@ export default function CreatePurchaseOrder() {
Submitting
</>
) : (
<>
Create purchase order
</>
<>{submitLabel}</>
)}
</Button>
</div>
+734
View File
@@ -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<string, NumericAggregate>;
categorical: Record<string, CategoricalEntry[]>;
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<string | null>(null);
const copyTimerRef = useRef<number | null>(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<SpecLookupResponse>({
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<HTMLButtonElement>, 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) => (
<div className="text-xs italic text-muted-foreground">No {label} data in matches</div>
);
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 (
<Card key={def.key}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<CardDescription>{def.label}</CardDescription>
{hasData && agg.trending && (
<Badge variant="default" className="h-4 px-1.5 text-[9px] font-medium uppercase">
Recent shift
</Badge>
)}
</div>
{hasData && recommendedValue != null && (
<Button
type="button"
size="icon"
variant={copied === modeKey ? "secondary" : "ghost"}
className="h-6 w-6"
aria-label={`Copy ${def.label}`}
onClick={(e) => handleCopy(e, modeKey, String(recommendedValue))}
>
{copied === modeKey ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
</Button>
)}
</div>
<CardTitle className="text-2xl">{formattedRecommended}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-xs text-muted-foreground">
{hasData ? (
<>
{agg.trending && agg.recent_mode != null ? (
<>
<div>
Recent: <span className="text-foreground font-medium">{def.format(agg.recent_mode)}</span> ({agg.recent_mode_count} of last {agg.recent_window})
</div>
<div>
Overall mode was <span className="text-foreground">{def.format(agg.mode!)}</span> ({agg.mode_count} of {agg.count})
</div>
</>
) : (
<div>
Mode: <span className="text-foreground font-medium">{def.format(agg.mode!)}</span> ({agg.mode_count} of {agg.count})
</div>
)}
<div>
Median: <span className="text-foreground">{def.format(agg.median!)}</span>
{" · "}
Avg: <span className="text-foreground">{def.format(agg.avg!)}</span>
</div>
<div>
Range: {def.format(agg.min!)} {def.format(agg.max!)}
</div>
<div>
{agg.count} of {agg.sample_size} products have this set
</div>
{agg.distribution.length > 1 && (
<Accordion type="single" collapsible>
<AccordionItem value="dist" className="border-none">
<AccordionTrigger className="py-1 text-xs">Distribution ({agg.distribution.length} distinct)</AccordionTrigger>
<AccordionContent>
<div className="max-h-56 overflow-auto pr-2">
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-7">Value</TableHead>
<TableHead className="h-7 text-right">Count</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agg.distribution.map((d) => (
<TableRow key={`${def.key}-${d.value}`}>
<TableCell className="py-1 font-mono">{def.format(d.value)}</TableCell>
<TableCell className="py-1 text-right">{d.count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</>
) : (
renderEmpty(def.label)
)}
</CardContent>
</Card>
);
};
const renderCategoricalCard = (key: string, label: string) => {
const entries = data?.aggregates.categorical[key] ?? [];
const top = entries[0];
const cardKey = `cat:${key}`;
return (
<Card key={key}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardDescription>{label}</CardDescription>
{top && (
<Button
type="button"
size="icon"
variant={copied === cardKey ? "secondary" : "ghost"}
className="h-6 w-6"
aria-label={`Copy top ${label}`}
onClick={(e) => handleCopy(e, cardKey, top.value)}
>
{copied === cardKey ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
</Button>
)}
</div>
<CardTitle className="text-base break-words">{top ? top.value : "—"}</CardTitle>
{top && (
<div className="text-xs text-muted-foreground">
{top.count} of {data?.total ?? 0} products
</div>
)}
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
{entries.length === 0 ? (
renderEmpty(label)
) : entries.length === 1 ? (
<div>Only one distinct value across matches.</div>
) : (
<Accordion type="single" collapsible>
<AccordionItem value="all" className="border-none">
<AccordionTrigger className="py-1 text-xs">All values ({entries.length})</AccordionTrigger>
<AccordionContent>
<div className="max-h-56 overflow-auto pr-2">
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-7">Value</TableHead>
<TableHead className="h-7 text-right">Count</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e) => (
<TableRow key={`${key}-${e.value}`}>
<TableCell className="py-1 break-all">{e.value}</TableCell>
<TableCell className="py-1 text-right">{e.count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</CardContent>
</Card>
);
};
const renderTokenCard = (
title: string,
emptyLabel: string,
entries: CategoricalEntry[],
) => {
const dominantThreshold = Math.max(2, (data?.total ?? 0) * 0.5);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
renderEmpty(emptyLabel)
) : (
<div className="flex flex-wrap gap-2">
{entries.map((e) => (
<Badge
key={e.value}
variant={e.count >= dominantThreshold ? "default" : "outline"}
className="font-normal"
>
{e.value}
<span className="ml-1.5 text-[10px] opacity-70">×{e.count}</span>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
};
const renderDescriptionCard = () => {
const desc = data?.aggregates.description;
if (!desc) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Description</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Used More Than Once {desc.duplicates.length > 0 && `(${desc.duplicates.length})`}
</div>
{desc.duplicates.length === 0 ? (
<div className="text-xs italic text-muted-foreground">No description was used more than once.</div>
) : (
<div className="max-h-96 space-y-2 overflow-auto pr-2">
{desc.duplicates.map((d, i) => (
<div key={`dup-${i}`} className="rounded-md border p-3">
<div className="mb-1 flex items-center justify-between gap-2">
<Badge variant="default">Used {d.count}× </Badge>
<Button
type="button"
size="icon"
variant={copied === `dup:${i}` ? "secondary" : "ghost"}
className="h-6 w-6"
aria-label="Copy description"
onClick={(e) => handleCopy(e, `dup:${i}`, d.value)}
>
{copied === `dup:${i}` ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
<div className="whitespace-pre-wrap text-sm">{d.value}</div>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Recent samples
</div>
{desc.samples.length === 0 ? (
<div className="text-xs italic text-muted-foreground">No descriptions in matches.</div>
) : (
<div className="max-h-96 space-y-2 overflow-auto pr-2">
{desc.samples.map((s, i) => (
<div key={`sample-${s.pid}`} className="rounded-md border p-3">
<div className="mb-1 flex items-center justify-between gap-2">
<a
href={`https://backend.acherryontop.com/product/${s.pid}`}
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-primary hover:underline"
>
{s.title}
</a>
<Button
type="button"
size="icon"
variant={copied === `sample:${i}` ? "secondary" : "ghost"}
className="h-6 w-6"
aria-label="Copy description"
onClick={(e) => handleCopy(e, `sample:${i}`, s.value)}
>
{copied === `sample:${i}` ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
<div className="whitespace-pre-wrap text-sm">{s.value}</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
);
};
const renderProductsTable = () => {
if (!data || data.products.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Matched products ({data.total})</CardTitle>
<CardDescription>
{data.truncated ? "Showing first 500 matches (newest first)." : ""}
</CardDescription>
</CardHeader>
<CardContent>
<div className="max-h-96 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Brand</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">MSRP</TableHead>
<TableHead className="text-right">Wt</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.products.map((p) => (
<TableRow key={p.pid}>
<TableCell>
<a
href={`https://backend.acherryontop.com/product/${p.pid}`}
target="_blank"
rel="noreferrer"
className="font-medium text-primary hover:underline"
>
{p.title}
</a>
</TableCell>
<TableCell className="whitespace-nowrap">{p.sku}</TableCell>
<TableCell>{p.brand || "—"}</TableCell>
<TableCell className="text-right">{p.cost_price != null ? formatCurrency(p.cost_price) : "—"}</TableCell>
<TableCell className="text-right">{p.regular_price != null ? formatCurrency(p.regular_price) : "—"}</TableCell>
<TableCell className="text-right">{p.weight != null ? formatNumber(p.weight) : "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
};
return (
<div className="space-y-6 p-6">
<div className="space-y-2">
<h1 className="text-3xl font-bold">Spec Lookup</h1>
<p className="text-sm text-muted-foreground">
Use this to compare existing values across similar products when setting up a new product.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Search</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-3 md:grid-cols-[1fr_1fr_auto] md:items-end">
<div className="space-y-1">
<Label htmlFor="pd-company">Company</Label>
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
<PopoverTrigger asChild>
<Button
id="pd-company"
type="button"
variant="outline"
role="combobox"
aria-expanded={companyOpen}
className={cn("w-full justify-between font-normal", !company && "text-muted-foreground")}
>
<span className="truncate">{company || "Select company..."}</span>
<div className="flex shrink-0 items-center gap-1">
{company && (
<span
role="button"
tabIndex={0}
aria-label="Clear company"
className="rounded hover:bg-muted"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCompany("");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
setCompany("");
}
}}
>
<X className="h-3 w-3 opacity-60" />
</span>
)}
<ChevronsUpDown className="h-4 w-4 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search companies..." />
<CommandList>
<CommandEmpty>No matching company.</CommandEmpty>
<CommandGroup>
{brands.map((b) => (
<CommandItem
key={b}
value={b}
onSelect={(value) => {
setCompany(value === company ? "" : value);
setCompanyOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", company === b ? "opacity-100" : "opacity-0")} />
{b}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<Label htmlFor="pd-term">Product Type</Label>
<Input
id="pd-term"
placeholder="Paper pad, washi, stickers, etc."
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={isFetching}>
{isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-2">Search</span>
</Button>
{isFetched && (
<Button
type="button"
variant="outline"
disabled={isFetching}
onClick={() => {
setCompany("");
setTerm("");
setSubmitted(null);
}}
>
Clear
</Button>
)}
</div>
</form>
</CardContent>
</Card>
{isFetched && data && data.total === 0 && (
<Card>
<CardContent className="space-y-3 py-8 text-center text-muted-foreground">
<PackageOpen className="mx-auto h-10 w-10" />
<div>No products matched.</div>
</CardContent>
</Card>
)}
{isFetched && data && data.total > 0 && (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardDescription>Matched products</CardDescription>
<CardTitle className="text-3xl">{data.total}</CardTitle>
{data.truncated && (
<div className="text-xs text-muted-foreground">capped at 500 refine search to narrow</div>
)}
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>Company filter</CardDescription>
<CardTitle className="text-base break-words">{data.company || "(any)"}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>Product type</CardDescription>
<CardTitle className="text-base break-words">{data.term || "(any)"}</CardTitle>
</CardHeader>
</Card>
</div>
<div>
<h2 className="mb-3 text-lg font-semibold">Numeric fields</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{NUMERIC_FIELDS.map(renderNumericCard)}
</div>
</div>
<div>
<h2 className="mb-3 text-lg font-semibold">Categorical fields</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{CATEGORICAL_FIELDS.map((f) => renderCategoricalCard(f.key, f.label))}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{renderTokenCard(
"Categories",
"category",
data.aggregates.categories,
)}
{renderTokenCard(
"Themes",
"theme",
data.aggregates.themes ?? [],
)}
</div>
{renderDescriptionCard()}
{renderProductsTable()}
</>
)}
</div>
);
}
+90
View File
@@ -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<AddProductsToPurchaseOrderResponse> {
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<string, unknown>;
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,
};
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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}...`);
+1 -1
View File
@@ -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/'
sshfs matt@159.195.13.70:/var/www/inventory '/Users/matt/Dev/inventory/inventory-server/'