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:
+2
-2
@@ -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
@@ -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'),
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"];
|
||||
+13
@@ -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
|
||||
});
|
||||
|
||||
|
||||
+13
@@ -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 (
|
||||
|
||||
+56
-11
@@ -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>
|
||||
|
||||
+23
-15
@@ -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 */}
|
||||
|
||||
@@ -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<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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}...`);
|
||||
|
||||
@@ -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/'
|
||||
Reference in New Issue
Block a user