Просмотр исходного кода

Add import progress bar and client-side batching for product catalog upload

- Rewrite handleImportProducts as async with 500-row client batching
- Add importProgress state with current/total/status/error tracking
- Show progress bar UI with percentage and batch counter during import
- Disable Cancel/Import buttons while import is running
- Column mapping for Homelegance Excel: Model, Description, Collection, Price, Features, Dimensions, URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tony T 2 дней назад
Родитель
Сommit
8c7ace82e0
1 измененных файлов с 82 добавлено и 22 удалено
  1. 82 22
      client/src/pages/DataSources.tsx

+ 82 - 22
client/src/pages/DataSources.tsx

@@ -58,6 +58,7 @@ export default function DataSources() {
   const [csvText, setCsvText] = useState("");
   const [productCsvText, setProductCsvText] = useState("");
   const [replaceAllProducts, setReplaceAllProducts] = useState(false);
+  const [importProgress, setImportProgress] = useState<{ current: number; total: number; status: "idle" | "running" | "done" | "error"; error?: string }>({ current: 0, total: 0, status: "idle" });
   const [promoteForm, setPromoteForm] = useState({ answer: "", category: "" });
 
   const utils = trpc.useUtils();
@@ -188,7 +189,7 @@ export default function DataSources() {
     importEntries.mutate({ entries: items, source: "csv" });
   };
 
-  const handleImportProducts = () => {
+  const handleImportProducts = async () => {
     const rows = parseCSV(productCsvText);
     if (rows.length === 0) { toast.error("No CSV rows found"); return; }
     const first = rows[0].map(c => c.trim().toLowerCase());
@@ -196,23 +197,29 @@ export default function DataSources() {
     const dataRows = hasHeader ? rows.slice(1) : rows;
     const idx = (name: string) => first.indexOf(name.trim().toLowerCase());
 
-    // ── Homelegance Excel column mapping ──────────────────────────────
+    // ── Homelegance Excel column mapping (with fallbacks for generic CSV) ──
     const col = {
-      model:          idx("model"),
-      description:    idx("description"),
-      longDesc:       idx("long description"),
-      collection:     idx("collection name"),
-      price:          idx("unit price"),
-      promoPrice:     idx("promo price"),
-      availability:   idx("availability"),
-      setupSize:      idx("setup size"),
-      url:            idx("url"),
-      // Feature 1–10
-      features:       Array.from({ length: 10 }, (_, i) => idx(`feature ${i + 1}`)),
-      // Additional Dimension 1–10
-      dimensions:     Array.from({ length: 10 }, (_, i) => idx(`additional dimension ${i + 1}`)),
+      model:        idx("model"),
+      description:  idx("description"),
+      longDesc:     idx("long description"),
+      collection:   idx("collection name") >= 0 ? idx("collection name") : idx("collection"),
+      price:        idx("unit price")  >= 0 ? idx("unit price")  : idx("price"),
+      promoPrice:   idx("promo price"),
+      availability: idx("availability"),
+      setupSize:    idx("setup size"),
+      url:          idx("url"),
+      features:     Array.from({ length: 10 }, (_, i) => idx(`feature ${i + 1}`)),
+      dimensions:   Array.from({ length: 10 }, (_, i) => idx(`additional dimension ${i + 1}`)),
     };
 
+    // Debug: show detected columns
+    const detected = Object.entries(col)
+      .filter(([, v]) => !Array.isArray(v))
+      .filter(([, v]) => (v as number) >= 0)
+      .map(([k]) => k).join(", ");
+    console.info("[ImportProducts] Detected columns:", detected);
+    console.info("[ImportProducts] Header row:", first.slice(0, 15).join(", "));
+
     const get = (r: string[], i: number) => (i >= 0 ? r[i]?.trim() || "" : "");
     const joinCols = (r: string[], indices: number[]) =>
       indices.map(i => get(r, i)).filter(Boolean).join(" | ") || undefined;
@@ -222,8 +229,8 @@ export default function DataSources() {
       .map(r => ({
         model:        get(r, col.model),
         description:  get(r, col.description) || get(r, col.longDesc) || undefined,
-        collection:   get(r, col.collection) || undefined,
-        price:        get(r, col.promoPrice) || get(r, col.price) || undefined,
+        collection:   get(r, col.collection)  || undefined,
+        price:        get(r, col.promoPrice)  || get(r, col.price) || undefined,
         availability: get(r, col.availability) || undefined,
         features:     joinCols(r, col.features),
         dimensions:   [get(r, col.setupSize), ...col.dimensions.map(i => get(r, i))]
@@ -231,8 +238,37 @@ export default function DataSources() {
         imageUrl:     get(r, col.url) || undefined,
       }));
 
-    if (items.length === 0) { toast.error("No valid product rows"); return; }
-    importProducts.mutate({ products: items, replaceAll: replaceAllProducts });
+    if (items.length === 0) {
+      toast.error(`No valid product rows — model column ${col.model >= 0 ? "found at col " + col.model : "NOT FOUND"}. Header: ${first.slice(0, 5).join(", ")}`);
+      return;
+    }
+
+    // ── Client-side batching with progress ─────────────────────────────
+    const BATCH = 500;
+    const batches = Math.ceil(items.length / BATCH);
+    setImportProgress({ current: 0, total: batches, status: "running" });
+
+    try {
+      for (let i = 0; i < batches; i++) {
+        const chunk = items.slice(i * BATCH, (i + 1) * BATCH);
+        await importProducts.mutateAsync({
+          products: chunk,
+          replaceAll: i === 0 ? replaceAllProducts : false, // only delete on first batch
+        });
+        setImportProgress({ current: i + 1, total: batches, status: i + 1 < batches ? "running" : "done" });
+      }
+      toast.success(`Imported ${items.length} products in ${batches} batch${batches > 1 ? "es" : ""}`);
+      utils.knowledge.listProducts.invalidate();
+      setTimeout(() => {
+        setShowImportProducts(false);
+        setProductCsvText("");
+        setReplaceAllProducts(false);
+        setImportProgress({ current: 0, total: 0, status: "idle" });
+      }, 1500);
+    } catch (err: any) {
+      setImportProgress(prev => ({ ...prev, status: "error", error: err?.message || "Unknown error" }));
+      toast.error(`Import failed at batch ${importProgress.current + 1}: ${err?.message || "Unknown error"}`);
+    }
   };
 
   const handleFileUpload = (
@@ -489,10 +525,34 @@ export default function DataSources() {
                   <input type="checkbox" checked={replaceAllProducts} onChange={(e) => setReplaceAllProducts(e.target.checked)} />
                   Replace All (delete existing products before import)
                 </label>
+
+                {/* Progress bar */}
+                {importProgress.status !== "idle" && (
+                  <div className="mt-3 p-3 rounded-lg border text-xs" style={{ borderColor: "#e7e0d5", background: "#f9f6f0" }}>
+                    <div className="flex justify-between mb-1" style={{ color: "#78716C" }}>
+                      <span>
+                        {importProgress.status === "running" && `Importing batch ${importProgress.current + 1} of ${importProgress.total}…`}
+                        {importProgress.status === "done"    && `✅ All ${importProgress.total} batches imported successfully`}
+                        {importProgress.status === "error"   && `❌ Error at batch ${importProgress.current + 1}: ${importProgress.error}`}
+                      </span>
+                      <span>{importProgress.total > 0 ? Math.round((importProgress.current / importProgress.total) * 100) : 0}%</span>
+                    </div>
+                    <div className="w-full rounded-full h-2" style={{ background: "#e7e0d5" }}>
+                      <div
+                        className="h-2 rounded-full transition-all"
+                        style={{
+                          width: `${importProgress.total > 0 ? (importProgress.current / importProgress.total) * 100 : 0}%`,
+                          background: importProgress.status === "error" ? "#dc2626" : "#059669",
+                        }}
+                      />
+                    </div>
+                  </div>
+                )}
+
                 <div className="flex gap-2 justify-end mt-3">
-                  <Button variant="outline" size="sm" onClick={() => { setShowImportProducts(false); setProductCsvText(""); setReplaceAllProducts(false); }}>Cancel</Button>
-                  <Button size="sm" className="text-white" style={{ background: "#059669" }} onClick={handleImportProducts} disabled={!productCsvText.trim() || importProducts.isPending}>
-                    {importProducts.isPending ? "Importing..." : "Import"}
+                  <Button variant="outline" size="sm" onClick={() => { setShowImportProducts(false); setProductCsvText(""); setReplaceAllProducts(false); setImportProgress({ current: 0, total: 0, status: "idle" }); }} disabled={importProgress.status === "running"}>Cancel</Button>
+                  <Button size="sm" className="text-white" style={{ background: "#059669" }} onClick={handleImportProducts} disabled={!productCsvText.trim() || importProgress.status === "running"}>
+                    {importProgress.status === "running" ? `Batch ${importProgress.current + 1}/${importProgress.total}…` : "Import"}
                   </Button>
                 </div>
               </div>