diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index 6ad1d38..a4cd58c 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -1151,7 +1151,7 @@ router.get('/search-products', async (req, res) => { p.itemnumber AS sku, p.upc AS barcode, p.harmonized_tariff_code, - pcp.price_each AS price, + MIN(pcp.price_each) AS price, p.sellingprice AS regular_price, CASE WHEN sid.supplier_id = 92 THEN @@ -1172,7 +1172,7 @@ router.get('/search-products', async (req, res) => { p.subline AS subline_id, pc4.name AS artist, p.artist AS artist_id, - COALESCE(CASE + COALESCE(CASE WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit ELSE sid.supplier_qty_per_unit END, sid.notions_qty_per_unit) AS moq, @@ -1273,7 +1273,7 @@ const PRODUCT_SELECT = ` p.itemnumber AS sku, p.upc AS barcode, p.harmonized_tariff_code, - pcp.price_each AS price, + MIN(pcp.price_each) AS price, p.sellingprice AS regular_price, CASE WHEN sid.supplier_id = 92 THEN diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index 69399db..62d82e8 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -40,6 +40,9 @@ const Import = lazy(() => import('./pages/Import').then(module => ({ default: mo // Product editor const ProductEditor = lazy(() => import('./pages/ProductEditor')); +// Bulk edit +const BulkEdit = lazy(() => import('./pages/BulkEdit')); + // 4. Chat archive - separate chunk const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat }))); @@ -198,6 +201,15 @@ function App() { } /> + {/* Bulk edit */} + + }> + + + + } /> + {/* Product import - separate chunk */} diff --git a/inventory/src/components/bulk-edit/BulkEditRow.tsx b/inventory/src/components/bulk-edit/BulkEditRow.tsx new file mode 100644 index 0000000..e66ca62 --- /dev/null +++ b/inventory/src/components/bulk-edit/BulkEditRow.tsx @@ -0,0 +1,440 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Loader2, + Check, + X, + ExternalLink, + Sparkles, + AlertCircle, + Save, + CheckCircle, + AlertTriangle, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { SearchProduct, FieldOption } from "@/components/product-editor/types"; + +const PROD_IMG_HOST = "https://sbing.com"; +const BACKEND_URL = "https://backend.acherryontop.com/product"; + +export type BulkEditFieldChoice = + | "name" + | "description" + | "categories" + | "themes" + | "colors" + | "tax_cat" + | "size_cat" + | "ship_restrictions" + | "hts_code" + | "weight" + | "msrp" + | "cost_each"; + +export interface AiResult { + isValid: boolean; + suggestion?: string | null; + issues: string[]; +} + +export interface RowAiState { + status: "idle" | "validating" | "done"; + result: AiResult | null; + editedSuggestion: string | null; + decision: "accepted" | "dismissed" | null; + saveStatus: "idle" | "saving" | "saved" | "error"; + saveError: string | null; + /** Track manual edits to the main field value */ + manualEdit: string | null; +} + +export const INITIAL_ROW_STATE: RowAiState = { + status: "idle", + result: null, + editedSuggestion: null, + decision: null, + saveStatus: "idle", + saveError: null, + manualEdit: null, +}; + +/** Fields that support AI validation */ +export const AI_FIELDS: BulkEditFieldChoice[] = ["name", "description"]; + +/** Field display config */ +export const FIELD_OPTIONS: { value: BulkEditFieldChoice; label: string; ai?: boolean }[] = [ + { value: "description", label: "Description", ai: true }, + { value: "name", label: "Name", ai: true }, + { value: "hts_code", label: "HTS Code" }, + { value: "weight", label: "Weight" }, + { value: "msrp", label: "MSRP" }, + { value: "cost_each", label: "Cost Each" }, + { value: "tax_cat", label: "Tax Category" }, + { value: "size_cat", label: "Size Category" }, + { value: "ship_restrictions", label: "Shipping Restrictions" }, +]; + +/** Get the current raw value for a field from SearchProduct */ +export function getFieldValue(product: SearchProduct, field: BulkEditFieldChoice): string { + switch (field) { + case "name": return product.title ?? ""; + case "description": return product.description ?? ""; + case "hts_code": return product.harmonized_tariff_code ?? ""; + case "weight": return product.weight != null ? String(product.weight) : ""; + case "msrp": return product.regular_price != null ? String(product.regular_price) : ""; + case "cost_each": return product.cost_price != null ? String(product.cost_price) : ""; + case "tax_cat": return product.tax_code ?? ""; + case "size_cat": return product.size_cat ?? ""; + case "ship_restrictions": return product.shipping_restrictions ?? ""; + default: return ""; + } +} + +/** Get the backend field key for submission */ +export function getSubmitFieldKey(field: BulkEditFieldChoice): string { + switch (field) { + case "name": return "description"; // backend field is "description" for product name + case "description": return "notes"; // backend uses "notes" for product description + case "hts_code": return "harmonized_tariff_code"; + case "msrp": return "sellingprice"; + case "cost_each": return "cost_each"; + case "tax_cat": return "tax_code"; + case "size_cat": return "size_cat"; + case "ship_restrictions": return "shipping_restrictions"; + default: return field; + } +} + +interface BulkEditRowProps { + product: SearchProduct; + field: BulkEditFieldChoice; + state: RowAiState; + imageUrl: string | null; + selectOptions?: FieldOption[]; + onAccept: (pid: number, value: string) => void; + onDismiss: (pid: number) => void; + onManualEdit: (pid: number, value: string) => void; + onEditSuggestion: (pid: number, value: string) => void; +} + +export function BulkEditRow({ + product, + field, + state, + imageUrl, + selectOptions, + onAccept, + onDismiss, + onManualEdit, + onEditSuggestion, +}: BulkEditRowProps) { + const currentValue = state.manualEdit ?? getFieldValue(product, field); + const hasAiSuggestion = + state.status === "done" && state.result && !state.result.isValid && state.result.suggestion; + const isValid = state.status === "done" && state.result?.isValid; + const isAccepted = state.decision === "accepted"; + const isDismissed = state.decision === "dismissed"; + const isValidating = state.status === "validating"; + const showSuggestion = hasAiSuggestion && !isDismissed && !isAccepted; + const backendUrl = `${BACKEND_URL}/${product.pid}`; + + // Determine border color based on state + const borderClass = isAccepted + ? "border-l-4 border-l-green-500" + : state.saveStatus === "saved" + ? "border-l-4 border-l-green-300" + : state.saveStatus === "error" + ? "border-l-4 border-l-destructive" + : ""; + + const renderFieldEditor = () => { + // If this is a select field, render a select + if (selectOptions) { + return ( + + ); + } + + // Description gets a textarea with inline spinner + if (field === "description") { + return ( +
+