Compare commits

...
Author SHA1 Message Date
devops ce4842f375 fix(commerce-backend): corregir tipo de callback en location_levels.find
TS2769: la anotacion explicita {location_id: string} no calzaba con el
tipo Maybe<InventoryLevel> que infiere query.graph para location_levels.
Se quita la anotacion y se deja inferir, con optional chaining.
2026-08-29 18:59:48 +00:00
devops 2cce671049 Merge branch 'feat/inventory-panel' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 32s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 4m52s
2026-08-29 17:56:51 +00:00
devops 22f97ee12e 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).
2026-08-29 17:56:44 +00:00
devops 42c90d8534 chore: release v1.0.216 [skip ci] 2026-08-29 17:10:35 +00:00
devops cd8d2edd8f Merge branch 'feat/google-oauth-frontend' into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m39s
2026-08-29 17:01:39 +00:00
gitea-actions 7ce11ecbb4 chore(gitops): deploy Medusa v1.0.215 [skip ci] 2026-08-29 16:34:34 +00:00
6 changed files with 379 additions and 3 deletions

No files matched your search

@@ -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" },
});
@@ -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<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;
@@ -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<string | null> {
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(
(candidate) => candidate?.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<PanelUpdateBody>, 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(
(candidate) => candidate?.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 });
}
@@ -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 },
]),
);
},
);
@@ -65,7 +65,7 @@ spec:
memory: 64Mi
- name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.215
imagePullPolicy: IfNotPresent
command:
- node
@@ -86,7 +86,7 @@ spec:
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.215
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.213
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.216
env:
- name: HEADLESS_PROVIDER
value: medusa