ERP-node/frontend/lib/registry/pop-components/pop-scanner.tsx

695 lines
23 KiB
TypeScript

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