|
|
@@ -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>
|