"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 | null { try { const parsed = JSON.parse(raw); if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { const result: Record = {}; 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 | 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; const compType = comp.type || ""; const compName = (comp as Record).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 (
{cfg.showLastScan && lastScan && (
{lastScan}
)} {!isDesignMode && ( )}
); } // ======================================== // 설정 패널 // ======================================== const FORMAT_LABELS: Record = { all: "모든 형식", "1d": "1D 바코드", "2d": "2D 바코드 (QR)", }; const VARIANT_LABELS: Record = { default: "기본 (Primary)", outline: "외곽선 (Outline)", secondary: "보조 (Secondary)", }; const PARSE_MODE_LABELS: Record = { 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) => { 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 (

인식할 바코드 종류를 선택합니다

update({ buttonLabel: e.target.value })} placeholder="스캔" className="h-8 text-xs" />

{cfg.autoSubmit ? "바코드 인식 즉시 값 전달 (확인 버튼 생략)" : "인식 후 확인 버튼을 눌러야 값 전달"}

update({ autoSubmit: v })} />

버튼 아래에 마지막 스캔값 표시

update({ showLastScan: v })} />
{/* 파싱 설정 섹션 */}

바코드/QR에 여러 정보가 담긴 경우, 파싱하여 각각 다른 컴포넌트에 전달

{cfg.parseMode === "auto" && (

자동 매칭 방식

QR/바코드의 JSON 키가 연결된 컴포넌트의 필드명과 같으면 자동 입력됩니다.

{connectedFields.length > 0 && (

연결된 필드 목록:

{connectedFields.map((f, i) => (
{f.fieldName} - {f.fieldLabel} ({f.componentName})
))}

QR에 위 필드명이 JSON 키로 포함되면 자동 매칭됩니다.

)} {connectedFields.length === 0 && (

연결 탭에서 스캐너와 다른 컴포넌트를 먼저 연결하세요. 연결 없이도 같은 화면의 모든 컴포넌트에 전역으로 전달됩니다.

)}
)} {cfg.parseMode === "json" && (

연결된 컴포넌트의 필드를 선택하고, 매핑할 JSON 키를 지정합니다. 필드명과 같은 JSON 키가 있으면 자동 매칭됩니다.

{connectedFields.length === 0 ? (

연결 탭에서 스캐너와 다른 컴포넌트를 먼저 연결해주세요. 연결된 컴포넌트의 필드 목록이 여기에 표시됩니다.

) : (
{cfg.fieldMappings.map((mapping) => (
toggleMapping(mapping.targetFieldName, mapping.targetComponentId) } className="mt-0.5" />
{mapping.enabled && (
JSON 키: updateMappingSourceKey( mapping.targetFieldName, mapping.targetComponentId, e.target.value, ) } placeholder={mapping.targetFieldName} className="h-6 text-[10px]" />
)}
))}
{cfg.fieldMappings.some((m) => m.enabled) && (

활성 매핑:

    {cfg.fieldMappings .filter((m) => m.enabled) .map((m, i) => (
  • {m.sourceKey || "?"} {" -> "} {m.targetFieldName} {m.label && ({m.label})}
  • ))}
)}
)}
)}
); } // ======================================== // 미리보기 // ======================================== function PopScannerPreview({ config }: { config?: PopScannerConfig }) { const cfg = config || DEFAULT_SCANNER_CONFIG; return (
); } // ======================================== // 동적 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) => ({ sendable: buildSendableMeta(config as unknown as PopScannerConfig), receivable: [], }), touchOptimized: true, supportedDevices: ["mobile", "tablet"], });