HextaUI

Inventory

A stock table where merchants edit quantities in place and save adjustments. Handles live orders, edits from another device and oversold or untracked stock.

Open in new tab

Inventory is the stock table for one location. Each row is a variant with its thumbnail, title and SKU. The columns follow Shopify: Unavailable, Committed, Available, On hand and Incoming. On hand is always Available plus Committed plus Unavailable, so editing one of them moves the other by the same amount.

Click a quantity, press Enter or start typing to open the editor. Adjust by adds or removes stock and Set to enters a count, with a live preview such as 42 → 47. The arrow keys step by 1, or by 10 with Shift. Enter keeps the change in a draft and moves down a row, and Escape cancels. Edited cells get a dot and show their delta, and a save bar collects the batch with one reason, such as Received or Cycle count.

Saving sends adjustments, not overwrites. Each one carries its delta, the quantity it was based on and the result, so an order placed while you were typing isn’t lost. A row the server rejects stays in the draft with its message, such as “Quantity changed to 40 by another device”, and Reapply sends it again on top of the new quantity.

When new items arrive, changed numbers roll digit by digit and flash briefly. Rows don’t re-sort while you’re working in the table. The toolbar has search by title or SKU, Low stock, Out of stock and Incoming filters, sorting by Available, and bulk Set to 0 or Adjust by for selected rows. Loading, an empty location, no results, untracked variants, oversold stock and an all-locations read-only view are covered too, and every row has its adjustment history in a sheet.

  1. Obtén HextaUI Pro

    Un solo pago desbloquea todos los bloques, incluidos los futuros. Ver precios.

  2. Añade el registro Pro a components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Añade tu token

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  4. Añade el bloque

    pnpm dlx shadcn@latest add @hextaui-pro/inventory

Save to Shopify

Send each batch to inventoryAdjustQuantities with the delta and changeFromQuantity. Shopify rejects a change when the quantity moved since you loaded it, and those user errors come back as failed rows that the merchant can reapply.

"use client"

import { InventoryTable } from "@/components/blocks/inventory/inventory-table"
import type {
  InventoryAdjustment,
  InventoryItem,
  InventoryReason,
  InventorySaveResult,
} from "@/components/blocks/inventory/inventory"

const reasons: Record<InventoryReason, string> = {
  correction: "correction",
  received: "received",
  damaged: "damaged",
  theft_or_loss: "theft_or_loss",
  returned: "restock",
  cycle_count: "cycle_count_available",
}

const mutation = `
  mutation Adjust($input: InventoryAdjustQuantitiesInput!) {
    inventoryAdjustQuantities(input: $input) {
      userErrors { field message }
    }
  }
`

async function adjustOnShopify(
  locationId: string,
  adjustments: InventoryAdjustment[]
): Promise<InventorySaveResult> {
  const failed: InventorySaveResult["failed"] = []
  const groups = new Map<InventoryReason, InventoryAdjustment[]>()
  for (const adjustment of adjustments) {
    groups.set(adjustment.reason, [...(groups.get(adjustment.reason) ?? []), adjustment])
  }
  for (const [reason, group] of groups) {
    const response = await fetch("/api/shopify/graphql", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        query: mutation,
        variables: {
          input: {
            name: "available",
            reason: reasons[reason],
            changes: group.map((adjustment) => ({
              inventoryItemId: adjustment.itemId,
              locationId,
              delta: adjustment.delta,
              changeFromQuantity: adjustment.compareQuantity,
            })),
          },
        },
      }),
    })
    const { data } = await response.json()
    const errors: { field: string[] | null; message: string }[] =
      data.inventoryAdjustQuantities.userErrors
    for (const error of errors) {
      const index = Number(error.field?.[2])
      const itemId = group[index]?.itemId
      if (itemId) failed.push({ itemId, message: error.message })
    }
  }
  return { failed }
}

export function ShopifyInventory({
  items,
  locationId,
  locationName,
  refresh,
}: {
  items: InventoryItem[]
  locationId: string
  locationName: string
  refresh: () => Promise<void>
}) {
  return (
    <InventoryTable
      items={items}
      locationName={locationName}
      onSave={async (adjustments) => {
        const result = await adjustOnShopify(locationId, adjustments)
        await refresh()
        return result
      }}
    />
  )
}

Medusa inventory levels

Map Medusa location levels to items: stocked minus reserved is Available, and reserved is Committed. Each adjustment posts the new stocked quantity for that inventory item and location.

