段階スキャンScanGateExperimental

複数段階のバーコード読み取りを、確認値を引き継ぎながら進めるスキャンフローです。

プレビュー

次に読み取る

カートン

梱包 0 / 3

  1. 1

    カートン

    箱ラベルを読み取り、以降の商品をこのカートンに紐付けます。

    対応中

    CTN-001

    出荷カートン

    CTN-002

    出荷カートン

  2. 2

    商品

    複数の商品ラベルを読み取り、明細ごとの梱包数を完了にします。

    待機
    • デスクライト LED0 / 2
      4901111111118
    • USB-C ケーブル 1m0 / 1
      4902222222225
  3. 3

    出荷確認

    出荷ラベルを読み取り、カートンを出荷待ちにしてから次へ進みます。

    待機

    商品がすべて完了すると出荷確認できます

    梱包 0 / 3、残り 3 点。

    SHIP-CTN-001

状態とバリエーション

一画面フロー

読み取り済みの明細を同じ画面で完了にし、次に読む対象だけを切り替えます。

次に読み取る

カートン

梱包 0 / 3

  1. 1

    カートン

    箱ラベルを読み取り、以降の商品をこのカートンに紐付けます。

    対応中

    CTN-001

    出荷カートン

    CTN-002

    出荷カートン

  2. 2

    商品

    複数の商品ラベルを読み取り、明細ごとの梱包数を完了にします。

    待機
    • デスクライト LED0 / 2
      4901111111118
    • USB-C ケーブル 1m0 / 1
      4902222222225
  3. 3

    出荷確認

    出荷ラベルを読み取り、カートンを出荷待ちにしてから次へ進みます。

    待機

    商品がすべて完了すると出荷確認できます

    梱包 0 / 3、残り 3 点。

    SHIP-CTN-001

ステップ表示あり

手順を明示したい確認フローでは showSteps を有効にできます。

次に読み取る

カートン

梱包 0 / 3
  1. 1カートン
  2. 2商品
  3. 3出荷

  1. 1

    カートン

    箱ラベルを読み取り、以降の商品をこのカートンに紐付けます。

    対応中

    CTN-001

    出荷カートン

    CTN-002

    出荷カートン

  2. 2

    商品

    複数の商品ラベルを読み取り、明細ごとの梱包数を完了にします。

    待機
    • デスクライト LED0 / 2
      4901111111118
    • USB-C ケーブル 1m0 / 1
      4902222222225
  3. 3

    出荷確認

    出荷ラベルを読み取り、カートンを出荷待ちにしてから次へ進みます。

    待機

    商品がすべて完了すると出荷確認できます

    梱包 0 / 3、残り 3 点。

    SHIP-CTN-001

完了で停止

advance="done" は最後の段階で保持し、確認済みの context を onComplete に渡します。

EMP-104 を入力して確認します。

省略表示

周囲に明細や進捗がある場合は、ScanGate 自体は入力だけに寄せます。

次に読み取る

カートン

梱包 0 / 3

  1. 1

    カートン

    箱ラベルを読み取り、以降の商品をこのカートンに紐付けます。

    対応中

    CTN-001

    出荷カートン

    CTN-002

    出荷カートン

  2. 2

    商品

    複数の商品ラベルを読み取り、明細ごとの梱包数を完了にします。

    待機
    • デスクライト LED0 / 2
      4901111111118
    • USB-C ケーブル 1m0 / 1
      4902222222225
  3. 3

    出荷確認

    出荷ラベルを読み取り、カートンを出荷待ちにしてから次へ進みます。

    待機

    商品がすべて完了すると出荷確認できます

    梱包 0 / 3、残り 3 点。

    SHIP-CTN-001

プロパティ

