Nueva pagina "Importacion" en Medusa Admin para cargar varios productos de una sola vez (comprados en Amazon/eBay/etc, no dropshipping automatizado -- se evaluo y descarto raspar catalogos de terceros sin autorizacion por riesgo de copyright/ToS). src/api/admin/product-import/route.ts: GET sirve la plantilla CSV descargable, POST procesa fila por fila (una foto rota o categoria mal escrita no tumba el resto del archivo). Cada foto se descarga del origen y se re-aloja en el mismo MinIO/S3 del NAS que ya usa el catalogo (prefijo import-products/, separado de ari-products/), se crea el registro de inventario con la cantidad indicada y el producto queda vinculado con el mismo patron "Presentacion"/"Estandar" que usa el resto del catalogo -- el storefront no necesita cambios. src/lib/csv.ts: parser CSV propio (RFC4180), sin sumar dependencia nueva. Workflows y servicios de modulo (uploadFilesWorkflow, createInventoryItemsWorkflow, createProductsWorkflow, stock-location/ fulfillment/sales-channel/product module services) verificados contra los .d.ts reales instalados, no solo contra documentacion.
146 lines
5.2 KiB
TypeScript
146 lines
5.2 KiB
TypeScript
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
|
import { ArrowDownTray, ArrowUpTray, CheckCircleSolid, XCircleSolid } from "@medusajs/icons";
|
|
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
|
|
import { useRef, useState } from "react";
|
|
import { sdk } from "../../lib/sdk";
|
|
|
|
// Importador masivo via CSV -- pensado para cargar varios productos de
|
|
// una sola vez (comprados en Amazon/eBay/etc, no dropshipping
|
|
// automatizado -- ver conversacion sobre por que se descarto raspar
|
|
// tiendas de terceros). Las fotos se descargan del origen y se re-alojan
|
|
// en el mismo MinIO/S3 del NAS que ya usa el resto del catalogo.
|
|
|
|
type RowResult = {
|
|
row: number;
|
|
title: string;
|
|
ok: boolean;
|
|
error?: string;
|
|
};
|
|
|
|
function downloadTemplate() {
|
|
window.open("/admin/product-import", "_blank");
|
|
}
|
|
|
|
const ProductImportPanel = () => {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [results, setResults] = useState<RowResult[] | null>(null);
|
|
|
|
async function handleFile(file: File) {
|
|
const csv = await file.text();
|
|
setLoading(true);
|
|
setResults(null);
|
|
try {
|
|
const data = await sdk.client.fetch<{ results?: RowResult[]; error?: string }>("/admin/product-import", {
|
|
method: "post",
|
|
body: { csv },
|
|
});
|
|
if (data.error) {
|
|
toast.error("No se pudo procesar el archivo", { description: data.error });
|
|
return;
|
|
}
|
|
setResults(data.results ?? []);
|
|
const okCount = data.results?.filter((r) => r.ok).length ?? 0;
|
|
const failCount = (data.results?.length ?? 0) - okCount;
|
|
if (failCount === 0) {
|
|
toast.success(`${okCount} productos importados`);
|
|
} else {
|
|
toast.warning(`${okCount} importados, ${failCount} con error`, {
|
|
description: "Revisa el detalle abajo.",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast.error("No se pudo procesar el archivo", { description: (error as Error).message });
|
|
} finally {
|
|
setLoading(false);
|
|
if (inputRef.current) inputRef.current.value = "";
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Container className="divide-y p-0">
|
|
<div className="flex items-center justify-between px-6 py-4">
|
|
<div>
|
|
<Heading level="h2">Importación de productos</Heading>
|
|
<Text size="small" className="text-ui-fg-subtle">
|
|
Carga varios productos a la vez desde un archivo CSV, con fotos, descripción y precio.
|
|
</Text>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button size="small" variant="secondary" onClick={downloadTemplate}>
|
|
<ArrowDownTray />
|
|
Descargar plantilla
|
|
</Button>
|
|
<Button size="small" variant="primary" isLoading={loading} onClick={() => inputRef.current?.click()}>
|
|
<ArrowUpTray />
|
|
Subir CSV
|
|
</Button>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept=".csv,text/csv"
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) handleFile(file);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-6 py-4">
|
|
<Text size="small" className="text-ui-fg-subtle">
|
|
Columnas: <code>titulo, descripcion, precio, categoria, coleccion, cantidad, fotos</code>. Varias fotos se
|
|
separan con <code>;</code> dentro de la misma celda. <code>categoria</code>/<code>coleccion</code> deben
|
|
escribirse igual que en la tienda o se dejan vacías. Cada foto se descarga y se guarda en el almacenamiento
|
|
propio de la tienda (no queda enlazada al sitio de origen).
|
|
</Text>
|
|
</div>
|
|
|
|
{results && (
|
|
<div className="px-6 py-4">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-left text-ui-fg-subtle">
|
|
<th className="w-10 py-2" />
|
|
<th className="py-2 font-medium">Fila</th>
|
|
<th className="py-2 font-medium">Producto</th>
|
|
<th className="py-2 font-medium">Detalle</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{results.map((result) => (
|
|
<tr key={result.row} className="border-b last:border-0">
|
|
<td className="py-2">
|
|
{result.ok ? (
|
|
<CheckCircleSolid className="text-ui-tag-green-icon" />
|
|
) : (
|
|
<XCircleSolid className="text-ui-tag-red-icon" />
|
|
)}
|
|
</td>
|
|
<td className="py-2">{result.row}</td>
|
|
<td className="py-2">{result.title}</td>
|
|
<td className="py-2">
|
|
{result.ok ? (
|
|
<Badge color="green">Importado</Badge>
|
|
) : (
|
|
<span className="text-rose-600">{result.error}</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export const config = defineRouteConfig({
|
|
label: "Importación",
|
|
icon: ArrowUpTray,
|
|
});
|
|
|
|
export default ProductImportPanel;
|