"use client"

import * as React from "react"

import { InventoryTable } from "@/components/blocks/inventory/inventory-table"
import type { InventoryItem } from "@/components/blocks/inventory/inventory"

type MedusaLevel = {
  inventory_item_id: string
  location_id: string
  stocked_quantity: number
  reserved_quantity: number
  incoming_quantity: number
}

type MedusaVariant = {
  id: string
  title: string
  sku: string | null
  manage_inventory: boolean
  product: { title: string; thumbnail: string | null }
  inventory_items: { inventory_item_id: string }[]
}

function toItem(variant: MedusaVariant, level: MedusaLevel | undefined): InventoryItem {
  const stocked = level?.stocked_quantity ?? 0
  const reserved = level?.reserved_quantity ?? 0
  return {
    id: variant.inventory_items[0]?.inventory_item_id ?? variant.id,
    productTitle: variant.product.title,
    variantTitle: variant.title,
    sku: variant.sku ?? undefined,
    image: variant.product.thumbnail ? { src: variant.product.thumbnail } : undefined,
    tracked: variant.manage_inventory,
    available: stocked - reserved,
    committed: reserved,
    incoming: level?.incoming_quantity
      ? [{ id: "incoming", quantity: level.incoming_quantity }]
      : [],
  }
}

export function MedusaInventory({
  variants,
  levels,
  locationId,
}: {
  variants: MedusaVariant[]
  levels: MedusaLevel[]
  locationId: string
}) {
  const [current, setCurrent] = React.useState(levels)
  const items = variants.map((variant) =>
    toItem(
      variant,
      current.find(
        (level) =>
          level.location_id === locationId &&
          level.inventory_item_id === variant.inventory_items[0]?.inventory_item_id
      )
    )
  )

  return (
    <InventoryTable
      items={items}
      onSave={async (adjustments) => {
        const failed = []
        for (const adjustment of adjustments) {
          const response = await fetch(
            `/admin/inventory-items/${adjustment.itemId}/location-levels/${locationId}`,
            {
              method: "POST",
              credentials: "include",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ stocked_quantity: adjustment.compareQuantity + adjustment.delta }),
            }
          )
          if (!response.ok) {
            failed.push({ itemId: adjustment.itemId, message: "Medusa didn’t accept this change" })
            continue
          }
          const { inventory_item } = await response.json()
          setCurrent((levels) =>
            levels.map((level) =>
              level.inventory_item_id === adjustment.itemId && level.location_id === locationId
                ? { ...level, ...inventory_item.location_levels[0] }
                : level
            )
          )
        }
        return { failed }
      }}
    />
  )
}

Map a CSV import

previewCsvImport matches rows to variants by SKU and turns each count into an adjustment against the current quantity. It also lists unknown SKUs and bad rows so you can show them before anything is saved.

import {
  fieldValue,
  parseQuantity,
  type InventoryAdjustment,
  type InventoryItem,
  type InventoryReason,
} from "@/components/blocks/inventory/inventory"

type CsvMapping = {
  sku: string
  quantity: string
  field?: "available" | "onHand"
}

type CsvPreview = {
  adjustments: InventoryAdjustment[]
  unknownSkus: string[]
  invalidRows: number[]
}

function parseCsv(text: string) {
  const [header, ...lines] = text.trim().split(/\r?\n/)
  const columns = header.split(",").map((cell) => cell.trim().replace(/^"|"$/g, ""))
  return lines.map((line) => {
    const cells = line.split(",").map((cell) => cell.trim().replace(/^"|"$/g, ""))
    return Object.fromEntries(columns.map((column, index) => [column, cells[index] ?? ""]))
  })
}