表は横にスクロールできます
プロパティ初期値説明
stagesScanGateStage[]-順番に実行するスキャン段階です。各段階は id、label、onScan を持ちます。
ScanGateResult.advance"next" | "stay" | "reset" | "done" | stageId-読み取り後にどの段階へ進むかを指定します。done は完了状態として保持し、onComplete を呼びます。
ScanGateResult.valueunknown-その段階で確認した値です。後続段階は ctx.values[stageId] から参照できます。
onStageChange / onComplete(ctx) => void-段階変更または done 完了時に呼ばれるコールバックです。
showStepsbooleantrue上部のステップ表示を出すかどうかを指定します。
stepsLabelstring"スキャン手順"ステップ表示リストのアクセシブル名です。既定は日本語のままで、インスタンス単位でローカライズできます。
ScanGateStage.onScannerOpen(action, ctx) => void-入力欄のバーコードアイコンを押した時に呼ばれます。スキャン画面を開き、action.commit(code) で読み取り結果を確定できます。
ScanGateStage.scannerLabelstring-バーコードアイコンボタンの aria-label とツールチップ文言です。
autoFocusbooleantrueマウント時と段階変更時にアクティブな入力へフォーカスします。
clearOnScanboolean-内部の ScanInput へ渡します。カメラ/スキャン画面で読み取りコードを入力欄に残したい場合は false にします。
assertive / showFeed / feedLimit / lockMsboolean / boolean / number / number-内部の ScanInput へ渡す読み取り通知、履歴、重複防止の設定です。
refScanGateHandle-reset、goTo、getValues を持つ命令的ハンドルです。

使い方

import * as React from "react";
import {
  Button,
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  ScanGate,
  Tooltip,
  TooltipContent,
  TooltipTrigger,
  type ScanGateHandle,
  type ScanGateResult,
  type ScanInputAction,
} from "@gunjo/ui";

type ScannerTarget = {
  stage: "carton" | "item" | "ship";
  title: string;
  description: string;
  codes: { code: string; label: string; meta: string }[];
  action: ScanInputAction;
};

function BarcodeVisual({ value }: { value: string }) {
  const bars = [2, 1, 3, 1, 1, 2, 4, 1, 2, 3, 1, 1, 3, 2, 1, 4, 1, 2, 2, 1];
  return (
    <div className="flex h-10 items-end gap-0.5 rounded bg-background px-2 py-1" aria-hidden="true">
      {bars.map((width, index) => (
        <span key={index} className="block bg-foreground" style={{ width, height: 18 + ((index + value.length) % 5) * 4 }} />
      ))}
    </div>
  );
}

function QrVisual({ value }: { value: string }) {
  const filledCells = [
    0, 1, 2, 6, 7, 8, 12, 14, 18, 20, 21, 24,
    28, 30, 32, 35, 36, 40, 42, 45, 48,
  ];
  const cells = Array.from({ length: 49 }, (_, index) =>
    filledCells.includes(index)
  );
  return (
    <div className="grid h-12 w-12 grid-cols-7 gap-0.5 rounded bg-background p-1" aria-hidden="true">
      {cells.map((filled, index) => <span key={index} className={filled ? "bg-foreground" : "bg-transparent"} />)}
    </div>
  );
}

