From 20ad1d6829be5b1ca13bfa7ef84b5e3ef4869cc0 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Mar 2026 19:52:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-scanner):=20=EB=B0=94=EC=BD=94?= =?UTF-8?q?=EB=93=9C/QR=20=EC=8A=A4=EC=BA=90=EB=84=88=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20+=20=EB=A9=80=ED=8B=B0=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=ED=8C=8C=EC=8B=B1=20+=20=EB=B0=98=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EB=AA=A8=EB=B0=94=EC=9D=BC/=ED=83=9C?= =?UTF-8?q?=EB=B8=94=EB=A6=BF=20=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B0=94=EC=BD=94=EB=93=9C=C2=B7QR=EC=9D=84=20=EC=B9=B4?= =?UTF-8?q?=EB=A9=94=EB=9D=BC=EB=A1=9C=20=EC=8A=A4=EC=BA=94=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EA=B2=80=EC=83=89=C2=B7=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EC=97=90=20=EA=B0=92=EC=9D=84=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A0=84=EB=8B=AC=ED=95=98=EB=8A=94=20pop-scanner?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=98=EA=B3=A0,=20JSON=20=ED=98=95=ED=83=9C?= =?UTF-8?q?=EC=9D=98=20=EB=A9=80=ED=8B=B0=ED=95=84=EB=93=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=97=AC=EB=9F=AC=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20=EB=B6=84=EB=B0=B0?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=8C=8C=EC=8B=B1=20=EC=B2=B4=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4.=20[pop-scanne?= =?UTF-8?q?r=20=EC=8B=A0=EA=B7=9C]=20-=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20?= =?UTF-8?q?=EC=8A=A4=EC=BA=94=20UI=20(BarcodeScanModal)=20+=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=A0=84=EC=9A=A9=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?-=20parseMode=203=EB=AA=A8=EB=93=9C:=20none(=EB=8B=A8=EC=9D=BC?= =?UTF-8?q?=EA=B0=92),=20auto(=EC=A0=84=EC=97=AD=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EB=A7=A4=EC=B9=AD),=20json(=EB=B0=98=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91)=20-=20auto:=20scan=5Fauto=5Ffill=20?= =?UTF-8?q?=EC=A0=84=EC=97=AD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A1=9C=20fie?= =?UTF-8?q?ldName=20=EA=B8=B0=EC=A4=80=20=EC=9E=90=EB=8F=99=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20-=20json:=20=EC=97=B0=EA=B2=B0=EB=90=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=95=84=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=91=9C=EC=8B=9C,=20=20=20fieldName=3D?= =?UTF-8?q?=3DJSON=ED=82=A4=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=EC=B9=AD=20+?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=EC=9E=90=20override(enabled/sourceKey)=20?= =?UTF-8?q?-=20getDynamicConnectionMeta=EB=A1=9C=20parseMode=EB=B3=84=20se?= =?UTF-8?q?ndable=20=EB=8F=99=EC=A0=81=20=EC=83=9D=EC=84=B1=20[pop-field?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99]=20-=20scan=5Fauto=5Ffill=20=EA=B5=AC?= =?UTF-8?q?=EB=8F=85:=20sections.fields=EC=9D=98=20fieldName=EA=B3=BC=20JS?= =?UTF-8?q?ON=20=ED=82=A4=20=EB=A7=A4=EC=B9=AD=20-=20columnMapping=20?= =?UTF-8?q?=ED=82=A4=EB=A5=BC=20fieldName=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20(fieldId=E2=86=92fieldName)=20?= =?UTF-8?q?-=20targetColumn=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20fieldName?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EB=8F=99=EA=B8=B0=ED=99=94=20-=20?= =?UTF-8?q?=EC=83=88=20=ED=95=84=EB=93=9C=20fieldName=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=EC=9D=84=20=EB=B9=88=20=EB=AC=B8=EC=9E=90=EC=97=B4?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20[pop-search=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99]=20-=20scan=5Fauto=5Ffill=20=EA=B5=AC=EB=8F=85:=20fil?= =?UTF-8?q?terColumns=20=EC=A0=84=EC=B2=B4=20=ED=82=A4=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20-=20set=5Fvalue=20=EC=88=98=EC=8B=A0=20=EC=8B=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=83=80=EC=9E=85=EC=9D=B4=EB=A9=B4=20mod?= =?UTF-8?q?alDisplayText=EB=8F=84=20=EA=B0=B1=EC=8B=A0=20[BarcodeScanModal?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0]=20-=20=EB=AA=A8=EB=8B=AC=20=EC=97=B4?= =?UTF-8?q?=EB=A6=B4=20=EB=95=8C=20=EC=83=81=ED=83=9C=20=EB=A6=AC=EC=85=8B?= =?UTF-8?q?=20(scannedCode/error/isScanning)=20-=20"=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=8A=A4=EC=BA=94"=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20-=20=EC=8A=A4=EC=BA=94=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=ED=99=95=EB=8C=80=20(h-3/5=20w-4/5)=20[ge?= =?UTF-8?q?tConnectedFields=20=ED=95=84=EB=93=9C=20=EC=B6=94=EC=B6=9C]=20-?= =?UTF-8?q?=20filterColumns(=EB=B3=B5=EC=88=98)=20>=20modalConfig.valueFie?= =?UTF-8?q?ld=20>=20fieldName=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20-?= =?UTF-8?q?=20pop-field=20sections.fields[].fieldName=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/BarcodeScanModal.tsx | 22 +- .../pop/designer/panels/ComponentPalette.tsx | 8 +- .../pop/designer/types/pop-layout.ts | 3 +- frontend/lib/registry/PopComponentRegistry.ts | 1 + frontend/lib/registry/pop-components/index.ts | 1 + .../pop-field/PopFieldComponent.tsx | 30 +- .../pop-field/PopFieldConfig.tsx | 15 +- .../registry/pop-components/pop-scanner.tsx | 694 ++++++++++++++++++ .../pop-search/PopSearchComponent.tsx | 29 +- 9 files changed, 792 insertions(+), 11 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-scanner.tsx diff --git a/frontend/components/common/BarcodeScanModal.tsx b/frontend/components/common/BarcodeScanModal.tsx index 34706b8c..36c01ef3 100644 --- a/frontend/components/common/BarcodeScanModal.tsx +++ b/frontend/components/common/BarcodeScanModal.tsx @@ -42,11 +42,13 @@ export const BarcodeScanModal: React.FC = ({ const codeReaderRef = useRef(null); const scanIntervalRef = useRef(null); - // 바코드 리더 초기화 + // 바코드 리더 초기화 + 모달 열릴 때 상태 리셋 useEffect(() => { if (open) { + setScannedCode(""); + setError(""); + setIsScanning(false); codeReaderRef.current = new BrowserMultiFormatReader(); - // 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청 } return () => { @@ -277,7 +279,7 @@ export const BarcodeScanModal: React.FC = ({ {/* 스캔 가이드 오버레이 */} {isScanning && (
-
+
@@ -356,6 +358,20 @@ export const BarcodeScanModal: React.FC = ({ )} + {scannedCode && ( + + )} + {scannedCode && !autoSubmit && ( + + {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"], +}); diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index 44fc6dcc..7c5f426d 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -112,15 +112,40 @@ export function PopSearchComponent({ const unsub = subscribe( `__comp_input__${componentId}__set_value`, (payload: unknown) => { - const data = payload as { value?: unknown } | unknown; + const data = payload as { value?: unknown; displayText?: string } | unknown; const incoming = typeof data === "object" && data && "value" in data ? (data as { value: unknown }).value : data; + if (isModalType && incoming != null) { + setModalDisplayText(String(incoming)); + } emitFilterChanged(incoming); } ); return unsub; - }, [componentId, subscribe, emitFilterChanged]); + }, [componentId, subscribe, emitFilterChanged, isModalType]); + + useEffect(() => { + const unsub = subscribe("scan_auto_fill", (payload: unknown) => { + const data = payload as Record | null; + if (!data || typeof data !== "object") return; + const myKey = config.fieldName; + if (!myKey) return; + const targetKeys = config.filterColumns?.length ? config.filterColumns : [myKey]; + for (const key of targetKeys) { + if (key in data) { + if (isModalType) setModalDisplayText(String(data[key])); + emitFilterChanged(data[key]); + return; + } + } + if (myKey in data) { + if (isModalType) setModalDisplayText(String(data[myKey])); + emitFilterChanged(data[myKey]); + } + }); + return unsub; + }, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]); const handleModalOpen = useCallback(() => { if (!config.modalConfig) return;