export function previewCsvImport(
  text: string,
  items: InventoryItem[],
  mapping: CsvMapping,
  reason: InventoryReason = "cycle_count"
): CsvPreview {
  const field = mapping.field ?? "available"
  const bySku = new Map(items.map((item) => [item.sku?.toLowerCase(), item]))
  const preview: CsvPreview = { adjustments: [], unknownSkus: [], invalidRows: [] }

  parseCsv(text).forEach((row, index) => {
    const sku = row[mapping.sku]?.toLowerCase()
    const item = sku ? bySku.get(sku) : undefined
    const quantity = parseQuantity(row[mapping.quantity] ?? "")
    if (!item) {
      if (sku) preview.unknownSkus.push(row[mapping.sku])
      return
    }
    if (quantity === null || Number.isNaN(quantity) || quantity < 0) {
      preview.invalidRows.push(index + 2)
      return
    }
    const current = fieldValue(item, field)
    if (quantity === current || item.tracked === false) return
    preview.adjustments.push({
      itemId: item.id,
      field,
      delta: quantity - current,
      quantity,
      compareQuantity: current,
      reason,
    })
  })

  return preview
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
InventoryTableToolbar, editable grid, quantity editor, history sheet and save bar.
QuantityEditorThe Adjust by / Set to popover with a stepper and a before → after preview.
InventoryHistorySheetA side sheet listing who changed a row, when, why, by how much and the resulting quantity.
buildAdjustments, draftDelta, onHand, stockStatusPure helpers for adjustments, deltas, on-hand math and stock states. They work on the server too.

InventoryTable

Also takes div props.

PropTipoPredeterminado
itemsVariants at the current location. Pass new arrays as orders come in, and changed numbers roll and flash in place.
InventoryItem[]–
onSaveApply the batch, update items, then resolve. Return { failed } to keep those rows in the draft with a message, or throw to fail the whole batch.
(adjustments: InventoryAdjustment[]) => Promise<InventorySaveResult | void>–
loadingShows skeleton rows.
booleanfalse
locationNameUsed in the empty state, the history sheet and labels.
string–
readOnlyTurns off editing and selection, for example when showing all locations.
booleanfalse
lowStockThresholdAvailable at or below this counts as low stock.
number5
defaultReasonThe reason preselected in the save bar.
InventoryReason"correction"
onTrackShows a Track button on untracked rows.
(item: InventoryItem) => Promise<void>–
onLoadHistoryLoads the adjustment history when the sheet opens. Throw to show a retry state.
(item: InventoryItem) => Promise<InventoryHistoryEntry[]>–
onDirtyChangeCalled when unsaved changes appear or clear, for navigation guards.
(dirty: boolean) => void–
localeLocale for numbers and dates.
Intl.LocalesArgument"en-US"
labelsColumn names, filters, save bar and empty-state words.
Partial<InventoryTableLabels>–
PropTipoPredeterminado
idYour inventory item or variant id. It comes back in each adjustment.
string–
productTitle, variantTitle, sku, imageWhat the row shows. Search matches the title, variant and SKU.
string, string, string, { src; alt? }–
availableSellable stock. Can be negative when overselling is allowed.
number–
committedStock in unfulfilled orders.
number–
unavailableCounted in On hand but not sellable, with a breakdown popover.
{ damaged?; qualityControl?; safetyStock?; reserved? }–
incomingTransfers and purchase orders on the way. Past dates show as overdue.
{ id; quantity; expectedOn?; source? }[]–
trackedfalse shows Not tracked instead of numbers.
booleantrue
PropTipoPredeterminado
itemIdThe row that changed.
string–
fieldWhich quantity was edited. Both move by the same delta.
"available" | "onHand"–
deltaThe change to apply. Send this to the server, not the result.
number–
compareQuantityThe quantity the change was based on, for compare-and-set checks.
number–
quantityThe expected result.
number–
reasoncorrection, received, damaged, theft_or_loss, returned or cycle_count.
InventoryReason–
KeyAcción
ArrowUpArrowDownArrowLeftArrowRightMoves between cells like a spreadsheet. In the editor, Up and Down step the quantity.
ShiftArrowUpSteps the quantity by 10 in the editor.
EnterOpens the editor on a quantity. In the editor, keeps the change and moves down a row.
0-9Starts editing with that digit. + and − start an Adjust by.
EscapeCancels the editor, or clears the selection in the grid.
BackspaceRemoves the unsaved change on the focused row.
SpaceSelects or clears the row when its checkbox has focus.
ModSSaves the draft.
  • The grid uses one tab stop that moves with the arrow keys. Every cell has a name such as “Available for Ring Vase, Sand / Medium, 47, changed by +5, not saved”.
  • A polite live region announces each change kept in the draft, selection counts and “Saved 3 changes”. Save failures go to an alert region, and the failed row links to its message.
  • Stock states are never shown by color alone. Low stock, Out of stock and Oversold are written out, oversold numbers get a warning icon, and edited cells show their delta as text.
  • Rows don’t move while focus is in the table or a popover is open, so live updates never pull the row out from under a screen reader or keyboard user. Number rolls and flashes are turned off for reduced motion.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Inventory. Cada uno se instala por separado.

Código

9 archivos, añadidos a components/blocks/inventory.