function ScannerPanel(props: { target: ScannerTarget; onClose: () => void }) {
  const { target, onClose } = props;

  return (
    <Dialog open onOpenChange={(open) => !open && onClose()}>
      <DialogContent className="gap-4 p-4 sm:max-w-md" closeLabel="閉じる">
        <DialogHeader>
          <DialogTitle>{target.title}</DialogTitle>
          <DialogDescription>{target.description}</DialogDescription>
        </DialogHeader>
        <div className="relative overflow-hidden rounded-md border bg-foreground p-4 text-background">
          <div className="pointer-events-none absolute inset-x-4 top-1/2 h-px bg-success" />
          <div className="grid gap-2">
          {target.codes.map((item) => (
            <button
              key={item.code}
              type="button"
              className="grid grid-cols-[1fr_auto] items-center gap-3 rounded-md
                border border-background/20 bg-background/10 px-3 py-2 text-left
                hover:bg-background/20"
              onClick={() => {
                target.action.commit(item.code);
                onClose();
              }}
            >
              <span>
                <span className="block font-mono text-sm font-semibold tracking-wide">{item.code}</span>
                <span className="block text-xs text-background/75">{item.label} / {item.meta}</span>
              </span>
              {target.stage === "item" ? <QrVisual value={item.code} /> : <BarcodeVisual value={item.code} />}
            </button>
          ))}
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}

export function PackingGate() {
  const [openCarton, setOpenCarton] = React.useState<string | null>(null);
  const [
    activeStage,
    setActiveStage,
  ] = React.useState<"carton" | "item" | "ship">("carton");
  const [scannerTarget, setScannerTarget] = React.useState<ScannerTarget | null>(null);
  const [shipped, setShipped] = React.useState(false);
  const [lines, setLines] = React.useState([
    { code: "4901111111118", name: "デスクライト LED", ordered: 2, packed: 0 },
    { code: "4902222222225", name: "USB-C ケーブル 1m", ordered: 1, packed: 0 },
  ]);
  const gateRef = React.useRef<ScanGateHandle>(null);
  const orderedTotal = lines.reduce((total, line) => total + line.ordered, 0);
  const packedTotal = lines.reduce((total, line) => total + line.packed, 0);

  const handleCarton = (code: string): ScanGateResult => {
    if (!["CTN-001", "CTN-002"].includes(code)) {
      return { ok: false, message: `カートンが見つかりません(${code})`, advance: "stay" };
    }
    setOpenCarton(code);
    setShipped(false);
    return { ok: true, message: `カートン ${code} を開きました`, advance: "next", value: { barcode: code } };
  };

  const handleItem = (code: string, ctx): ScanGateResult => {
    const carton = ctx.values.carton ?? (openCarton ? { barcode: openCarton } : undefined);
    if (!carton) return { ok: false, message: "先にカートンをスキャンしてください", advance: "carton" };

    const index = lines.findIndex((line) => line.code === code);
    if (index === -1) return { ok: false, message: `受注に無い商品です(${code})`, advance: "stay" };

    const line = lines[index];
    const next = line.packed + 1;
    setLines((current) =>
      current.map((item, itemIndex) => itemIndex === index ? {
        ...item,
        packed: next,
      } : item)
    );

    return {
      ok: true,
      message: `${line.name} を ${carton.barcode} に梱包(${next} / ${line.ordered})`,
      advance: "ship",
    };
  };

  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <ScanGate
        ref={gateRef}
        onComplete={() => setShipped(true)}
        onStageChange={(stageId) => setActiveStage(stageId === "item" || stageId === "ship" ? stageId : "carton")}
        stages={[
          {
            id: "carton",
            label: "1. カートンをスキャン",
            title: "カートン",
            placeholder: "CTN-001 / CTN-002",
            scannerLabel: "カートン用のスキャン画面を開く",
            onScannerOpen: (action) => setScannerTarget({
              stage: "carton",
              title: "カートンラベルを読み取る",
              description: "箱に貼られたラベルをスキャンします。",
              action,
              codes: [
                "CTN-001",
                "CTN-002",
              ].map((code) => ({ code, label: "出荷カートン", meta: "箱ラベル" })),
            }),
            onScan: handleCarton,
          },
          {
            id: "item",
            label: "2. 商品をスキャン",
            title: "商品",
            placeholder: "4901111111118 / 4902222222225",
            inputMode: "numeric",
            scannerLabel: "商品用のスキャン画面を開く",
            onScannerOpen: (action) => setScannerTarget({
              stage: "item",
              title: "商品バーコードを読み取る",
              description: "梱包明細の商品バーコードをスキャンします。",
              action,
              codes: lines.map((line) => ({
                code: line.code,
                label: line.name,
                meta: line.packed + " / " + line.ordered,
              })),
            }),
            onScan: handleItem,
          },
          {
            id: "ship",
            label: "3. 出荷確認",
            title: "出荷",
            placeholder: openCarton ? "SHIP-" + openCarton : "SHIP-CTN-001",
            scannerLabel: "出荷ラベル用のスキャン画面を開く",
            onScannerOpen: (action) => setScannerTarget({
              stage: "ship",
              title: "出荷ラベルを読み取る",
              description: "梱包完了後のカートンに貼る出荷ラベルを読み取ります。",
              action,
              codes: [{
                code: openCarton ? "SHIP-" + openCarton : "SHIP-CTN-001",
                label: "出荷ラベル",
                meta: openCarton ?? "CTN-001",
              }],
            }),
            onScan: (code, ctx) => {
              const expected = "SHIP-" + (ctx.values.carton?.barcode ?? openCarton ?? "CTN-001");
              return {
                ok: code === expected,
                message: code === expected ? "出荷待ちにしました" : "出荷ラベルが一致しません",
                advance: code === expected ? "done" : "stay",
              };
            },
          },
        ]}
      />

      {scannerTarget ? (
        <ScannerPanel target={scannerTarget} onClose={() => setScannerTarget(null)} />
      ) : null}

      <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card px-3 py-2">
        <div className="min-w-0 space-y-0.5">
          <p className="text-xs font-medium text-muted-foreground">次に読み取る</p>
          <p className="text-sm font-medium text-foreground">
            {activeStage === "carton" ? "カートン" : activeStage === "item" ? "商品" : "出荷確認"}
          </p>
        </div>
        <span className="rounded-full border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground">
          梱包 {packedTotal} / {orderedTotal}
        </span>
      </div>

      <ol className="flex flex-col gap-3">
        <section className="rounded-lg border bg-muted/20 p-3">
          <h3 className="mb-2 text-sm font-semibold text-foreground">1. カートン</h3>
          <div className="flex flex-col gap-2">
            {["CTN-001", "CTN-002"].map((code) => (
              <div key={code} className="rounded-md border bg-background px-3 py-2">
                <p className="font-mono text-sm font-semibold text-foreground">{code}</p>
                <p className="text-xs text-muted-foreground">出荷カートン</p>
                <div className="mt-2 max-w-[144px]">
                  <BarcodeVisual value={code} />
                </div>
              </div>
            ))}
          </div>
        </section>

        <section className="rounded-lg border bg-card p-3">
          <h3 className="mb-2 text-sm font-semibold text-foreground">2. 商品</h3>
          <ul className="flex flex-col gap-2 text-sm">
            {lines.map((line) => (
              <li key={line.code} className="grid gap-1 rounded-md border bg-background px-3 py-2">
                <div className="flex items-center justify-between gap-3">
                  <span className="min-w-0 truncate font-medium text-foreground">{line.name}</span>
                  <span className="shrink-0 tabular-nums text-muted-foreground">
                    {line.packed} / {line.ordered}
                    {line.packed >= line.ordered ? <span className="ml-2 text-success-strong">完了</span> : null}
                  </span>
                </div>
                <div className="flex items-center justify-between gap-3">
                  <span className="font-mono text-xs text-muted-foreground">{line.code}</span>
                  <QrVisual value={line.code} />
                </div>
              </li>
            ))}
          </ul>
        </section>

        <section className="rounded-lg border bg-card p-3">
          <h3 className="mb-2 text-sm font-semibold text-foreground">3. 出荷確認</h3>
          <p className="mb-3 text-xs text-muted-foreground">
            {shipped ? "出荷待ちにしました。" : `梱包 ${packedTotal} / ${orderedTotal}、残り ${orderedTotal - packedTotal} 点。`}
          </p>
          <Tooltip>
            <TooltipTrigger asChild>
              <span className="block" tabIndex={shipped ? -1 : 0}>
                <Button className="h-11 w-full" disabled={!shipped}>
                  次のカートンを読み取る
                </Button>
              </span>
            </TooltipTrigger>
            <TooltipContent>
              {shipped ? "次のカートン読み取りへ切り替えます。" : "出荷ラベルを読み取ると、次のカートン読み取りへ進めます。"}
            </TooltipContent>
          </Tooltip>
        </section>
      </ol>
    </div>
  );
}

使用コンポーネント

設計の判断(UIXHERO)

「いつ・なぜ使うか」の判断は、姉妹サイト UIXHERO の記事で解説しています。