Inventory Allocation Table
A fulfillment-center grid for SKU allocation: edit demand, lead time, and safety stock inline, expand a SKU to allocate its batches across warehouse locations, and watch reorder points, days of cover, and suggested purchase orders recalculate live.
$ npx shadcn add https://www.shad-table.dev/r/inventory-allocation-table.jsonClick a demand, lead-time, or safety-stock value to edit it — every derived column recalculates. Expand a SKU to reallocate its batches.
| Batches | |||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Nitrile Gloves M (100 ct) SKU-3310 · 2 batches | Short | 60 of 280 on hand | 220 / 400 · 180 open | 700 0.0d cover | 820 82 × 10/case | ||||||||||||||||||||||||||||
Cold Brew Concentrate 1L SKU-1042 · 3 batches | Reorder | 104 of 240 on hand | 136 / 180 · 44 open | 260 1.5d cover | 204 17 × 12/case | ||||||||||||||||||||||||||||
180 units on open orders · 136 allocated · 44 still open
| |||||||||||||||||||||||||||||||||
Thermal Labels 4×6 (roll) SKU-5061 · 1 batch | Reorder | 380 of 380 on hand | 0 / 0 | 450 15.2d cover | 100 2 × 50/case | ||||||||||||||||||||||||||||
Greek Yogurt 500g SKU-6204 · 3 batches | Reorder | 80 of 120 on hand | 40 / 64 · 24 open | 86 2.5d cover | 32 4 × 8/case | ||||||||||||||||||||||||||||
Oat Milk Barista 1L SKU-2007 · 2 batches | Healthy | 180 of 300 on hand | 120 / 120 | 102 10.0d cover | — | ||||||||||||||||||||||||||||
Protein Bar Chocolate 12-pk SKU-4120 · 2 batches | Healthy | 300 of 390 on hand | 90 / 90 | 250 10.0d cover | — | ||||||||||||||||||||||||||||
Shipping Carton 12×12×8 SKU-7118 · 2 batches | Healthy | 750 of 1,000 on hand | 250 / 250 | 480 12.5d cover | — | ||||||||||||||||||||||||||||
| Totals | 1,854 | 1,156 | |||||||||||||||||||||||||||||||
How it works
1.Formulas are derived on read, never stored
A row only holds raw inputs — batches, open order quantity, demand, lead time, safety stock. Available, reorder point, days of cover, and the suggested PO are all recomputed by deriveMetrics() from those inputs on every render. That's what makes editing safe: change one batch allocation and every dependent number (the SKU's status, its reorder quantity, the footer total) is correct on the next render, because there's no cached value anywhere to forget to update.
const reorderPoint = row.dailyDemand * row.leadTimeDays + row.safetyStockconst position = available - unallocatedconst shortfall = Math.max(0, reorderPoint - position)const reorderQty = Math.ceil(shortfall / row.casePack) * row.casePack
2.Computed columns sort by the formula, not by what's on screen
Each formula column uses accessorFn to hand TanStack the derived number, so sorting by Reorder point or Suggested PO works with no custom sortingFn. Status sorts by a severity rank (Short → Reorder → Healthy) rather than alphabetically, so the default sort puts the SKUs that need attention first.
{id: 'status',accessorFn: (row) => statusRank[deriveMetrics(row).status],cell: ({ row }) => <Badge>{deriveMetrics(row.original).status}</Badge>,}
3.Edits go through table meta, so columns stay static
The Editable Table builds its columns inside useMemo because each cell closes over component state. Here the write-back functions are passed as the table's meta instead, and cells reach them with table.options.meta. The columns array is a plain module-level constant — it never gets rebuilt on each keystroke or save, and it can be read top to bottom like any other example's columns.tsx.
cell: ({ row, table }) => (<EditableNumberCellvalue={row.original[field]}onCommit={(n) => getMeta(table).updateSku(row.original.sku, field, n)}/>)
4.Expansion survives edits because it's keyed by SKU
Unlike the Master-Detail Table's Set of ids, this uses TanStack's own expanded state with getRowCanExpand — batches are custom markup, not subRows. Two options make it survive editing: getRowId keys rows by SKU so an open panel follows its row when a re-sort moves it, and autoResetExpanded: false stops TanStack from collapsing every row whenever a save produces a new data array.
getRowId: (row) => row.sku,getRowCanExpand: () => true,autoResetExpanded: false,
5.Validation uses the whole row, not just the cell
A batch's allocation is limited by two things: what that batch has on hand, and what's left of the SKU's open order after the other batches' allocations. The validator closes over the parent row to check both. An invalid value keeps the input open with the message beneath it instead of silently reverting, so the picker can fix the number in place.
const allocatedElsewhere = allocated - batch.allocatedvalidate={(n) => {if (n > batch.onHand) return `Only ${batch.onHand} on hand`if (allocatedElsewhere + n > row.orderedQty) return `Max ${row.orderedQty - allocatedElsewhere}`return null}}
6.FEFO auto-allocation is just another formula
First-expired, first-out picks from the batch that expires soonest and skips expired stock. allocateFefo() takes a row and returns new batches, so the Auto-allocate button is a single updateRow call — and because nothing derived is stored, the status, reorder quantity, and totals all follow automatically.
let remaining = row.orderedQtyfor (const id of batchesByExpiry) {const take = Math.min(batch.onHand, remaining)allocations.set(id, take)remaining -= take}