695 lines
23 KiB
TypeScript
695 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useMemo, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { ScanLine } from "lucide-react";
|
|
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
|
import { BarcodeScanModal } from "@/components/common/BarcodeScanModal";
|
|
import type {
|
|
PopDataConnection,
|
|
PopComponentDefinitionV5,
|
|
} from "@/components/pop/designer/types/pop-layout";
|
|
|
|
// ========================================
|
|
// 타입 정의
|
|
// ========================================
|
|
|
|
export interface ScanFieldMapping {
|
|
sourceKey: string;
|
|
outputIndex: number;
|
|
label: string;
|
|
targetComponentId: string;
|
|
targetFieldName: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface PopScannerConfig {
|
|
barcodeFormat: "all" | "1d" | "2d";
|
|
autoSubmit: boolean;
|
|
showLastScan: boolean;
|
|
buttonLabel: string;
|
|
buttonVariant: "default" | "outline" | "secondary";
|
|
parseMode: "none" | "auto" | "json";
|
|
fieldMappings: ScanFieldMapping[];
|
|
}
|
|
|
|
// 연결된 컴포넌트의 필드 정보
|
|
interface ConnectedFieldInfo {
|
|
componentId: string;
|
|
componentName: string;
|
|
componentType: string;
|
|
fieldName: string;
|
|
fieldLabel: string;
|
|
}
|
|
|
|
const DEFAULT_SCANNER_CONFIG: PopScannerConfig = {
|
|
barcodeFormat: "all",
|
|
autoSubmit: true,
|
|
showLastScan: false,
|
|
buttonLabel: "스캔",
|
|
buttonVariant: "default",
|
|
parseMode: "none",
|
|
fieldMappings: [],
|
|
};
|
|
|
|
// ========================================
|
|
// 파싱 유틸리티
|
|
// ========================================
|
|
|
|
function tryParseJson(raw: string): Record<string, string> | null {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
const result: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(parsed)) {
|
|
result[k] = String(v);
|
|
}
|
|
return result;
|
|
}
|
|
} catch {
|
|
// JSON이 아닌 경우
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseScanResult(
|
|
raw: string,
|
|
mode: PopScannerConfig["parseMode"]
|
|
): Record<string, string> | null {
|
|
if (mode === "none") return null;
|
|
return tryParseJson(raw);
|
|
}
|
|
|
|
// ========================================
|
|
// 연결된 컴포넌트 필드 추출
|
|
// ========================================
|
|
|
|
function getConnectedFields(
|
|
componentId?: string,
|
|
connections?: PopDataConnection[],
|
|
allComponents?: PopComponentDefinitionV5[],
|
|
): ConnectedFieldInfo[] {
|
|
if (!componentId || !connections || !allComponents) return [];
|
|
|
|
const targetIds = connections
|
|
.filter((c) => c.sourceComponent === componentId)
|
|
.map((c) => c.targetComponent);
|
|
|
|
const uniqueTargetIds = [...new Set(targetIds)];
|
|
const fields: ConnectedFieldInfo[] = [];
|
|
|
|
for (const tid of uniqueTargetIds) {
|
|
const comp = allComponents.find((c) => c.id === tid);
|
|
if (!comp?.config) continue;
|
|
const compCfg = comp.config as Record<string, unknown>;
|
|
const compType = comp.type || "";
|
|
const compName = (comp as Record<string, unknown>).label as string || comp.type || tid;
|
|
|
|
// pop-search: filterColumns (복수) 또는 modalConfig.valueField 또는 fieldName (단일)
|
|
const filterCols = compCfg.filterColumns as string[] | undefined;
|
|
const modalCfg = compCfg.modalConfig as { valueField?: string; displayField?: string } | undefined;
|
|
|
|
if (Array.isArray(filterCols) && filterCols.length > 0) {
|
|
for (const col of filterCols) {
|
|
fields.push({
|
|
componentId: tid,
|
|
componentName: compName,
|
|
componentType: compType,
|
|
fieldName: col,
|
|
fieldLabel: col,
|
|
});
|
|
}
|
|
} else if (modalCfg?.valueField) {
|
|
fields.push({
|
|
componentId: tid,
|
|
componentName: compName,
|
|
componentType: compType,
|
|
fieldName: modalCfg.valueField,
|
|
fieldLabel: (compCfg.placeholder as string) || modalCfg.valueField,
|
|
});
|
|
} else if (compCfg.fieldName && typeof compCfg.fieldName === "string") {
|
|
fields.push({
|
|
componentId: tid,
|
|
componentName: compName,
|
|
componentType: compType,
|
|
fieldName: compCfg.fieldName,
|
|
fieldLabel: (compCfg.placeholder as string) || compCfg.fieldName as string,
|
|
});
|
|
}
|
|
|
|
// pop-field: sections 내 fields
|
|
const sections = compCfg.sections as Array<{
|
|
fields?: Array<{ id: string; fieldName?: string; labelText?: string }>;
|
|
}> | undefined;
|
|
if (Array.isArray(sections)) {
|
|
for (const section of sections) {
|
|
for (const f of section.fields ?? []) {
|
|
if (f.fieldName) {
|
|
fields.push({
|
|
componentId: tid,
|
|
componentName: compName,
|
|
componentType: compType,
|
|
fieldName: f.fieldName,
|
|
fieldLabel: f.labelText || f.fieldName,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
// ========================================
|
|
// 메인 컴포넌트
|
|
// ========================================
|
|
|
|
interface PopScannerComponentProps {
|
|
config?: PopScannerConfig;
|
|
label?: string;
|
|
isDesignMode?: boolean;
|
|
screenId?: string;
|
|
componentId?: string;
|
|
}
|
|
|
|
function PopScannerComponent({
|
|
config,
|
|
isDesignMode,
|
|
screenId,
|
|
componentId,
|
|
}: PopScannerComponentProps) {
|
|
const cfg = { ...DEFAULT_SCANNER_CONFIG, ...(config || {}) };
|
|
const { publish } = usePopEvent(screenId || "");
|
|
const [lastScan, setLastScan] = useState("");
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
const handleScanSuccess = useCallback(
|
|
(barcode: string) => {
|
|
setLastScan(barcode);
|
|
setModalOpen(false);
|
|
|
|
if (!componentId) return;
|
|
|
|
if (cfg.parseMode === "none") {
|
|
publish(`__comp_output__${componentId}__scan_value`, barcode);
|
|
return;
|
|
}
|
|
|
|
const parsed = parseScanResult(barcode, cfg.parseMode);
|
|
|
|
if (!parsed) {
|
|
publish(`__comp_output__${componentId}__scan_value`, barcode);
|
|
return;
|
|
}
|
|
|
|
if (cfg.parseMode === "auto") {
|
|
publish("scan_auto_fill", parsed);
|
|
publish(`__comp_output__${componentId}__scan_value`, barcode);
|
|
return;
|
|
}
|
|
|
|
if (cfg.fieldMappings.length === 0) {
|
|
publish(`__comp_output__${componentId}__scan_value`, barcode);
|
|
return;
|
|
}
|
|
|
|
for (const mapping of cfg.fieldMappings) {
|
|
if (!mapping.enabled) continue;
|
|
const value = parsed[mapping.sourceKey];
|
|
if (value === undefined) continue;
|
|
|
|
publish(
|
|
`__comp_output__${componentId}__scan_field_${mapping.outputIndex}`,
|
|
value
|
|
);
|
|
|
|
if (mapping.targetComponentId && mapping.targetFieldName) {
|
|
publish(
|
|
`__comp_input__${mapping.targetComponentId}__set_value`,
|
|
{ fieldName: mapping.targetFieldName, value }
|
|
);
|
|
}
|
|
}
|
|
},
|
|
[componentId, publish, cfg.parseMode, cfg.fieldMappings],
|
|
);
|
|
|
|
const handleClick = useCallback(() => {
|
|
if (isDesignMode) return;
|
|
setModalOpen(true);
|
|
}, [isDesignMode]);
|
|
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<Button
|
|
variant={cfg.buttonVariant}
|
|
size="icon"
|
|
onClick={handleClick}
|
|
className="h-full w-full rounded-md transition-transform active:scale-95"
|
|
>
|
|
<ScanLine className="h-7! w-7!" />
|
|
<span className="sr-only">{cfg.buttonLabel}</span>
|
|
</Button>
|
|
|
|
{cfg.showLastScan && lastScan && (
|
|
<div className="absolute inset-x-0 bottom-0 truncate bg-background/80 px-1 text-center text-[8px] text-muted-foreground backdrop-blur-sm">
|
|
{lastScan}
|
|
</div>
|
|
)}
|
|
|
|
{!isDesignMode && (
|
|
<BarcodeScanModal
|
|
open={modalOpen}
|
|
onOpenChange={setModalOpen}
|
|
barcodeFormat={cfg.barcodeFormat}
|
|
autoSubmit={cfg.autoSubmit}
|
|
onScanSuccess={handleScanSuccess}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 설정 패널
|
|
// ========================================
|
|
|
|
const FORMAT_LABELS: Record<string, string> = {
|
|
all: "모든 형식",
|
|
"1d": "1D 바코드",
|
|
"2d": "2D 바코드 (QR)",
|
|
};
|
|
|
|
const VARIANT_LABELS: Record<string, string> = {
|
|
default: "기본 (Primary)",
|
|
outline: "외곽선 (Outline)",
|
|
secondary: "보조 (Secondary)",
|
|
};
|
|
|
|
const PARSE_MODE_LABELS: Record<string, string> = {
|
|
none: "없음 (단일 값)",
|
|
auto: "자동 (검색 필드명과 매칭)",
|
|
json: "JSON (수동 매핑)",
|
|
};
|
|
|
|
interface PopScannerConfigPanelProps {
|
|
config: PopScannerConfig;
|
|
onUpdate: (config: PopScannerConfig) => void;
|
|
allComponents?: PopComponentDefinitionV5[];
|
|
connections?: PopDataConnection[];
|
|
componentId?: string;
|
|
}
|
|
|
|
function PopScannerConfigPanel({
|
|
config,
|
|
onUpdate,
|
|
allComponents,
|
|
connections,
|
|
componentId,
|
|
}: PopScannerConfigPanelProps) {
|
|
const cfg = { ...DEFAULT_SCANNER_CONFIG, ...config };
|
|
|
|
const update = (partial: Partial<PopScannerConfig>) => {
|
|
onUpdate({ ...cfg, ...partial });
|
|
};
|
|
|
|
const connectedFields = useMemo(
|
|
() => getConnectedFields(componentId, connections, allComponents),
|
|
[componentId, connections, allComponents],
|
|
);
|
|
|
|
const buildMappingsFromFields = useCallback(
|
|
(fields: ConnectedFieldInfo[], existing: ScanFieldMapping[]): ScanFieldMapping[] => {
|
|
return fields.map((f, i) => {
|
|
const prev = existing.find(
|
|
(m) => m.targetComponentId === f.componentId && m.targetFieldName === f.fieldName
|
|
);
|
|
return {
|
|
sourceKey: prev?.sourceKey ?? f.fieldName,
|
|
outputIndex: i,
|
|
label: f.fieldLabel,
|
|
targetComponentId: f.componentId,
|
|
targetFieldName: f.fieldName,
|
|
enabled: prev?.enabled ?? true,
|
|
};
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const toggleMapping = (fieldName: string, componentId: string) => {
|
|
const updated = cfg.fieldMappings.map((m) =>
|
|
m.targetFieldName === fieldName && m.targetComponentId === componentId
|
|
? { ...m, enabled: !m.enabled }
|
|
: m
|
|
);
|
|
update({ fieldMappings: updated });
|
|
};
|
|
|
|
const updateMappingSourceKey = (fieldName: string, componentId: string, sourceKey: string) => {
|
|
const updated = cfg.fieldMappings.map((m) =>
|
|
m.targetFieldName === fieldName && m.targetComponentId === componentId
|
|
? { ...m, sourceKey }
|
|
: m
|
|
);
|
|
update({ fieldMappings: updated });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (cfg.parseMode !== "json" || connectedFields.length === 0) return;
|
|
const synced = buildMappingsFromFields(connectedFields, cfg.fieldMappings);
|
|
const isSame =
|
|
synced.length === cfg.fieldMappings.length &&
|
|
synced.every(
|
|
(s, i) =>
|
|
s.targetComponentId === cfg.fieldMappings[i]?.targetComponentId &&
|
|
s.targetFieldName === cfg.fieldMappings[i]?.targetFieldName,
|
|
);
|
|
if (!isSame) {
|
|
onUpdate({ ...cfg, fieldMappings: synced });
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [connectedFields, cfg.parseMode]);
|
|
|
|
return (
|
|
<div className="space-y-4 pr-1 pb-16">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">바코드 형식</Label>
|
|
<Select
|
|
value={cfg.barcodeFormat}
|
|
onValueChange={(v) => update({ barcodeFormat: v as PopScannerConfig["barcodeFormat"] })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(FORMAT_LABELS).map(([key, label]) => (
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[10px] text-muted-foreground">인식할 바코드 종류를 선택합니다</p>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">버튼 라벨</Label>
|
|
<Input
|
|
value={cfg.buttonLabel}
|
|
onChange={(e) => update({ buttonLabel: e.target.value })}
|
|
placeholder="스캔"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">버튼 스타일</Label>
|
|
<Select
|
|
value={cfg.buttonVariant}
|
|
onValueChange={(v) => update({ buttonVariant: v as PopScannerConfig["buttonVariant"] })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(VARIANT_LABELS).map(([key, label]) => (
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-xs">인식 후 자동 확인</Label>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{cfg.autoSubmit
|
|
? "바코드 인식 즉시 값 전달 (확인 버튼 생략)"
|
|
: "인식 후 확인 버튼을 눌러야 값 전달"}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={cfg.autoSubmit}
|
|
onCheckedChange={(v) => update({ autoSubmit: v })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-xs">마지막 스캔값 표시</Label>
|
|
<p className="text-[10px] text-muted-foreground">버튼 아래에 마지막 스캔값 표시</p>
|
|
</div>
|
|
<Switch
|
|
checked={cfg.showLastScan}
|
|
onCheckedChange={(v) => update({ showLastScan: v })}
|
|
/>
|
|
</div>
|
|
|
|
{/* 파싱 설정 섹션 */}
|
|
<div className="border-t pt-4">
|
|
<Label className="text-xs font-semibold">스캔 데이터 파싱</Label>
|
|
<p className="mb-3 text-[10px] text-muted-foreground">
|
|
바코드/QR에 여러 정보가 담긴 경우, 파싱하여 각각 다른 컴포넌트에 전달
|
|
</p>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">파싱 모드</Label>
|
|
<Select
|
|
value={cfg.parseMode}
|
|
onValueChange={(v) => {
|
|
const mode = v as PopScannerConfig["parseMode"];
|
|
update({
|
|
parseMode: mode,
|
|
fieldMappings: mode === "none" ? [] : cfg.fieldMappings,
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(PARSE_MODE_LABELS).map(([key, label]) => (
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{cfg.parseMode === "auto" && (
|
|
<div className="mt-3 rounded-md bg-muted/50 p-3">
|
|
<p className="text-[10px] font-medium">자동 매칭 방식</p>
|
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
QR/바코드의 JSON 키가 연결된 컴포넌트의 필드명과 같으면 자동 입력됩니다.
|
|
</p>
|
|
{connectedFields.length > 0 && (
|
|
<div className="mt-2 space-y-1">
|
|
<p className="text-[10px] font-medium">연결된 필드 목록:</p>
|
|
{connectedFields.map((f, i) => (
|
|
<div key={i} className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
<span className="font-mono text-primary">{f.fieldName}</span>
|
|
<span>- {f.fieldLabel}</span>
|
|
<span className="text-muted-foreground/50">({f.componentName})</span>
|
|
</div>
|
|
))}
|
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
QR에 위 필드명이 JSON 키로 포함되면 자동 매칭됩니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{connectedFields.length === 0 && (
|
|
<p className="mt-2 text-[10px] text-muted-foreground">
|
|
연결 탭에서 스캐너와 다른 컴포넌트를 먼저 연결하세요.
|
|
연결 없이도 같은 화면의 모든 컴포넌트에 전역으로 전달됩니다.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{cfg.parseMode === "json" && (
|
|
<div className="mt-3 space-y-3">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
연결된 컴포넌트의 필드를 선택하고, 매핑할 JSON 키를 지정합니다.
|
|
필드명과 같은 JSON 키가 있으면 자동 매칭됩니다.
|
|
</p>
|
|
|
|
{connectedFields.length === 0 ? (
|
|
<div className="rounded-md bg-muted/50 p-3">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
연결 탭에서 스캐너와 다른 컴포넌트를 먼저 연결해주세요.
|
|
연결된 컴포넌트의 필드 목록이 여기에 표시됩니다.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold">필드 매핑</Label>
|
|
<div className="space-y-1.5">
|
|
{cfg.fieldMappings.map((mapping) => (
|
|
<div
|
|
key={`${mapping.targetComponentId}_${mapping.targetFieldName}`}
|
|
className="flex items-start gap-2 rounded-md border p-2"
|
|
>
|
|
<Checkbox
|
|
id={`map_${mapping.targetComponentId}_${mapping.targetFieldName}`}
|
|
checked={mapping.enabled}
|
|
onCheckedChange={() =>
|
|
toggleMapping(mapping.targetFieldName, mapping.targetComponentId)
|
|
}
|
|
className="mt-0.5"
|
|
/>
|
|
<div className="flex-1 space-y-1">
|
|
<label
|
|
htmlFor={`map_${mapping.targetComponentId}_${mapping.targetFieldName}`}
|
|
className="flex cursor-pointer items-center gap-1 text-[11px]"
|
|
>
|
|
<span className="font-mono text-primary">
|
|
{mapping.targetFieldName}
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
({mapping.label})
|
|
</span>
|
|
</label>
|
|
{mapping.enabled && (
|
|
<div className="flex items-center gap-1">
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
|
JSON 키:
|
|
</span>
|
|
<Input
|
|
value={mapping.sourceKey}
|
|
onChange={(e) =>
|
|
updateMappingSourceKey(
|
|
mapping.targetFieldName,
|
|
mapping.targetComponentId,
|
|
e.target.value,
|
|
)
|
|
}
|
|
placeholder={mapping.targetFieldName}
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{cfg.fieldMappings.some((m) => m.enabled) && (
|
|
<div className="rounded-md bg-muted/50 p-2">
|
|
<p className="text-[10px] font-medium text-muted-foreground">활성 매핑:</p>
|
|
<ul className="mt-1 space-y-0.5">
|
|
{cfg.fieldMappings
|
|
.filter((m) => m.enabled)
|
|
.map((m, i) => (
|
|
<li key={i} className="text-[10px] text-muted-foreground">
|
|
<span className="font-mono">{m.sourceKey || "?"}</span>
|
|
{" -> "}
|
|
<span className="font-mono text-primary">{m.targetFieldName}</span>
|
|
{m.label && <span className="ml-1">({m.label})</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 미리보기
|
|
// ========================================
|
|
|
|
function PopScannerPreview({ config }: { config?: PopScannerConfig }) {
|
|
const cfg = config || DEFAULT_SCANNER_CONFIG;
|
|
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
|
<Button
|
|
variant={cfg.buttonVariant}
|
|
size="icon"
|
|
className="pointer-events-none h-full w-full rounded-md"
|
|
tabIndex={-1}
|
|
>
|
|
<ScanLine className="h-7! w-7!" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 동적 sendable 생성
|
|
// ========================================
|
|
|
|
function buildSendableMeta(config?: PopScannerConfig) {
|
|
const base = [
|
|
{
|
|
key: "scan_value",
|
|
label: "스캔 값 (원본)",
|
|
type: "filter_value" as const,
|
|
category: "filter" as const,
|
|
description: "파싱 전 원본 스캔 결과 (단일 값 모드이거나 파싱 실패 시)",
|
|
},
|
|
];
|
|
|
|
if (config?.fieldMappings && config.fieldMappings.length > 0) {
|
|
for (const mapping of config.fieldMappings) {
|
|
base.push({
|
|
key: `scan_field_${mapping.outputIndex}`,
|
|
label: mapping.label || `스캔 필드 ${mapping.outputIndex}`,
|
|
type: "filter_value" as const,
|
|
category: "filter" as const,
|
|
description: `파싱된 필드: JSON 키 "${mapping.sourceKey}"`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
// ========================================
|
|
// 레지스트리 등록
|
|
// ========================================
|
|
|
|
PopComponentRegistry.registerComponent({
|
|
id: "pop-scanner",
|
|
name: "스캐너",
|
|
description: "바코드/QR 카메라 스캔",
|
|
category: "input",
|
|
icon: "ScanLine",
|
|
component: PopScannerComponent,
|
|
configPanel: PopScannerConfigPanel,
|
|
preview: PopScannerPreview,
|
|
defaultProps: DEFAULT_SCANNER_CONFIG,
|
|
connectionMeta: {
|
|
sendable: buildSendableMeta(),
|
|
receivable: [],
|
|
},
|
|
getDynamicConnectionMeta: (config: Record<string, unknown>) => ({
|
|
sendable: buildSendableMeta(config as unknown as PopScannerConfig),
|
|
receivable: [],
|
|
}),
|
|
touchOptimized: true,
|
|
supportedDevices: ["mobile", "tablet"],
|
|
});
|