段階スキャンScanGateExperimental
複数段階のバーコード読み取りを、確認値を引き継ぎながら進めるスキャンフローです。
プレビュー
次に読み取る
カートン
- 対応中1
カートン
箱ラベルを読み取り、以降の商品をこのカートンに紐付けます。
CTN-001
出荷カートン
CTN-002
出荷カートン
- 待機2
商品
複数の商品ラベルを読み取り、明細ごとの梱包数を完了にします。
- デスクライト LED0 / 24901111111118
- USB-C ケーブル 1m0 / 14902222222225
- 待機3
出荷確認
出荷ラベルを読み取り、カートンを出荷待ちにしてから次へ進みます。
SHIP-CTN-001商品がすべて完了すると出荷確認できます
梱包 0 / 3、残り 3 点。
状態とバリエーション
一画面フロー
読み取り済みの明細を同じ画面で完了にし、次に読む対象だけを切り替えます。
次に読み取る
カートン
- 対応中1
カートン
箱ラベルを読み取り、以降の商品をこのカートンに紐付けます。
CTN-001
出荷カートン
CTN-002
出荷カートン
- 待機2
商品
複数の商品ラベルを読み取り、明細ごとの梱包数を完了にします。
- デスクライト LED0 / 24901111111118
- USB-C ケーブル 1m0 / 14902222222225
- 待機3
出荷確認
出荷ラベルを読み取り、カートンを出荷待ちにしてから次へ進みます。
SHIP-CTN-001商品がすべて完了すると出荷確認できます
梱包 0 / 3、残り 3 点。
ステップ表示あり
手順を明示したい確認フローでは showSteps を有効にできます。
次に読み取る
カートン
- 1カートン
- 2商品
- 3出荷
- 対応中1
カートン
箱ラベルを読み取り、以降の商品をこのカートンに紐付けます。
CTN-001
出荷カートン
CTN-002
出荷カートン
- 待機2
商品
複数の商品ラベルを読み取り、明細ごとの梱包数を完了にします。
- デスクライト LED0 / 24901111111118
- USB-C ケーブル 1m0 / 14902222222225
- 待機3
出荷確認
出荷ラベルを読み取り、カートンを出荷待ちにしてから次へ進みます。
SHIP-CTN-001商品がすべて完了すると出荷確認できます
梱包 0 / 3、残り 3 点。
完了で停止
advance="done" は最後の段階で保持し、確認済みの context を onComplete に渡します。
EMP-104 を入力して確認します。
省略表示
周囲に明細や進捗がある場合は、ScanGate 自体は入力だけに寄せます。
次に読み取る
カートン
- 対応中1
カートン
箱ラベルを読み取り、以降の商品をこのカートンに紐付けます。
CTN-001
出荷カートン
CTN-002
出荷カートン
- 待機2
商品
複数の商品ラベルを読み取り、明細ごとの梱包数を完了にします。
- デスクライト LED0 / 24901111111118
- USB-C ケーブル 1m0 / 14902222222225
- 待機3
出荷確認
出荷ラベルを読み取り、カートンを出荷待ちにしてから次へ進みます。
SHIP-CTN-001商品がすべて完了すると出荷確認できます
梱包 0 / 3、残り 3 点。
プロパティ
| プロパティ | 型 | 初期値 | 説明 |
|---|---|---|---|
| stages | ScanGateStage[] | - | 順番に実行するスキャン段階です。各段階は id、label、onScan を持ちます。 |
| ScanGateResult.advance | "next" | "stay" | "reset" | "done" | stageId | - | 読み取り後にどの段階へ進むかを指定します。done は完了状態として保持し、onComplete を呼びます。 |
| ScanGateResult.value | unknown | - | その段階で確認した値です。後続段階は ctx.values[stageId] から参照できます。 |
| onStageChange / onComplete | (ctx) => void | - | 段階変更または done 完了時に呼ばれるコールバックです。 |
| showSteps | boolean | true | 上部のステップ表示を出すかどうかを指定します。 |
| stepsLabel | string | "スキャン手順" | ステップ表示リストのアクセシブル名です。既定は日本語のままで、インスタンス単位でローカライズできます。 |
| ScanGateStage.onScannerOpen | (action, ctx) => void | - | 入力欄のバーコードアイコンを押した時に呼ばれます。スキャン画面を開き、action.commit(code) で読み取り結果を確定できます。 |
| ScanGateStage.scannerLabel | string | - | バーコードアイコンボタンの aria-label とツールチップ文言です。 |
| autoFocus | boolean | true | マウント時と段階変更時にアクティブな入力へフォーカスします。 |
| clearOnScan | boolean | - | 内部の ScanInput へ渡します。カメラ/スキャン画面で読み取りコードを入力欄に残したい場合は false にします。 |
| assertive / showFeed / feedLimit / lockMs | boolean / boolean / number / number | - | 内部の ScanInput へ渡す読み取り通知、履歴、重複防止の設定です。 |
| ref | ScanGateHandle | - | 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 の記事で解説しています。