From 22f97ee12ecbe757ef774306ec5f2c56d9aed25b Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sat, 29 Aug 2026 17:56:44 +0000 Subject: [PATCH] feat(commerce-backend): panel simplificado de inventario en Medusa Admin 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). --- .../commerce-backend/src/admin/lib/sdk.ts | 9 + .../src/admin/routes/inventario/page.tsx | 194 ++++++++++++++++++ .../src/api/admin/inventory-panel/route.ts | 152 ++++++++++++++ .../attach-inventory-item-to-variant.ts | 21 ++ 4 files changed, 376 insertions(+) create mode 100644 workloads/commerce-backend/src/admin/lib/sdk.ts create mode 100644 workloads/commerce-backend/src/admin/routes/inventario/page.tsx create mode 100644 workloads/commerce-backend/src/api/admin/inventory-panel/route.ts create mode 100644 workloads/commerce-backend/src/workflows/attach-inventory-item-to-variant.ts diff --git a/workloads/commerce-backend/src/admin/lib/sdk.ts b/workloads/commerce-backend/src/admin/lib/sdk.ts new file mode 100644 index 0000000..ddf0f67 --- /dev/null +++ b/workloads/commerce-backend/src/admin/lib/sdk.ts @@ -0,0 +1,9 @@ +import Medusa from "@medusajs/js-sdk"; + +// baseUrl "/" -- esta pagina se sirve dentro del mismo dashboard admin +// (mismo origen que la API), no hace falta apuntar a otra URL. auth +// "session" reusa la cookie de sesion ya autenticada del dashboard. +export const sdk = new Medusa({ + baseUrl: "/", + auth: { type: "session" }, +}); diff --git a/workloads/commerce-backend/src/admin/routes/inventario/page.tsx b/workloads/commerce-backend/src/admin/routes/inventario/page.tsx new file mode 100644 index 0000000..443dafa --- /dev/null +++ b/workloads/commerce-backend/src/admin/routes/inventario/page.tsx @@ -0,0 +1,194 @@ +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 { + 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([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState>(new Set()); + const [savingId, setSavingId] = useState(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 ( + +
+
+ Inventario + + Activa el seguimiento de stock y ajusta la cantidad disponible por producto. + +
+ {selected.size > 0 && ( +
+ + +
+ )} +
+ + {loading ? ( +
+ Cargando… +
+ ) : ( +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
+ ProductoSKUTrackear stockCantidad disponible +
+ toggleSelected(row.productId, checked === true)} + /> + + {row.thumbnail ? ( + + ) : ( +
+ )} + {row.title} +
+ {row.sku ?? "—"} + + updateManaged(row, checked)} + /> + + { + const value = Number(event.target.value); + if (value !== row.quantity) updateQuantity(row, value); + }} + /> + + {row.managed && row.quantity <= LOW_STOCK_THRESHOLD && ( + Stock bajo + )} +
+
+ )} +
+ ); +}; + +export const config = defineRouteConfig({ + label: "Inventario", + icon: Buildings, +}); + +export default InventoryPanel; diff --git a/workloads/commerce-backend/src/api/admin/inventory-panel/route.ts b/workloads/commerce-backend/src/api/admin/inventory-panel/route.ts new file mode 100644 index 0000000..1f50c4e --- /dev/null +++ b/workloads/commerce-backend/src/api/admin/inventory-panel/route.ts @@ -0,0 +1,152 @@ +import type { MedusaRequest, MedusaResponse } from "@medusajs/framework"; +import { ContainerRegistrationKeys } from "@medusajs/framework/utils"; +import { + createInventoryItemsWorkflow, + updateInventoryLevelsWorkflow, + updateProductVariantsWorkflow, +} from "@medusajs/medusa/core-flows"; +import { attachInventoryItemToVariantWorkflow } from "../../../workflows/attach-inventory-item-to-variant"; + +// Panel simplificado de inventario (pensado para un usuario no tecnico, +// no reemplaza la vista nativa de Medusa Admin -- ver ADR/memoria del +// bug de Firefox Android, tema aparte). Un producto = una variante en +// este catalogo real (confirmado en vivo contra /store/products antes +// de asumirlo), asi que la tabla trabaja a nivel de producto. + +const PRODUCT_FIELDS = [ + "id", + "title", + "thumbnail", + "status", + "variants.id", + "variants.sku", + "variants.manage_inventory", + "variants.inventory_items.inventory_item_id", + "variants.inventory_items.inventory.location_levels.id", + "variants.inventory_items.inventory.location_levels.location_id", + "variants.inventory_items.inventory.location_levels.stocked_quantity", +]; + +async function defaultLocationId(req: MedusaRequest): Promise { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY); + const { data: locations = [] } = await query.graph({ + entity: "stock_location", + fields: ["id"], + }); + return locations[0]?.id ?? null; +} + +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY); + const locationId = await defaultLocationId(req); + + const { data: products = [] } = await query.graph({ + entity: "product", + fields: PRODUCT_FIELDS, + }); + + const rows = products.map((product) => { + const variant = product.variants?.[0]; + const inventoryItem = variant?.inventory_items?.[0]; + const level = inventoryItem?.inventory?.location_levels?.find( + (level: { location_id: string }) => level.location_id === locationId, + ); + return { + productId: product.id, + variantId: variant?.id ?? null, + title: product.title, + thumbnail: product.thumbnail, + status: product.status, + sku: variant?.sku ?? null, + managed: variant?.manage_inventory ?? false, + quantity: level?.stocked_quantity ?? 0, + }; + }); + + res.json({ rows, locationId }); +} + +type PanelUpdateBody = { + productId: string; + variantId: string; + managed?: boolean; + quantity?: number; +}; + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { productId, variantId, managed, quantity } = req.body; + if (!productId || !variantId) { + res.status(400).json({ error: "productId y variantId son obligatorios" }); + return; + } + + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY); + const locationId = await defaultLocationId(req); + if (!locationId) { + res.status(400).json({ error: "No hay una bodega (stock location) configurada" }); + return; + } + + const { data: products = [] } = await query.graph({ + entity: "product", + fields: PRODUCT_FIELDS, + filters: { id: productId }, + }); + const variant = products[0]?.variants?.find((v: { id: string }) => v.id === variantId); + const inventoryItem = variant?.inventory_items?.[0]; + let inventoryItemId: string | undefined = inventoryItem?.inventory_item_id; + + if (managed === true && !inventoryItemId) { + // Primera vez que se activa el seguimiento para esta variante -- no + // trae InventoryItem todavia (confirmado en vivo: togglear + // manage_inventory=true en una variante existente NO crea uno solo, + // a diferencia de crear un producto nuevo con manage_inventory + // desde el inicio). Se crea con el nivel de stock inicial y se + // vincula aparte. + const { result: items } = await createInventoryItemsWorkflow(req.scope).run({ + input: { + items: [ + { + title: variant?.sku ? undefined : products[0]?.title, + sku: variant?.sku ?? undefined, + location_levels: [{ location_id: locationId, stocked_quantity: quantity ?? 0 }], + }, + ], + }, + }); + inventoryItemId = items[0]?.id; + if (inventoryItemId) { + await attachInventoryItemToVariantWorkflow(req.scope).run({ + input: { inventoryItemId, variantId }, + }); + } + await updateProductVariantsWorkflow(req.scope).run({ + input: { product_variants: [{ id: variantId, manage_inventory: true }] }, + }); + } else { + if (typeof managed === "boolean") { + await updateProductVariantsWorkflow(req.scope).run({ + input: { product_variants: [{ id: variantId, manage_inventory: managed }] }, + }); + } + if (typeof quantity === "number" && inventoryItemId) { + const level = inventoryItem?.inventory?.location_levels?.find( + (level: { location_id: string }) => level.location_id === locationId, + ); + await updateInventoryLevelsWorkflow(req.scope).run({ + input: { + updates: [ + { + id: level?.id, + inventory_item_id: inventoryItemId, + location_id: locationId, + stocked_quantity: quantity, + }, + ], + }, + }); + } + } + + res.json({ ok: true }); +} diff --git a/workloads/commerce-backend/src/workflows/attach-inventory-item-to-variant.ts b/workloads/commerce-backend/src/workflows/attach-inventory-item-to-variant.ts new file mode 100644 index 0000000..7ff05d4 --- /dev/null +++ b/workloads/commerce-backend/src/workflows/attach-inventory-item-to-variant.ts @@ -0,0 +1,21 @@ +import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"; +import { attachInventoryItemToVariants } from "@medusajs/medusa/core-flows"; + +// attachInventoryItemToVariants es un step suelto (no un workflow +// exportado) -- confirmado leyendo el .js real instalado +// (@medusajs/core-flows/dist/inventory/steps/attach-inventory-items.js): +// internamente resuelve ContainerRegistrationKeys.LINK y crea el link +// Product<->Inventory. El campo "tag" del input es el variant_id (nombre +// generico del step, no documentado con ese detalle). +type Input = { inventoryItemId: string; variantId: string }; + +export const attachInventoryItemToVariantWorkflow = createWorkflow( + "attach-inventory-item-to-variant", + (input: Input) => { + return new WorkflowResponse( + attachInventoryItemToVariants([ + { inventoryItemId: input.inventoryItemId, tag: input.variantId }, + ]), + ); + }, +);