Nueva pestana "Inventario" en el sidebar del Admin (src/admin/routes/ inventario), pensada para un usuario no tecnico: una fila por producto (catalogo real confirmado 1 variante por producto), switch para activar seguimiento de stock, input de cantidad que guarda solo, badge de stock bajo (<=5 unidades) y activacion/desactivacion en bloque por seleccion. No reemplaza la vista nativa de Inventory de Medusa, es un atajo. Backend: src/api/admin/inventory-panel/route.ts + workflow propio src/workflows/attach-inventory-item-to-variant.ts para vincular un InventoryItem nuevo a una variante existente (Medusa no lo hace solo al togglear manage_inventory en un producto ya creado -- confirmado en vivo contra un producto [DEMO] antes de escribir el codigo). Nombres de campos y workflows verificados contra los .d.ts reales instalados en el pod (no solo contra la documentacion, que no cubre bien este flujo de vincular inventario a una variante existente).
195 lines
6.8 KiB
TypeScript
195 lines
6.8 KiB
TypeScript
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
|
import { Buildings } from "@medusajs/icons";
|
|
import { Badge, Button, Checkbox, Container, Heading, Input, Switch, Text, toast } from "@medusajs/ui";
|
|
import { useEffect, useState } from "react";
|
|
import { sdk } from "../../lib/sdk";
|
|
|
|
// Panel simplificado para alguien no tecnico (la esposa del dueno del
|
|
// negocio) -- una fila por producto (este catalogo es 1 variante por
|
|
// producto, confirmado en vivo), switch para activar seguimiento de
|
|
// stock y un input de cantidad que guarda solo. La vista nativa de
|
|
// Medusa Admin (Inventory) sigue disponible para el dueno, esto no la
|
|
// reemplaza, es un atajo.
|
|
|
|
const LOW_STOCK_THRESHOLD = 5;
|
|
|
|
type Row = {
|
|
productId: string;
|
|
variantId: string | null;
|
|
title: string;
|
|
thumbnail: string | null;
|
|
sku: string | null;
|
|
managed: boolean;
|
|
quantity: number;
|
|
};
|
|
|
|
async function fetchRows(): Promise<Row[]> {
|
|
const data = await sdk.client.fetch<{ rows: Row[] }>("/admin/inventory-panel");
|
|
return data.rows;
|
|
}
|
|
|
|
async function saveRow(row: { productId: string; variantId: string; managed?: boolean; quantity?: number }) {
|
|
await sdk.client.fetch("/admin/inventory-panel", { method: "post", body: row });
|
|
}
|
|
|
|
const InventoryPanel = () => {
|
|
const [rows, setRows] = useState<Row[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [savingId, setSavingId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchRows().then((data) => {
|
|
setRows(data);
|
|
setLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
function toggleSelected(productId: string, checked: boolean) {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (checked) next.add(productId);
|
|
else next.delete(productId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function updateManaged(row: Row, managed: boolean) {
|
|
if (!row.variantId) return;
|
|
setSavingId(row.productId);
|
|
try {
|
|
await saveRow({ productId: row.productId, variantId: row.variantId, managed });
|
|
setRows((prev) =>
|
|
prev.map((r) => (r.productId === row.productId ? { ...r, managed } : r)),
|
|
);
|
|
} catch {
|
|
toast.error("No se pudo guardar", { description: row.title });
|
|
} finally {
|
|
setSavingId(null);
|
|
}
|
|
}
|
|
|
|
async function updateQuantity(row: Row, quantity: number) {
|
|
if (!row.variantId || Number.isNaN(quantity) || quantity < 0) return;
|
|
setSavingId(row.productId);
|
|
try {
|
|
await saveRow({ productId: row.productId, variantId: row.variantId, quantity });
|
|
setRows((prev) =>
|
|
prev.map((r) => (r.productId === row.productId ? { ...r, quantity } : r)),
|
|
);
|
|
toast.success("Stock actualizado", { description: row.title });
|
|
} catch {
|
|
toast.error("No se pudo guardar", { description: row.title });
|
|
} finally {
|
|
setSavingId(null);
|
|
}
|
|
}
|
|
|
|
async function bulkSetManaged(managed: boolean) {
|
|
const targets = rows.filter((r) => selected.has(r.productId) && r.variantId);
|
|
for (const row of targets) {
|
|
await updateManaged(row, managed);
|
|
}
|
|
setSelected(new Set());
|
|
}
|
|
|
|
return (
|
|
<Container className="divide-y p-0">
|
|
<div className="flex items-center justify-between px-6 py-4">
|
|
<div>
|
|
<Heading level="h2">Inventario</Heading>
|
|
<Text size="small" className="text-ui-fg-subtle">
|
|
Activa el seguimiento de stock y ajusta la cantidad disponible por producto.
|
|
</Text>
|
|
</div>
|
|
{selected.size > 0 && (
|
|
<div className="flex gap-2">
|
|
<Button size="small" variant="secondary" onClick={() => bulkSetManaged(true)}>
|
|
Activar seguimiento ({selected.size})
|
|
</Button>
|
|
<Button size="small" variant="secondary" onClick={() => bulkSetManaged(false)}>
|
|
Desactivar seguimiento
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="px-6 py-8">
|
|
<Text className="text-ui-fg-subtle">Cargando…</Text>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-left text-ui-fg-subtle">
|
|
<th className="w-10 px-6 py-3" />
|
|
<th className="px-3 py-3 font-medium">Producto</th>
|
|
<th className="px-3 py-3 font-medium">SKU</th>
|
|
<th className="px-3 py-3 font-medium">Trackear stock</th>
|
|
<th className="px-3 py-3 font-medium">Cantidad disponible</th>
|
|
<th className="px-3 py-3 font-medium" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr key={row.productId} className="border-b last:border-0">
|
|
<td className="px-6 py-3">
|
|
<Checkbox
|
|
checked={selected.has(row.productId)}
|
|
onCheckedChange={(checked) => toggleSelected(row.productId, checked === true)}
|
|
/>
|
|
</td>
|
|
<td className="flex items-center gap-3 px-3 py-3">
|
|
{row.thumbnail ? (
|
|
<img src={row.thumbnail} alt="" className="size-8 rounded object-cover" />
|
|
) : (
|
|
<div className="size-8 rounded bg-ui-bg-subtle" />
|
|
)}
|
|
<Text>{row.title}</Text>
|
|
</td>
|
|
<td className="px-3 py-3">
|
|
<Text className="text-ui-fg-subtle">{row.sku ?? "—"}</Text>
|
|
</td>
|
|
<td className="px-3 py-3">
|
|
<Switch
|
|
checked={row.managed}
|
|
disabled={savingId === row.productId}
|
|
onCheckedChange={(checked) => updateManaged(row, checked)}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-3">
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
defaultValue={row.quantity}
|
|
disabled={!row.managed || savingId === row.productId}
|
|
className="w-24"
|
|
onBlur={(event) => {
|
|
const value = Number(event.target.value);
|
|
if (value !== row.quantity) updateQuantity(row, value);
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-3">
|
|
{row.managed && row.quantity <= LOW_STOCK_THRESHOLD && (
|
|
<Badge color="red">Stock bajo</Badge>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export const config = defineRouteConfig({
|
|
label: "Inventario",
|
|
icon: Buildings,
|
|
});
|
|
|
|
export default InventoryPanel;
|