Add draft bulk edit page, enhance product edit form to handle current price and image changes submission, handle product editor taxonomy updates
This commit is contained in:
@@ -1151,7 +1151,7 @@ router.get('/search-products', async (req, res) => {
|
|||||||
p.itemnumber AS sku,
|
p.itemnumber AS sku,
|
||||||
p.upc AS barcode,
|
p.upc AS barcode,
|
||||||
p.harmonized_tariff_code,
|
p.harmonized_tariff_code,
|
||||||
pcp.price_each AS price,
|
MIN(pcp.price_each) AS price,
|
||||||
p.sellingprice AS regular_price,
|
p.sellingprice AS regular_price,
|
||||||
CASE
|
CASE
|
||||||
WHEN sid.supplier_id = 92 THEN
|
WHEN sid.supplier_id = 92 THEN
|
||||||
@@ -1273,7 +1273,7 @@ const PRODUCT_SELECT = `
|
|||||||
p.itemnumber AS sku,
|
p.itemnumber AS sku,
|
||||||
p.upc AS barcode,
|
p.upc AS barcode,
|
||||||
p.harmonized_tariff_code,
|
p.harmonized_tariff_code,
|
||||||
pcp.price_each AS price,
|
MIN(pcp.price_each) AS price,
|
||||||
p.sellingprice AS regular_price,
|
p.sellingprice AS regular_price,
|
||||||
CASE
|
CASE
|
||||||
WHEN sid.supplier_id = 92 THEN
|
WHEN sid.supplier_id = 92 THEN
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ const Import = lazy(() => import('./pages/Import').then(module => ({ default: mo
|
|||||||
// Product editor
|
// Product editor
|
||||||
const ProductEditor = lazy(() => import('./pages/ProductEditor'));
|
const ProductEditor = lazy(() => import('./pages/ProductEditor'));
|
||||||
|
|
||||||
|
// Bulk edit
|
||||||
|
const BulkEdit = lazy(() => import('./pages/BulkEdit'));
|
||||||
|
|
||||||
// 4. Chat archive - separate chunk
|
// 4. Chat archive - separate chunk
|
||||||
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
|
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
|
||||||
|
|
||||||
@@ -198,6 +201,15 @@ function App() {
|
|||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* Bulk edit */}
|
||||||
|
<Route path="/bulk-edit" element={
|
||||||
|
<Protected page="bulk_edit">
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<BulkEdit />
|
||||||
|
</Suspense>
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Product import - separate chunk */}
|
{/* Product import - separate chunk */}
|
||||||
<Route path="/import" element={
|
<Route path="/import" element={
|
||||||
<Protected page="import">
|
<Protected page="import">
|
||||||
|
|||||||
440
inventory/src/components/bulk-edit/BulkEditRow.tsx
Normal file
440
inventory/src/components/bulk-edit/BulkEditRow.tsx
Normal file
@@ -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 (
|
||||||
|
<Select
|
||||||
|
value={currentValue}
|
||||||
|
onValueChange={(v) => onManualEdit(product.pid, v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full h-8 text-sm">
|
||||||
|
<SelectValue placeholder="Select..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{selectOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description gets a textarea with inline spinner
|
||||||
|
if (field === "description") {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => onManualEdit(product.pid, e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"text-sm min-h-[60px] max-h-[120px] resize-y",
|
||||||
|
isValidating && "pr-8"
|
||||||
|
)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
{isValidating && (
|
||||||
|
<Loader2 className="absolute top-2 right-2 h-4 w-4 animate-spin text-purple-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name/text fields with inline spinner
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => onManualEdit(product.pid, e.target.value)}
|
||||||
|
className={cn("h-8 text-sm", isValidating && "pr-8")}
|
||||||
|
/>
|
||||||
|
{isValidating && (
|
||||||
|
<Loader2 className="absolute top-2 right-2 h-3.5 w-3.5 animate-spin text-purple-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline AI suggestion panel for name / short text fields
|
||||||
|
const renderNameSuggestion = () => {
|
||||||
|
if (!showSuggestion) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
|
||||||
|
{/* Issues */}
|
||||||
|
{state.result!.issues.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{state.result!.issues.map((issue, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-1 text-[11px] text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Editable suggestion + actions */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-purple-500 shrink-0" />
|
||||||
|
<Input
|
||||||
|
value={state.editedSuggestion ?? state.result!.suggestion!}
|
||||||
|
onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
|
||||||
|
className="h-7 text-sm flex-1 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 bg-purple-50/50 dark:bg-purple-950/20"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2 text-xs shrink-0 bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||||
|
onClick={() =>
|
||||||
|
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-1.5 text-xs text-gray-500 hover:text-gray-700 shrink-0"
|
||||||
|
onClick={() => onDismiss(product.pid)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline AI suggestion panel for description (larger, stacked)
|
||||||
|
const renderDescriptionSuggestion = () => {
|
||||||
|
if (!showSuggestion) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0 flex-1 bg-purple-50/60 dark:bg-purple-950/20 rounded-md p-2">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||||
|
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||||
|
AI Suggestion
|
||||||
|
</span>
|
||||||
|
{state.result!.issues.length > 0 && (
|
||||||
|
<span className="text-[11px] text-purple-500">
|
||||||
|
({state.result!.issues.length} {state.result!.issues.length === 1 ? "issue" : "issues"})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issues */}
|
||||||
|
{state.result!.issues.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{state.result!.issues.map((issue, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-1 text-[11px] text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editable suggestion */}
|
||||||
|
<Textarea
|
||||||
|
value={state.editedSuggestion ?? state.result!.suggestion!}
|
||||||
|
onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
|
||||||
|
className="text-sm min-h-[60px] max-h-[120px] resize-y border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 bg-white dark:bg-black/20"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||||
|
onClick={() =>
|
||||||
|
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-xs text-gray-500 hover:text-gray-700"
|
||||||
|
onClick={() => onDismiss(product.pid)}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status icon (right edge)
|
||||||
|
const renderStatus = () => {
|
||||||
|
// Don't show spinner here anymore — it's in the field editor
|
||||||
|
if (isValid && !isAccepted) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>No changes needed</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isAccepted) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Change accepted</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.saveStatus === "saving") {
|
||||||
|
return <Loader2 className="h-4 w-4 animate-spin text-primary" />;
|
||||||
|
}
|
||||||
|
if (state.saveStatus === "saved") {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Save className="h-4 w-4 text-green-500" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Saved</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.saveStatus === "error") {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{state.saveError || "Save failed"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-lg border bg-card transition-colors", borderClass)}>
|
||||||
|
<div className="flex items-start gap-3 p-3">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<a
|
||||||
|
href={backendUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block h-12 w-12 shrink-0 overflow-hidden rounded-md border bg-muted"
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl.startsWith("/") ? PROD_IMG_HOST + imageUrl : imageUrl}
|
||||||
|
alt={product.title}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-[10px] text-muted-foreground">
|
||||||
|
No img
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Identity */}
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0 w-44 shrink-0">
|
||||||
|
<a
|
||||||
|
href={backendUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm font-medium text-primary hover:underline flex items-center gap-1 truncate"
|
||||||
|
>
|
||||||
|
<span className="truncate">{product.title}</span>
|
||||||
|
<ExternalLink className="h-3 w-3 shrink-0" />
|
||||||
|
</a>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
UPC: {product.barcode || "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
SKU: {product.sku || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field editor */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{renderFieldEditor()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI suggestion inline (same row) */}
|
||||||
|
{field === "description"
|
||||||
|
? renderDescriptionSuggestion()
|
||||||
|
: renderNameSuggestion()
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Status icon */}
|
||||||
|
<div className="flex items-center shrink-0 w-6 justify-center pt-1">
|
||||||
|
{renderStatus()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
FileSearch,
|
FileSearch,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
FilePenLine,
|
FilePenLine,
|
||||||
|
PenLine,
|
||||||
Mail,
|
Mail,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
@@ -122,6 +123,12 @@ const toolsItems = [
|
|||||||
url: "/product-editor",
|
url: "/product-editor",
|
||||||
permission: "access:product_editor"
|
permission: "access:product_editor"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Bulk Edit",
|
||||||
|
icon: PenLine,
|
||||||
|
url: "/bulk-edit",
|
||||||
|
permission: "access:bulk_edit"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Newsletter",
|
title: "Newsletter",
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
|||||||
import { useInlineAiValidation } from "@/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation";
|
import { useInlineAiValidation } from "@/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation";
|
||||||
import { AiSuggestionBadge } from "@/components/product-import/steps/ValidationStep/components/AiSuggestionBadge";
|
import { AiSuggestionBadge } from "@/components/product-import/steps/ValidationStep/components/AiSuggestionBadge";
|
||||||
import { AiDescriptionCompare } from "@/components/ai/AiDescriptionCompare";
|
import { AiDescriptionCompare } from "@/components/ai/AiDescriptionCompare";
|
||||||
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
|
import { submitProductEdit, submitImageChanges, submitTaxonomySet, type ImageChanges } from "@/services/productEditor";
|
||||||
import { EditableComboboxField } from "./EditableComboboxField";
|
import { EditableComboboxField } from "./EditableComboboxField";
|
||||||
import { EditableInput } from "./EditableInput";
|
import { EditableInput } from "./EditableInput";
|
||||||
import { EditableMultiSelect } from "./EditableMultiSelect";
|
import { EditableMultiSelect } from "./EditableMultiSelect";
|
||||||
@@ -79,6 +79,8 @@ interface FieldConfig {
|
|||||||
showColors?: boolean;
|
showColors?: boolean;
|
||||||
/** Format value for display (editing shows raw value) */
|
/** Format value for display (editing shows raw value) */
|
||||||
formatDisplay?: (val: string) => string;
|
formatDisplay?: (val: string) => string;
|
||||||
|
/** Number of grid columns this field spans (default 1) */
|
||||||
|
colSpan?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FieldGroup {
|
interface FieldGroup {
|
||||||
@@ -107,7 +109,8 @@ const F: Record<string, FieldConfig> = {
|
|||||||
artist: { key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
|
artist: { key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
|
||||||
tax_cat: { key: "tax_cat", label: "Tax Cat", type: "combobox", optionsKey: "taxCategories" },
|
tax_cat: { key: "tax_cat", label: "Tax Cat", type: "combobox", optionsKey: "taxCategories" },
|
||||||
ship: { key: "ship_restrictions", label: "Shipping", type: "combobox", optionsKey: "shippingRestrictions" },
|
ship: { key: "ship_restrictions", label: "Shipping", type: "combobox", optionsKey: "shippingRestrictions" },
|
||||||
msrp: { key: "msrp", label: "MSRP", type: "input" },
|
msrp: { key: "msrp", label: "MSRP", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
||||||
|
cur_price: { key: "current_price", label: "Current", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
||||||
cost: { key: "cost_each", label: "Cost", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
cost: { key: "cost_each", label: "Cost", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
||||||
min_qty: { key: "qty_per_unit", label: "Min Qty", type: "input" },
|
min_qty: { key: "qty_per_unit", label: "Min Qty", type: "input" },
|
||||||
case_qty: { key: "case_qty", label: "Case Pack", type: "input" },
|
case_qty: { key: "case_qty", label: "Case Pack", type: "input" },
|
||||||
@@ -145,7 +148,7 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
|
|||||||
{ label: "Taxonomy", cols: 2, fields: [F.supplier,F.company, F.line, F.subline] },
|
{ label: "Taxonomy", cols: 2, fields: [F.supplier,F.company, F.line, F.subline] },
|
||||||
{ cols: 2, fields: [F.artist, F.size_cat] },
|
{ cols: 2, fields: [F.artist, F.size_cat] },
|
||||||
{ label: "Description", cols: 1, fields: [F.description] },
|
{ label: "Description", cols: 1, fields: [F.description] },
|
||||||
{ label: "Pricing", cols: 4, fields: [F.msrp, F.cost, F.min_qty, F.case_qty] },
|
{ label: "Pricing", cols: 5, fields: [F.msrp, F.cur_price, F.cost, F.min_qty, F.case_qty] },
|
||||||
{ label: "Dimensions", cols: 4, fields: [F.weight, F.length, F.width, F.height] },
|
{ label: "Dimensions", cols: 4, fields: [F.weight, F.length, F.width, F.height] },
|
||||||
{ cols: 4, fields: [ F.tax_cat, F.ship,F.coo, F.hts_code] },
|
{ cols: 4, fields: [ F.tax_cat, F.ship,F.coo, F.hts_code] },
|
||||||
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
||||||
@@ -156,22 +159,21 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
|
|||||||
sidebarGroups: 3,
|
sidebarGroups: 3,
|
||||||
descriptionRows: 8,
|
descriptionRows: 8,
|
||||||
groups: [
|
groups: [
|
||||||
{ label: "Taxonomy", cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
|
{ label: "Taxonomy", cols: 7, fields: [{ ...F.company, colSpan: 3 }, { ...F.msrp, colSpan: 2 }, { ...F.cur_price, colSpan: 2 }] },
|
||||||
{ cols: 2, fields: [F.artist, F.size_cat] },
|
{ cols: 2, fields: [F.line, F.subline, F.artist, F.size_cat] },
|
||||||
{ label: "Description", cols: 1, fields: [F.description] },
|
{ label: "Description", cols: 1, fields: [F.description] },
|
||||||
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
backend: {
|
backend: {
|
||||||
sidebarGroups: 5,
|
sidebarGroups: 6,
|
||||||
groups: [
|
groups: [
|
||||||
{ label: "Pricing", cols: 2, fields: [F.supplier, F.min_qty, F.cost, F.msrp] },
|
{ label: "Pricing", cols: 2, fields: [F.supplier, F.min_qty] },
|
||||||
|
{ cols: 3, fields: [F.cost, F.cur_price, F.msrp] },
|
||||||
{ cols: 3, fields: [F.case_qty, F.size_cat, F.weight] },
|
{ cols: 3, fields: [F.case_qty, F.size_cat, F.weight] },
|
||||||
{ label: "Dimensions", cols: 3, fields: [ F.length, F.width, F.height] },
|
{ label: "Dimensions", cols: 3, fields: [F.length, F.width, F.height] },
|
||||||
{ cols: 2, fields: [F.tax_cat, F.ship, F.coo, F.hts_code] },
|
{ cols: 2, fields: [F.tax_cat, F.ship, F.coo, F.hts_code] },
|
||||||
{ label: "Notes", cols: 1, fields: [F.priv_notes] },
|
{ label: "Notes", cols: 1, fields: [F.priv_notes] },
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
minimal: {
|
minimal: {
|
||||||
@@ -233,6 +235,7 @@ export function ProductEditForm({
|
|||||||
supplier_no: product.vendor_reference ?? "",
|
supplier_no: product.vendor_reference ?? "",
|
||||||
notions_no: product.notions_reference ?? "",
|
notions_no: product.notions_reference ?? "",
|
||||||
msrp: String(product.regular_price ?? ""),
|
msrp: String(product.regular_price ?? ""),
|
||||||
|
current_price: String(product.price ?? ""),
|
||||||
cost_each: String(product.cost_price ?? ""),
|
cost_each: String(product.cost_price ?? ""),
|
||||||
qty_per_unit: String(product.moq ?? ""),
|
qty_per_unit: String(product.moq ?? ""),
|
||||||
case_qty: String(product.case_qty ?? ""),
|
case_qty: String(product.case_qty ?? ""),
|
||||||
@@ -324,12 +327,13 @@ export function ProductEditForm({
|
|||||||
const originalIds = original.map((img) => img.iid);
|
const originalIds = original.map((img) => img.iid);
|
||||||
const currentIds = current.map((img) => img.iid);
|
const currentIds = current.map((img) => img.iid);
|
||||||
|
|
||||||
const deleted = originalIds.filter((id) => !currentIds.includes(id)) as number[];
|
const toDelete = originalIds.filter((id) => !currentIds.includes(id)) as number[];
|
||||||
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
||||||
const added: Record<string, string> = {};
|
const show = current.filter((img) => !img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
||||||
|
const add: Record<string, string> = {};
|
||||||
for (const img of current) {
|
for (const img of current) {
|
||||||
if (img.isNew && img.imageUrl) {
|
if (img.isNew && img.imageUrl) {
|
||||||
added[String(img.iid)] = img.imageUrl;
|
add[String(img.iid)] = img.imageUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,14 +342,14 @@ export function ProductEditForm({
|
|||||||
const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid);
|
const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid);
|
||||||
const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds);
|
const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds);
|
||||||
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
|
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
|
||||||
const hasDeleted = deleted.length > 0;
|
const hasDeleted = toDelete.length > 0;
|
||||||
const hasAdded = Object.keys(added).length > 0;
|
const hasAdded = Object.keys(add).length > 0;
|
||||||
|
|
||||||
if (!orderChanged && !hiddenChanged && !hasDeleted && !hasAdded) {
|
if (!orderChanged && !hiddenChanged && !hasDeleted && !hasAdded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { order, hidden, deleted, added };
|
return { order, hidden, show, delete: toDelete, add };
|
||||||
}, [productImages]);
|
}, [productImages]);
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
@@ -369,32 +373,57 @@ export function ProductEditForm({
|
|||||||
|
|
||||||
const imageChanges = computeImageChanges();
|
const imageChanges = computeImageChanges();
|
||||||
|
|
||||||
if (Object.keys(changes).length === 0 && !imageChanges) {
|
// Extract taxonomy changes for separate API calls
|
||||||
|
const taxonomyCalls: { type: "cats" | "themes" | "colors"; ids: number[] }[] = [];
|
||||||
|
if ("categories" in changes) {
|
||||||
|
taxonomyCalls.push({ type: "cats", ids: (changes.categories as string[]).map(Number) });
|
||||||
|
delete changes.categories;
|
||||||
|
}
|
||||||
|
if ("themes" in changes) {
|
||||||
|
taxonomyCalls.push({ type: "themes", ids: (changes.themes as string[]).map(Number) });
|
||||||
|
delete changes.themes;
|
||||||
|
}
|
||||||
|
if ("colors" in changes) {
|
||||||
|
taxonomyCalls.push({ type: "colors", ids: (changes.colors as string[]).map(Number) });
|
||||||
|
delete changes.colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFieldChanges = Object.keys(changes).length > 0;
|
||||||
|
|
||||||
|
if (!hasFieldChanges && !imageChanges && taxonomyCalls.length === 0) {
|
||||||
toast.info("No changes to submit");
|
toast.info("No changes to submit");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await submitProductEdit({
|
const promises: Promise<{ success: boolean; error?: unknown; message?: string }>[] = [];
|
||||||
pid: product.pid,
|
|
||||||
changes,
|
|
||||||
environment: "prod",
|
|
||||||
imageChanges: imageChanges ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
if (hasFieldChanges) {
|
||||||
|
promises.push(submitProductEdit({ pid: product.pid, changes, environment: "prod" }));
|
||||||
|
}
|
||||||
|
if (imageChanges) {
|
||||||
|
promises.push(submitImageChanges({ pid: product.pid, imageChanges, environment: "prod" }));
|
||||||
|
}
|
||||||
|
for (const { type, ids } of taxonomyCalls) {
|
||||||
|
promises.push(submitTaxonomySet({ pid: product.pid, type, ids, environment: "prod" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
const failed = results.find((r) => !r.success);
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
const errorDetail = Array.isArray(failed.error)
|
||||||
|
? failed.error.filter((e) => e !== "Errors").join("; ")
|
||||||
|
: typeof failed.error === "string"
|
||||||
|
? failed.error
|
||||||
|
: null;
|
||||||
|
toast.error(errorDetail || failed.message || "Failed to update product");
|
||||||
|
} else {
|
||||||
toast.success("Product updated successfully");
|
toast.success("Product updated successfully");
|
||||||
originalValuesRef.current = { ...data };
|
originalValuesRef.current = { ...data };
|
||||||
originalImagesRef.current = [...productImages];
|
originalImagesRef.current = [...productImages];
|
||||||
reset(data);
|
reset(data);
|
||||||
} else {
|
|
||||||
const errorDetail = Array.isArray(result.error)
|
|
||||||
? result.error.filter((e) => e !== "Errors").join("; ")
|
|
||||||
: typeof result.error === "string"
|
|
||||||
? result.error
|
|
||||||
: null;
|
|
||||||
toast.error(errorDetail || result.message || "Failed to update product");
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -484,8 +513,13 @@ export function ProductEditForm({
|
|||||||
style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
|
style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
|
||||||
>
|
>
|
||||||
{group.fields.map((fc) => {
|
{group.fields.map((fc) => {
|
||||||
|
const wrapSpan = (node: React.ReactNode) =>
|
||||||
|
fc.colSpan && fc.colSpan > 1
|
||||||
|
? <div key={fc.key} style={{ gridColumn: `span ${fc.colSpan}` }}>{node}</div>
|
||||||
|
: node;
|
||||||
|
|
||||||
if (fc.type === "input") {
|
if (fc.type === "input") {
|
||||||
return (
|
return wrapSpan(
|
||||||
<Controller
|
<Controller
|
||||||
key={fc.key}
|
key={fc.key}
|
||||||
name={fc.key}
|
name={fc.key}
|
||||||
@@ -506,7 +540,7 @@ export function ProductEditForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (fc.type === "combobox") {
|
if (fc.type === "combobox") {
|
||||||
return (
|
return wrapSpan(
|
||||||
<Controller
|
<Controller
|
||||||
key={fc.key}
|
key={fc.key}
|
||||||
name={fc.key}
|
name={fc.key}
|
||||||
@@ -526,7 +560,7 @@ export function ProductEditForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (fc.type === "multiselect") {
|
if (fc.type === "multiselect") {
|
||||||
return (
|
return wrapSpan(
|
||||||
<Controller
|
<Controller
|
||||||
key={fc.key}
|
key={fc.key}
|
||||||
name={fc.key}
|
name={fc.key}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export interface ProductFormValues {
|
|||||||
supplier_no: string;
|
supplier_no: string;
|
||||||
notions_no: string;
|
notions_no: string;
|
||||||
msrp: string;
|
msrp: string;
|
||||||
|
current_price: string;
|
||||||
cost_each: string;
|
cost_each: string;
|
||||||
qty_per_unit: string;
|
qty_per_unit: string;
|
||||||
case_qty: string;
|
case_qty: string;
|
||||||
|
|||||||
908
inventory/src/pages/BulkEdit.tsx
Normal file
908
inventory/src/pages/BulkEdit.tsx
Normal file
@@ -0,0 +1,908 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, Sparkles, Save } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationEllipsis,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import { ProductSearch } from "@/components/product-editor/ProductSearch";
|
||||||
|
import {
|
||||||
|
BulkEditRow,
|
||||||
|
FIELD_OPTIONS,
|
||||||
|
AI_FIELDS,
|
||||||
|
INITIAL_ROW_STATE,
|
||||||
|
getFieldValue,
|
||||||
|
getSubmitFieldKey,
|
||||||
|
type BulkEditFieldChoice,
|
||||||
|
type RowAiState,
|
||||||
|
} from "@/components/bulk-edit/BulkEditRow";
|
||||||
|
import { submitProductEdit } from "@/services/productEditor";
|
||||||
|
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
|
||||||
|
|
||||||
|
const PER_PAGE = 20;
|
||||||
|
const PROD_IMG_HOST = "https://sbing.com";
|
||||||
|
|
||||||
|
/** Strip all HTML tags for use in plain text contexts */
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BulkEdit() {
|
||||||
|
// Product loading state (mirrors ProductEditor)
|
||||||
|
const [allProducts, setAllProducts] = useState<SearchProduct[]>([]);
|
||||||
|
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(null);
|
||||||
|
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
|
||||||
|
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||||
|
const [page, _setPage] = useState(1);
|
||||||
|
const topRef = useRef<HTMLDivElement>(null);
|
||||||
|
const setPage = useCallback((v: number | ((p: number) => number)) => {
|
||||||
|
_setPage(v);
|
||||||
|
setTimeout(() => topRef.current?.scrollIntoView({ behavior: "smooth" }), 0);
|
||||||
|
}, []);
|
||||||
|
const [activeTab, setActiveTab] = useState("new");
|
||||||
|
const [loadedTab, setLoadedTab] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Line picker state
|
||||||
|
const [lineCompany, setLineCompany] = useState<string>("");
|
||||||
|
const [lineLine, setLineLine] = useState<string>("");
|
||||||
|
const [lineSubline, setLineSubline] = useState<string>("");
|
||||||
|
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
||||||
|
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
|
||||||
|
const [isLoadingLines, setIsLoadingLines] = useState(false);
|
||||||
|
const [isLoadingSublines, setIsLoadingSublines] = useState(false);
|
||||||
|
|
||||||
|
// Landing extras state
|
||||||
|
const [landingExtras, setLandingExtras] = useState<Record<string, LandingExtra[]>>({});
|
||||||
|
const [isLoadingExtras, setIsLoadingExtras] = useState(false);
|
||||||
|
const [activeLandingItem, setActiveLandingItem] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Abort controller
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Bulk edit state
|
||||||
|
const [selectedField, setSelectedField] = useState<BulkEditFieldChoice>("description");
|
||||||
|
const [aiStates, setAiStates] = useState<Map<number, RowAiState>>(new Map());
|
||||||
|
const [productImages, setProductImages] = useState<Map<number, string | null>>(new Map());
|
||||||
|
|
||||||
|
// Validation progress
|
||||||
|
const [validationProgress, setValidationProgress] = useState<{
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Save progress
|
||||||
|
const [saveProgress, setSaveProgress] = useState<{
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const isAiField = AI_FIELDS.includes(selectedField);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(allProducts.length / PER_PAGE);
|
||||||
|
const pageProducts = useMemo(
|
||||||
|
() => allProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE),
|
||||||
|
[allProducts, page]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get select options for the current field
|
||||||
|
const currentFieldSelectOptions = useMemo((): FieldOption[] | undefined => {
|
||||||
|
if (!fieldOptions) return undefined;
|
||||||
|
switch (selectedField) {
|
||||||
|
case "tax_cat": return fieldOptions.taxCategories;
|
||||||
|
case "size_cat": return fieldOptions.sizes;
|
||||||
|
case "ship_restrictions": return fieldOptions.shippingRestrictions;
|
||||||
|
default: return undefined;
|
||||||
|
}
|
||||||
|
}, [fieldOptions, selectedField]);
|
||||||
|
|
||||||
|
// Load field options on mount (but don't auto-load products)
|
||||||
|
useEffect(() => {
|
||||||
|
axios
|
||||||
|
.get("/api/import/field-options")
|
||||||
|
.then((res) => setFieldOptions(res.data))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to load field options:", err);
|
||||||
|
toast.error("Failed to load field options");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoadingOptions(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load lines when company changes
|
||||||
|
useEffect(() => {
|
||||||
|
setLineLine("");
|
||||||
|
setLineSubline("");
|
||||||
|
setLineOptions([]);
|
||||||
|
setSublineOptions([]);
|
||||||
|
if (!lineCompany) return;
|
||||||
|
setIsLoadingLines(true);
|
||||||
|
axios
|
||||||
|
.get(`/api/import/product-lines/${lineCompany}`)
|
||||||
|
.then((res) => setLineOptions(res.data))
|
||||||
|
.catch(() => setLineOptions([]))
|
||||||
|
.finally(() => setIsLoadingLines(false));
|
||||||
|
}, [lineCompany]);
|
||||||
|
|
||||||
|
// Load sublines when line changes
|
||||||
|
useEffect(() => {
|
||||||
|
setLineSubline("");
|
||||||
|
setSublineOptions([]);
|
||||||
|
if (!lineLine) return;
|
||||||
|
setIsLoadingSublines(true);
|
||||||
|
axios
|
||||||
|
.get(`/api/import/sublines/${lineLine}`)
|
||||||
|
.then((res) => setSublineOptions(res.data))
|
||||||
|
.catch(() => setSublineOptions([]))
|
||||||
|
.finally(() => setIsLoadingSublines(false));
|
||||||
|
}, [lineLine]);
|
||||||
|
|
||||||
|
const loadedPids = useMemo(
|
||||||
|
() => new Set(allProducts.map((p) => Number(p.pid))),
|
||||||
|
[allProducts]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Product loading (same patterns as ProductEditor) ──
|
||||||
|
|
||||||
|
const handleSearchSelect = useCallback((product: SearchProduct) => {
|
||||||
|
setAllProducts((prev) => {
|
||||||
|
if (prev.some((p) => p.pid === product.pid)) return prev;
|
||||||
|
return [product, ...prev];
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNewSearch = useCallback(() => {
|
||||||
|
setAllProducts([]);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoadAllSearch = useCallback(async (pids: number[]) => {
|
||||||
|
const hadExisting = allProducts.length > 0;
|
||||||
|
setIsLoadingProducts(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get("/api/import/search-products", {
|
||||||
|
params: { pid: pids.join(",") },
|
||||||
|
});
|
||||||
|
const fetched = res.data as SearchProduct[];
|
||||||
|
setAllProducts((prev) => {
|
||||||
|
const existingPids = new Set(prev.map((p) => p.pid));
|
||||||
|
const newProducts = fetched.filter((p) => !existingPids.has(p.pid));
|
||||||
|
return [...prev, ...newProducts];
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
if (fetched.length > 1) {
|
||||||
|
toast.success(
|
||||||
|
hadExisting
|
||||||
|
? `Loaded remaining ${fetched.length} products`
|
||||||
|
: "Loaded all products"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load products");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProducts(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadFeedProducts = useCallback(async (endpoint: string, label: string) => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setAllProducts([]);
|
||||||
|
setIsLoadingProducts(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/import/${endpoint}`, { signal: controller.signal });
|
||||||
|
setAllProducts(res.data);
|
||||||
|
setPage(1);
|
||||||
|
toast.success(`Loaded ${res.data.length} ${label} products`);
|
||||||
|
} catch (e) {
|
||||||
|
if (!axios.isCancel(e)) toast.error(`Failed to load ${label} products`);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProducts(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadLandingExtras = useCallback(async (catId: number, tabKey: string) => {
|
||||||
|
if (landingExtras[tabKey]) return;
|
||||||
|
setIsLoadingExtras(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get("/api/import/landing-extras", {
|
||||||
|
params: { catId, sid: 0 },
|
||||||
|
});
|
||||||
|
setLandingExtras((prev) => ({ ...prev, [tabKey]: res.data }));
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to load landing extras");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingExtras(false);
|
||||||
|
}
|
||||||
|
}, [landingExtras]);
|
||||||
|
|
||||||
|
const handleLandingClick = useCallback(async (extra: LandingExtra) => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setActiveLandingItem(extra.path);
|
||||||
|
setAllProducts([]);
|
||||||
|
setIsLoadingProducts(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get("/api/import/path-products", {
|
||||||
|
params: { path: extra.path },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
setAllProducts(res.data);
|
||||||
|
setPage(1);
|
||||||
|
toast.success(`Loaded ${res.data.length} products for ${stripHtml(extra.name)}`);
|
||||||
|
} catch (e) {
|
||||||
|
if (!axios.isCancel(e)) toast.error("Failed to load products for " + stripHtml(extra.name));
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProducts(false);
|
||||||
|
setActiveLandingItem(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback((tab: string) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
if (tab === "new" && loadedTab !== "new") {
|
||||||
|
setLoadedTab("new");
|
||||||
|
loadFeedProducts("new-products", "new");
|
||||||
|
loadLandingExtras(-2, "new");
|
||||||
|
} else if (tab === "preorder" && loadedTab !== "preorder") {
|
||||||
|
setLoadedTab("preorder");
|
||||||
|
loadFeedProducts("preorder-products", "pre-order");
|
||||||
|
loadLandingExtras(-16, "preorder");
|
||||||
|
} else if (tab === "hidden" && loadedTab !== "hidden") {
|
||||||
|
setLoadedTab("hidden");
|
||||||
|
loadFeedProducts("hidden-new-products", "hidden");
|
||||||
|
} else if (tab === "search" || tab === "by-line") {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setAllProducts([]);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [loadedTab, loadFeedProducts, loadLandingExtras]);
|
||||||
|
|
||||||
|
const loadLineProducts = useCallback(async () => {
|
||||||
|
if (!lineCompany || !lineLine) return;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setAllProducts([]);
|
||||||
|
setIsLoadingProducts(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { company: lineCompany, line: lineLine };
|
||||||
|
if (lineSubline) params.subline = lineSubline;
|
||||||
|
const res = await axios.get("/api/import/line-products", { params, signal: controller.signal });
|
||||||
|
setAllProducts(res.data);
|
||||||
|
setPage(1);
|
||||||
|
toast.success(`Loaded ${res.data.length} products`);
|
||||||
|
} catch (e) {
|
||||||
|
if (!axios.isCancel(e)) toast.error("Failed to load line products");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProducts(false);
|
||||||
|
}
|
||||||
|
}, [lineCompany, lineLine, lineSubline]);
|
||||||
|
|
||||||
|
// ── Image loading ──
|
||||||
|
|
||||||
|
// Load first image for current page products
|
||||||
|
useEffect(() => {
|
||||||
|
const pidsNeedingImages = pageProducts
|
||||||
|
.filter((p) => !productImages.has(p.pid))
|
||||||
|
.map((p) => p.pid);
|
||||||
|
|
||||||
|
if (pidsNeedingImages.length === 0) return;
|
||||||
|
|
||||||
|
pidsNeedingImages.forEach((pid) => {
|
||||||
|
axios
|
||||||
|
.get(`/api/import/product-images/${pid}`)
|
||||||
|
.then((res) => {
|
||||||
|
const images = res.data;
|
||||||
|
let url: string | null = null;
|
||||||
|
if (Array.isArray(images) && images.length > 0) {
|
||||||
|
// Get smallest size for thumbnail
|
||||||
|
const first = images[0];
|
||||||
|
const sizes = first.sizes || {};
|
||||||
|
const smallKey = Object.keys(sizes).find((k) => k.includes("175") || k.includes("small"));
|
||||||
|
const anyKey = Object.keys(sizes)[0];
|
||||||
|
const chosen = sizes[smallKey ?? anyKey];
|
||||||
|
url = chosen?.url ?? null;
|
||||||
|
}
|
||||||
|
setProductImages((prev) => new Map(prev).set(pid, url));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setProductImages((prev) => new Map(prev).set(pid, null));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [pageProducts, productImages]);
|
||||||
|
|
||||||
|
// ── AI Validation ──
|
||||||
|
|
||||||
|
const triggerValidation = useCallback(
|
||||||
|
(products: SearchProduct[]) => {
|
||||||
|
if (!isAiField) return;
|
||||||
|
|
||||||
|
const total = products.length;
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
// Mark all as validating
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
products.forEach((p) => {
|
||||||
|
const existing = next.get(p.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(p.pid, { ...existing, status: "validating" });
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationProgress({ done: 0, total });
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
selectedField === "name"
|
||||||
|
? "/api/ai/validate/inline/name"
|
||||||
|
: "/api/ai/validate/inline/description";
|
||||||
|
|
||||||
|
// Fire all requests at once
|
||||||
|
products.forEach(async (product) => {
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (selectedField === "name") {
|
||||||
|
payload.name = product.title;
|
||||||
|
payload.company_name = product.brand;
|
||||||
|
payload.company_id = product.brand_id;
|
||||||
|
payload.line_name = product.line;
|
||||||
|
payload.subline_name = product.subline;
|
||||||
|
// Gather sibling names from products in same brand + line
|
||||||
|
const siblings = allProducts
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.pid !== product.pid &&
|
||||||
|
p.brand_id === product.brand_id &&
|
||||||
|
p.line_id === product.line_id
|
||||||
|
)
|
||||||
|
.map((p) => p.title)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (siblings.length > 0) payload.siblingNames = siblings;
|
||||||
|
} else {
|
||||||
|
payload.name = product.title;
|
||||||
|
payload.description = product.description ?? "";
|
||||||
|
payload.company_name = product.brand;
|
||||||
|
payload.company_id = product.brand_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ product: payload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(product.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(product.pid, {
|
||||||
|
...existing,
|
||||||
|
status: "done",
|
||||||
|
result: {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion || null,
|
||||||
|
issues: result.issues || [],
|
||||||
|
},
|
||||||
|
editedSuggestion: result.suggestion || null,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Validation error for PID ${product.pid}:`, err);
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(product.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(product.pid, {
|
||||||
|
...existing,
|
||||||
|
status: "done",
|
||||||
|
result: { isValid: true, issues: [] },
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
done++;
|
||||||
|
setValidationProgress((prev) =>
|
||||||
|
prev ? { ...prev, done } : null
|
||||||
|
);
|
||||||
|
if (done >= total) {
|
||||||
|
// Clear progress after a short delay
|
||||||
|
setTimeout(() => setValidationProgress(null), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[selectedField, isAiField, allProducts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleValidateAll = useCallback(() => {
|
||||||
|
if (!isAiField) return;
|
||||||
|
triggerValidation(pageProducts);
|
||||||
|
}, [isAiField, pageProducts, triggerValidation]);
|
||||||
|
|
||||||
|
// ── Row actions ──
|
||||||
|
|
||||||
|
const handleAccept = useCallback((pid: number, value: string) => {
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(pid, {
|
||||||
|
...existing,
|
||||||
|
decision: "accepted",
|
||||||
|
editedSuggestion: value,
|
||||||
|
manualEdit: value,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback((pid: number) => {
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(pid, { ...existing, decision: "dismissed" });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleManualEdit = useCallback((pid: number, value: string) => {
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(pid, { ...existing, manualEdit: value });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditSuggestion = useCallback((pid: number, value: string) => {
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(pid, { ...existing, editedSuggestion: value });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Save ──
|
||||||
|
|
||||||
|
const getChangedRows = useCallback((): { pid: number; value: string }[] => {
|
||||||
|
const changed: { pid: number; value: string }[] = [];
|
||||||
|
for (const product of allProducts) {
|
||||||
|
const state = aiStates.get(product.pid);
|
||||||
|
if (!state) continue;
|
||||||
|
|
||||||
|
// Accepted AI suggestion
|
||||||
|
if (state.decision === "accepted" && state.editedSuggestion != null) {
|
||||||
|
if (state.saveStatus !== "saved") {
|
||||||
|
changed.push({ pid: product.pid, value: state.editedSuggestion });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual edit (non-AI fields or user-modified field)
|
||||||
|
if (state.manualEdit != null) {
|
||||||
|
const original = getFieldValue(product, selectedField);
|
||||||
|
if (state.manualEdit !== original && state.saveStatus !== "saved") {
|
||||||
|
changed.push({ pid: product.pid, value: state.manualEdit });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}, [allProducts, aiStates, selectedField]);
|
||||||
|
|
||||||
|
const changedCount = useMemo(() => getChangedRows().length, [getChangedRows]);
|
||||||
|
|
||||||
|
const handleSaveAll = useCallback(async () => {
|
||||||
|
const rows = getChangedRows();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
toast.info("No changes to save");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitKey = getSubmitFieldKey(selectedField);
|
||||||
|
let done = 0;
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
setSaveProgress({ done: 0, total: rows.length });
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// Mark as saving
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(row.pid, { ...existing, saveStatus: "saving", saveError: null });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await submitProductEdit({
|
||||||
|
pid: row.pid,
|
||||||
|
changes: { [submitKey]: row.value },
|
||||||
|
environment: "prod",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(row.pid, { ...existing, saveStatus: "saved" });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
const errorMsg = result.message || "Save failed";
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(row.pid, { ...existing, saveStatus: "error", saveError: errorMsg });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorCount++;
|
||||||
|
const errorMsg = err instanceof Error ? err.message : "Save failed";
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(row.pid, { ...existing, saveStatus: "error", saveError: errorMsg });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
done++;
|
||||||
|
setSaveProgress({ done, total: rows.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => setSaveProgress(null), 500);
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
toast.success(`Saved ${successCount} product${successCount === 1 ? "" : "s"}`);
|
||||||
|
} else {
|
||||||
|
toast.error(`Saved ${successCount}, failed ${errorCount}`);
|
||||||
|
}
|
||||||
|
}, [getChangedRows, selectedField]);
|
||||||
|
|
||||||
|
// ── Clear AI states when field changes ──
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback((field: BulkEditFieldChoice) => {
|
||||||
|
setSelectedField(field);
|
||||||
|
setAiStates(new Map());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Landing extras render ──
|
||||||
|
|
||||||
|
const renderLandingExtras = (tabKey: string) => {
|
||||||
|
const extras = landingExtras[tabKey];
|
||||||
|
if (!extras || extras.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2 items-start">
|
||||||
|
{extras.map((extra) => (
|
||||||
|
<button
|
||||||
|
key={extra.extra_id}
|
||||||
|
onClick={() => handleLandingClick(extra)}
|
||||||
|
disabled={activeLandingItem === extra.path}
|
||||||
|
className="flex-shrink-0 group relative w-28 text-left"
|
||||||
|
>
|
||||||
|
<div className="aspect-square w-full overflow-hidden rounded-lg border bg-card hover:bg-accent transition-colors relative">
|
||||||
|
{extra.image && (
|
||||||
|
<img
|
||||||
|
src={extra.image.startsWith("/") ? PROD_IMG_HOST + extra.image : extra.image}
|
||||||
|
alt={stripHtml(extra.name)}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeLandingItem === extra.path && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-background/60">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pt-1 text-center">
|
||||||
|
{(() => {
|
||||||
|
const parts = extra.name.split(/<br\s*\/?>/i).map(stripHtml);
|
||||||
|
return (
|
||||||
|
<div className="text-xs leading-snug">
|
||||||
|
{parts[0] && <span className="font-semibold">{parts[0]}</span>}
|
||||||
|
{parts[1] && <><br /><span className="font-normal">{parts[1]}</span></>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pagination ──
|
||||||
|
|
||||||
|
const renderPagination = () => {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const pages: (number | "ellipsis")[] = [];
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
if (page > 3) pages.push("ellipsis");
|
||||||
|
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
if (page < totalPages - 2) pages.push("ellipsis");
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
className={page === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
{getPageNumbers().map((p, i) =>
|
||||||
|
p === "ellipsis" ? (
|
||||||
|
<PaginationItem key={`e${i}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem key={p}>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={p === page}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
className={page === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingOptions) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-6 max-w-5xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Bulk Edit</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">Field:</span>
|
||||||
|
<Select value={selectedField} onValueChange={(v) => handleFieldChange(v as BulkEditFieldChoice)}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FIELD_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{opt.label}
|
||||||
|
{opt.ai && <Sparkles className="h-3 w-3 text-purple-500" />}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{isAiField && (
|
||||||
|
<Button
|
||||||
|
onClick={handleValidateAll}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={pageProducts.length === 0 || validationProgress !== null}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 mr-1" />
|
||||||
|
Validate Page
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
size="sm"
|
||||||
|
disabled={changedCount === 0 || saveProgress !== null}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4 mr-1" />
|
||||||
|
Save{changedCount > 0 ? ` (${changedCount})` : " All"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product loading tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="new">New</TabsTrigger>
|
||||||
|
<TabsTrigger value="preorder">Pre-Order</TabsTrigger>
|
||||||
|
<TabsTrigger value="hidden">Hidden (New)</TabsTrigger>
|
||||||
|
<TabsTrigger value="by-line">By Line</TabsTrigger>
|
||||||
|
<TabsTrigger value="search">Search</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="search" className="mt-4">
|
||||||
|
<ProductSearch
|
||||||
|
onSelect={handleSearchSelect}
|
||||||
|
onLoadAll={handleLoadAllSearch}
|
||||||
|
onNewSearch={handleNewSearch}
|
||||||
|
loadedPids={loadedPids}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="new" className="mt-4">
|
||||||
|
{isLoadingExtras && !landingExtras["new"] && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading featured lines...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderLandingExtras("new")}
|
||||||
|
{isLoadingProducts && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading new products...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="preorder" className="mt-4">
|
||||||
|
{isLoadingExtras && !landingExtras["preorder"] && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading featured lines...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderLandingExtras("preorder")}
|
||||||
|
{isLoadingProducts && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading pre-order products...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="hidden" className="mt-4">
|
||||||
|
{isLoadingProducts && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading hidden recently-created products...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="by-line" className="mt-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={lineCompany} onValueChange={setLineCompany}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue placeholder="Select company..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fieldOptions?.companies.map((c) => (
|
||||||
|
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={lineLine} onValueChange={setLineLine} disabled={!lineCompany || isLoadingLines}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue placeholder={isLoadingLines ? "Loading..." : "Select line..."} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{lineOptions.map((l) => (
|
||||||
|
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{sublineOptions.length > 0 && (
|
||||||
|
<Select value={lineSubline} onValueChange={setLineSubline} disabled={isLoadingSublines}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue placeholder={isLoadingSublines ? "Loading..." : "All sublines"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sublineOptions.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<Button onClick={loadLineProducts} disabled={!lineLine || isLoadingProducts}>
|
||||||
|
{isLoadingProducts && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Progress bars */}
|
||||||
|
{validationProgress && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Validating...</span>
|
||||||
|
<span>
|
||||||
|
{validationProgress.done} / {validationProgress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(validationProgress.done / validationProgress.total) * 100}
|
||||||
|
className="h-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveProgress && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Saving...</span>
|
||||||
|
<span>
|
||||||
|
{saveProgress.done} / {saveProgress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(saveProgress.done / saveProgress.total) * 100}
|
||||||
|
className="h-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={topRef} />
|
||||||
|
{renderPagination()}
|
||||||
|
|
||||||
|
{/* Product rows */}
|
||||||
|
{pageProducts.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pageProducts.map((product) => (
|
||||||
|
<BulkEditRow
|
||||||
|
key={product.pid}
|
||||||
|
product={product}
|
||||||
|
field={selectedField}
|
||||||
|
state={aiStates.get(product.pid) ?? INITIAL_ROW_STATE}
|
||||||
|
imageUrl={productImages.get(product.pid) ?? null}
|
||||||
|
selectOptions={currentFieldSelectOptions}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onManualEdit={handleManualEdit}
|
||||||
|
onEditSuggestion={handleEditSuggestion}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderPagination()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
export interface ImageChanges {
|
export interface ImageChanges {
|
||||||
order: (number | string)[];
|
order: (number | string)[];
|
||||||
hidden: number[];
|
hidden: number[];
|
||||||
deleted: number[];
|
show: number[];
|
||||||
added: Record<string, string>; // e.g. { "new-0": "https://..." }
|
delete: number[];
|
||||||
|
add: Record<string, string>; // e.g. { "new-0": "https://..." }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitProductEditArgs {
|
export interface SubmitProductEditArgs {
|
||||||
pid: number;
|
pid: number;
|
||||||
changes: Record<string, unknown>;
|
changes: Record<string, unknown>;
|
||||||
environment: "dev" | "prod";
|
environment: "dev" | "prod";
|
||||||
imageChanges?: ImageChanges;
|
}
|
||||||
|
|
||||||
|
export interface SubmitImageChangesArgs {
|
||||||
|
pid: number;
|
||||||
|
imageChanges: ImageChanges;
|
||||||
|
environment: "dev" | "prod";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitProductEditResponse {
|
export interface SubmitProductEditResponse {
|
||||||
@@ -31,14 +37,10 @@ export async function submitProductEdit({
|
|||||||
pid,
|
pid,
|
||||||
changes,
|
changes,
|
||||||
environment,
|
environment,
|
||||||
imageChanges,
|
|
||||||
}: SubmitProductEditArgs): Promise<SubmitProductEditResponse> {
|
}: SubmitProductEditArgs): Promise<SubmitProductEditResponse> {
|
||||||
const targetUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
|
const targetUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
|
||||||
|
|
||||||
const product: Record<string, unknown> = { pid, ...changes };
|
const product: Record<string, unknown> = { pid, ...changes };
|
||||||
if (imageChanges) {
|
|
||||||
product.image_changes = imageChanges;
|
|
||||||
}
|
|
||||||
const payload = new URLSearchParams();
|
const payload = new URLSearchParams();
|
||||||
payload.append("products", JSON.stringify([product]));
|
payload.append("products", JSON.stringify([product]));
|
||||||
|
|
||||||
@@ -96,3 +98,138 @@ export async function submitProductEdit({
|
|||||||
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TaxonomyType = "cats" | "themes" | "colors";
|
||||||
|
|
||||||
|
export interface SubmitTaxonomySetArgs {
|
||||||
|
pid: number;
|
||||||
|
type: TaxonomyType;
|
||||||
|
ids: number[];
|
||||||
|
environment: "dev" | "prod";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitTaxonomySet({
|
||||||
|
pid,
|
||||||
|
type,
|
||||||
|
ids,
|
||||||
|
environment,
|
||||||
|
}: SubmitTaxonomySetArgs): Promise<SubmitProductEditResponse> {
|
||||||
|
const base = environment === "dev" ? "/apiv2-test" : "/apiv2";
|
||||||
|
const targetUrl = `${base}/product/${type}/${pid}/set`;
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(ids),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (environment === "dev") {
|
||||||
|
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
|
||||||
|
if (authToken) {
|
||||||
|
fetchOptions.body = JSON.stringify({ ids, auth: authToken });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fetchOptions.credentials = "include";
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(targetUrl, fetchOptions);
|
||||||
|
} 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 parsedResponse = parsed as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
success: Boolean(parsedResponse.success),
|
||||||
|
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
|
||||||
|
data: parsedResponse.data,
|
||||||
|
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEV_IMAGE_ENDPOINT = "/apiv2-test/product/image_changes";
|
||||||
|
const PROD_IMAGE_ENDPOINT = "/apiv2/product/image_changes";
|
||||||
|
|
||||||
|
export async function submitImageChanges({
|
||||||
|
pid,
|
||||||
|
imageChanges,
|
||||||
|
environment,
|
||||||
|
}: SubmitImageChangesArgs): Promise<SubmitProductEditResponse> {
|
||||||
|
const targetUrl = environment === "dev" ? DEV_IMAGE_ENDPOINT : PROD_IMAGE_ENDPOINT;
|
||||||
|
|
||||||
|
const body = { pid, image_changes: imageChanges };
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (environment === "dev") {
|
||||||
|
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
|
||||||
|
if (authToken) {
|
||||||
|
(body as Record<string, unknown>).auth = authToken;
|
||||||
|
fetchOptions.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fetchOptions.credentials = "include";
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(targetUrl, fetchOptions);
|
||||||
|
} 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 parsedResponse = parsed as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
success: Boolean(parsedResponse.success),
|
||||||
|
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
|
||||||
|
data: parsedResponse.data,
|
||||||
|
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user