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 scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 바코드 리더 초기화
|
||||
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setScannedCode("");
|
||||
setError("");
|
||||
setIsScanning(false);
|
||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||
// 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
|
@ -277,7 +279,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
{/* 스캔 가이드 오버레이 */}
|
||||
{isScanning && (
|
||||
<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="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" />
|
||||
|
|
@ -356,6 +358,20 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
</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 && (
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -69,6 +69,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: TextCursorInput,
|
||||
description: "저장용 값 입력 (섹션별 멀티필드)",
|
||||
},
|
||||
{
|
||||
type: "pop-scanner",
|
||||
label: "스캐너",
|
||||
icon: ScanLine,
|
||||
description: "바코드/QR 카메라 스캔",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
/**
|
||||
* 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-search": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
||||
"pop-scanner": { colSpan: 1, rowSpan: 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export interface PopComponentDefinition {
|
|||
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
||||
defaultProps?: Record<string, any>;
|
||||
connectionMeta?: ComponentConnectionMeta;
|
||||
getDynamicConnectionMeta?: (config: Record<string, unknown>) => ComponentConnectionMeta;
|
||||
// POP 전용 속성
|
||||
touchOptimized?: boolean;
|
||||
minTouchArea?: number;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import "./pop-string-list";
|
|||
import "./pop-search";
|
||||
|
||||
import "./pop-field";
|
||||
import "./pop-scanner";
|
||||
|
||||
// 향후 추가될 컴포넌트들:
|
||||
// import "./pop-list";
|
||||
|
|
|
|||
|
|
@ -203,6 +203,32 @@ export function PopFieldComponent({
|
|||
return unsub;
|
||||
}, [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 응답
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
|
|
@ -220,7 +246,7 @@ export function PopFieldComponent({
|
|||
? {
|
||||
targetTable: cfg.saveConfig.tableName,
|
||||
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 || [])
|
||||
.filter((m) => m.numberingRuleId)
|
||||
|
|
@ -248,7 +274,7 @@ export function PopFieldComponent({
|
|||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe, publish, allValues, cfg.saveConfig]);
|
||||
}, [componentId, subscribe, publish, allValues, cfg.saveConfig, fieldIdToName]);
|
||||
|
||||
// 필드 값 변경 핸들러
|
||||
const handleFieldChange = useCallback(
|
||||
|
|
|
|||
|
|
@ -398,8 +398,19 @@ function SaveTabContent({
|
|||
syncAndUpdateSaveMappings((prev) =>
|
||||
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 = {
|
||||
id: fieldId,
|
||||
inputType: "text",
|
||||
fieldName: fieldId,
|
||||
fieldName: "",
|
||||
labelText: "",
|
||||
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(
|
||||
`__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<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(() => {
|
||||
if (!config.modalConfig) return;
|
||||
|
|
|
|||
Loading…
Reference in New Issue