feat(pop-scanner): 바코드/QR 스캐너 컴포넌트 + 멀티필드 파싱 + 반자동 매핑
모바일/태블릿 환경에서 바코드·QR을 카메라로 스캔하여 검색·입력 필드에 값을 자동 전달하는 pop-scanner 컴포넌트를 추가하고, JSON 형태의 멀티필드 데이터를 여러 컴포넌트에 분배하는 파싱 체계를 구현한다. [pop-scanner 신규] - 카메라 스캔 UI (BarcodeScanModal) + 아이콘 전용 버튼 - parseMode 3모드: none(단일값), auto(전역 자동매칭), json(반자동 매핑) - auto: scan_auto_fill 전역 이벤트로 fieldName 기준 자동 입력 - json: 연결된 컴포넌트 필드를 체크박스 목록으로 표시, fieldName==JSON키 자동 매칭 + 관리자 override(enabled/sourceKey) - getDynamicConnectionMeta로 parseMode별 sendable 동적 생성 [pop-field 연동] - scan_auto_fill 구독: sections.fields의 fieldName과 JSON 키 매칭 - columnMapping 키를 fieldName 기준으로 통일 (fieldId→fieldName) - targetColumn 선택 시 fieldName 자동 동기화 - 새 필드 fieldName 기본값을 빈 문자열로 변경 [pop-search 연동] - scan_auto_fill 구독: filterColumns 전체 키 매칭 - set_value 수신 시 모달 타입이면 modalDisplayText도 갱신 [BarcodeScanModal 개선] - 모달 열릴 때 상태 리셋 (scannedCode/error/isScanning) - "다시 스캔" 버튼 추가 - 스캔 가이드 영역 확대 (h-3/5 w-4/5) [getConnectedFields 필드 추출] - filterColumns(복수) > modalConfig.valueField > fieldName 우선순위 - pop-field sections.fields[].fieldName 추출
This commit is contained in:
parent
955da6ae87
commit
20ad1d6829
|
|
@ -42,11 +42,13 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
||||||
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// 바코드 리더 초기화
|
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
setScannedCode("");
|
||||||
|
setError("");
|
||||||
|
setIsScanning(false);
|
||||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||||
// 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -277,7 +279,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
{/* 스캔 가이드 오버레이 */}
|
{/* 스캔 가이드 오버레이 */}
|
||||||
{isScanning && (
|
{isScanning && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
|
<div className="h-3/5 w-4/5 rounded-lg border-4 border-primary/70 animate-pulse" />
|
||||||
<div className="absolute bottom-4 left-0 right-0 text-center">
|
<div className="absolute bottom-4 left-0 right-0 text-center">
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
|
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
|
||||||
<Scan className="h-4 w-4 animate-pulse text-primary" />
|
<Scan className="h-4 w-4 animate-pulse text-primary" />
|
||||||
|
|
@ -356,6 +358,20 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{scannedCode && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setScannedCode("");
|
||||||
|
startScanning();
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<Camera className="mr-2 h-4 w-4" />
|
||||||
|
다시 스캔
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{scannedCode && !autoSubmit && (
|
{scannedCode && !autoSubmit && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
import { PopComponentType } from "../types/pop-layout";
|
||||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
|
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine } from "lucide-react";
|
||||||
import { DND_ITEM_TYPES } from "../constants";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
|
|
||||||
// 컴포넌트 정의
|
// 컴포넌트 정의
|
||||||
|
|
@ -69,6 +69,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: TextCursorInput,
|
icon: TextCursorInput,
|
||||||
description: "저장용 값 입력 (섹션별 멀티필드)",
|
description: "저장용 값 입력 (섹션별 멀티필드)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-scanner",
|
||||||
|
label: "스캐너",
|
||||||
|
icon: ScanLine,
|
||||||
|
description: "바코드/QR 카메라 스캔",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* POP 컴포넌트 타입
|
||||||
*/
|
*/
|
||||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field";
|
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field" | "pop-scanner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 흐름 정의
|
* 데이터 흐름 정의
|
||||||
|
|
@ -362,6 +362,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
|
||||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||||
"pop-search": { colSpan: 2, rowSpan: 1 },
|
"pop-search": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
"pop-field": { colSpan: 6, rowSpan: 2 },
|
||||||
|
"pop-scanner": { colSpan: 1, rowSpan: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export interface PopComponentDefinition {
|
||||||
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
||||||
defaultProps?: Record<string, any>;
|
defaultProps?: Record<string, any>;
|
||||||
connectionMeta?: ComponentConnectionMeta;
|
connectionMeta?: ComponentConnectionMeta;
|
||||||
|
getDynamicConnectionMeta?: (config: Record<string, unknown>) => ComponentConnectionMeta;
|
||||||
// POP 전용 속성
|
// POP 전용 속성
|
||||||
touchOptimized?: boolean;
|
touchOptimized?: boolean;
|
||||||
minTouchArea?: number;
|
minTouchArea?: number;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import "./pop-string-list";
|
||||||
import "./pop-search";
|
import "./pop-search";
|
||||||
|
|
||||||
import "./pop-field";
|
import "./pop-field";
|
||||||
|
import "./pop-scanner";
|
||||||
|
|
||||||
// 향후 추가될 컴포넌트들:
|
// 향후 추가될 컴포넌트들:
|
||||||
// import "./pop-list";
|
// import "./pop-list";
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,32 @@ export function PopFieldComponent({
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
|
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe("scan_auto_fill", (payload: unknown) => {
|
||||||
|
const data = payload as Record<string, unknown> | null;
|
||||||
|
if (!data || typeof data !== "object") return;
|
||||||
|
|
||||||
|
const fieldNames = new Set<string>();
|
||||||
|
for (const section of cfg.sections) {
|
||||||
|
for (const f of section.fields ?? []) {
|
||||||
|
if (f.fieldName) fieldNames.add(f.fieldName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (fieldNames.has(key)) {
|
||||||
|
matched[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(matched).length > 0) {
|
||||||
|
setAllValues((prev) => ({ ...prev, ...matched }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe, cfg.sections]);
|
||||||
|
|
||||||
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답
|
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!componentId) return;
|
if (!componentId) return;
|
||||||
|
|
@ -220,7 +246,7 @@ export function PopFieldComponent({
|
||||||
? {
|
? {
|
||||||
targetTable: cfg.saveConfig.tableName,
|
targetTable: cfg.saveConfig.tableName,
|
||||||
columnMapping: Object.fromEntries(
|
columnMapping: Object.fromEntries(
|
||||||
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
|
(cfg.saveConfig.fieldMappings || []).map((m) => [fieldIdToName[m.fieldId] || m.fieldId, m.targetColumn])
|
||||||
),
|
),
|
||||||
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
|
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
|
||||||
.filter((m) => m.numberingRuleId)
|
.filter((m) => m.numberingRuleId)
|
||||||
|
|
@ -248,7 +274,7 @@ export function PopFieldComponent({
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [componentId, subscribe, publish, allValues, cfg.saveConfig]);
|
}, [componentId, subscribe, publish, allValues, cfg.saveConfig, fieldIdToName]);
|
||||||
|
|
||||||
// 필드 값 변경 핸들러
|
// 필드 값 변경 핸들러
|
||||||
const handleFieldChange = useCallback(
|
const handleFieldChange = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -398,8 +398,19 @@ function SaveTabContent({
|
||||||
syncAndUpdateSaveMappings((prev) =>
|
syncAndUpdateSaveMappings((prev) =>
|
||||||
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m))
|
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (partial.targetColumn !== undefined) {
|
||||||
|
const newFieldName = partial.targetColumn || "";
|
||||||
|
const sections = cfg.sections.map((s) => ({
|
||||||
|
...s,
|
||||||
|
fields: (s.fields ?? []).map((f) =>
|
||||||
|
f.id === fieldId ? { ...f, fieldName: newFieldName } : f
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
onUpdateConfig({ sections });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[syncAndUpdateSaveMappings]
|
[syncAndUpdateSaveMappings, cfg, onUpdateConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- 숨은 필드 매핑 로직 ---
|
// --- 숨은 필드 매핑 로직 ---
|
||||||
|
|
@ -1426,7 +1437,7 @@ function SectionEditor({
|
||||||
const newField: PopFieldItem = {
|
const newField: PopFieldItem = {
|
||||||
id: fieldId,
|
id: fieldId,
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
fieldName: fieldId,
|
fieldName: "",
|
||||||
labelText: "",
|
labelText: "",
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,694 @@
|
||||||
|
"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"],
|
||||||
|
});
|
||||||
|
|
@ -112,15 +112,40 @@ export function PopSearchComponent({
|
||||||
const unsub = subscribe(
|
const unsub = subscribe(
|
||||||
`__comp_input__${componentId}__set_value`,
|
`__comp_input__${componentId}__set_value`,
|
||||||
(payload: unknown) => {
|
(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
|
const incoming = typeof data === "object" && data && "value" in data
|
||||||
? (data as { value: unknown }).value
|
? (data as { value: unknown }).value
|
||||||
: data;
|
: data;
|
||||||
|
if (isModalType && incoming != null) {
|
||||||
|
setModalDisplayText(String(incoming));
|
||||||
|
}
|
||||||
emitFilterChanged(incoming);
|
emitFilterChanged(incoming);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [componentId, subscribe, emitFilterChanged]);
|
}, [componentId, subscribe, emitFilterChanged, isModalType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe("scan_auto_fill", (payload: unknown) => {
|
||||||
|
const data = payload as Record<string, unknown> | 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(() => {
|
const handleModalOpen = useCallback(() => {
|
||||||
if (!config.modalConfig) return;
|
if (!config.modalConfig) return;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue