Interactive.picklist

Pick list

A warehouse-style picking interface for quantity confirmation. Users can pick items by scanning barcodes or manually adjusting quantities, with visual feedback as they progress toward target quantities.

ctx.ui.interactive.pickList("name", config);

Config

name*
string

The name of the pick list. This will be the key in the result object.

data*
T[]

An array of data to display in the pick list.


render*
(data: T) => PickListItem

A function that takes an item from the data array and returns a PickListItem. See the Item structure section for details on the PickListItem type.


supportedInputs
{ scanner?: boolean; manual?: boolean }

Controls which input methods are available. When scanner is true, barcodes can be scanned to increment quantities. When manual is true, plus and minus buttons are shown for manual adjustment. Both default to true.


duplicateHandling
"increaseQuantity" | "rejectDuplicates"

How to handle duplicate barcode scans. With increaseQuantity (default), scanning the same barcode multiple times increments the quantity. With rejectDuplicates, each unique barcode can only be scanned once per item.


autoContinue
boolean

When true, the flow automatically advances to the next step once all items reach their target quantities. Only triggers when the final item is completed via scanning.

Item structure

The render function must return a PickListItem with the following properties:

PropertyTypeRequiredDescription
idstringYesUnique identifier for the item
titlestringNoDisplay name shown in the pick list
descriptionstringNoAdditional details shown below the title
targetQuantitynumberYesThe quantity required to complete this item
barcodesstring[]NoBarcodes that map to this item for scanning
image{ url: string; alt?: string; fit?: "contain" | "cover" }NoProduct image to display

Return value

The pick list returns an object containing an items array with the picked quantities:

{
  items: [
    {
      id: string;
      quantity: number;
      targetQuantity: number;
      scannedBarcodes: string[];
    }
  ]
}

Each item includes the scannedBarcodes array, which tracks exactly which barcodes were scanned. This is useful for serial number tracking or audit trails.

Usage

The pick list is an interactive element that tracks progress toward target quantities. Items turn green when their target is reached.

const items = [
  {
    id: "sku-001",
    name: "Wireless Mouse",
    location: "A-12-3",
    qty: 2,
    barcode: "5901234123457",
    imageUrl: "https://example.com/mouse.jpg",
  },
  {
    id: "sku-002",
    name: "USB-C Cable",
    location: "B-04-1",
    qty: 5,
    barcode: "5901234123458",
  },
];
 
const result = await ctx.ui.page({
  title: "Pick items for order",
  content: [
    ctx.ui.interactive.pickList("picks", {
      supportedInputs: {
        scanner: true,
        manual: true,
      },
      data: items,
      render: (item) => ({
        id: item.id,
        title: item.name,
        description: `Location: ${item.location}`,
        targetQuantity: item.qty,
        barcodes: [item.barcode],
        image: item.imageUrl ? { url: item.imageUrl, alt: item.name } : undefined,
      }),
    }),
  ],
});
 
// Access picked quantities
for (const item of result.picks.items) {
  console.log(`${item.id}: picked ${item.quantity} of ${item.targetQuantity}`);
}

Warehouse picking workflow

Here's a complete example of an order fulfillment picking flow:

flows/pickOrder.ts
import { PickOrder, models } from "@teamkeel/sdk";
 
export default PickOrder(async (ctx, inputs) => {
  // Fetch the order and its line items
  const order = await models.order.findOne({
    where: { id: inputs.orderId },
    include: { lines: { include: { product: true } } },
  });
 
  if (!order) {
    throw new Error("Order not found");
  }
 
  // Show the pick list
  const pickResult = await ctx.ui.page({
    title: `Pick Order ${order.reference}`,
    content: [
      ctx.ui.display.banner({
        mode: "info",
        title: `${order.lines.length} items to pick`,
      }),
      ctx.ui.interactive.pickList("picks", {
        supportedInputs: {
          scanner: true,
          manual: true,
        },
        duplicateHandling: "rejectDuplicates",
        data: order.lines,
        render: (line) => ({
          id: line.id,
          title: line.product.name,
          description: `Location: ${line.product.location} | SKU: ${line.product.sku}`,
          targetQuantity: line.quantity,
          barcodes: [line.product.barcode],
          image: line.product.imageUrl
            ? { url: line.product.imageUrl }
            : undefined,
        }),
      }),
    ],
  });
 
  // Check if all items were fully picked
  const allComplete = pickResult.picks.items.every(
    (item) => item.quantity >= item.targetQuantity
  );
 
  if (!allComplete) {
    // Handle partial picks
    const shortItems = pickResult.picks.items.filter(
      (item) => item.quantity < item.targetQuantity
    );
 
    await ctx.ui.page({
      title: "Incomplete pick",
      content: [
        ctx.ui.display.banner({
          mode: "warning",
          title: "Some items were not fully picked",
        }),
        ctx.ui.display.table({
          data: shortItems.map((item) => {
            const line = order.lines.find((l) => l.id === item.id);
            return {
              item: line?.product.name,
              picked: item.quantity,
              required: item.targetQuantity,
              short: item.targetQuantity - item.quantity,
            };
          }),
        }),
      ],
    });
  }
 
  // Record the pick results
  for (const item of pickResult.picks.items) {
    await models.pickRecord.create({
      orderId: order.id,
      orderLineId: item.id,
      quantityPicked: item.quantity,
      scannedBarcodes: item.scannedBarcodes,
      pickedAt: new Date(),
      pickedBy: ctx.identity?.id,
    });
  }
 
  return { success: allComplete };
});

Scanner-only mode

For strict warehouse environments where all picks must be verified by scanning:

ctx.ui.interactive.pickList("picks", {
  supportedInputs: {
    scanner: true,
    manual: false,  // Disable manual buttons
  },
  duplicateHandling: "rejectDuplicates",
  autoContinue: true,  // Auto-advance when complete
  data: items,
  render: (item) => ({
    id: item.id,
    title: item.name,
    targetQuantity: item.quantity,
    barcodes: item.barcodes,
  }),
});

Serial number tracking

When duplicateHandling is set to "rejectDuplicates", each barcode can only be scanned once. Combined with the scannedBarcodes return value, this enables serial number tracking:

const laptopBatch = [
  {
    batchId: "laptop-batch",
    productName: "Laptops",
    instructions: "Scan each laptop's serial number",
    requiredQuantity: 3,
    validSerials: ["SN-001", "SN-002", "SN-003", "SN-004", "SN-005"],
  },
];
 
const result = await ctx.ui.page({
  title: "Scan serial numbers",
  content: [
    ctx.ui.interactive.pickList("serialPicks", {
      duplicateHandling: "rejectDuplicates",
      data: laptopBatch,
      render: (batch) => ({
        id: batch.batchId,
        title: batch.productName,
        description: batch.instructions,
        targetQuantity: batch.requiredQuantity,
        barcodes: batch.validSerials,
      }),
    }),
  ],
});
 
// Record which serial numbers were picked
const serialNumbers = result.serialPicks.items[0].scannedBarcodes;
console.log("Picked serial numbers:", serialNumbers);
// ["SN-002", "SN-003", "SN-001"]