From 47384e1c2ba934b61b51eb138d3c9ee9f045a9f9 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Mar 2026 11:00:31 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(pop-search):=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=ED=83=AD=20=EC=9D=BC=EA=B4=80=EC=84=B1=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?+=20=ED=95=84=ED=84=B0=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=84=A4=EC=A0=95=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EA=B2=80=EC=83=89=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=9D=98=20=EC=97=B0=EA=B2=B0=20=ED=83=AD?= =?UTF-8?q?=EC=9D=B4=20=EB=8B=A4=EB=A5=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8(=EC=9E=85=EB=A0=A5,=20=EC=B9=B4=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8)=EC=99=80=20=EB=8B=AC=EB=A6=AC=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=84=A4=EC=A0=95=EC=9D=B4=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=ED=8F=BC=EC=97=90=20=ED=8F=AC=ED=95=A8=EB=90=98=EC=96=B4=20?= =?UTF-8?q?=EC=9E=88=EB=8D=98=20=EB=B9=84=EC=9D=BC=EA=B4=80=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=ED=95=B4=EA=B2=B0=ED=95=9C=EB=8B=A4.=20-=20Connect?= =?UTF-8?q?ionEditor:=20FilterConnectionForm=20=EC=A0=9C=EA=B1=B0,=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=20=20SimpleConnectionForm=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=ED=86=B5=ED=95=A9=20-=20PopSearc?= =?UTF-8?q?hConfig:=20=EC=83=81=EC=84=B8=EC=84=A4=EC=A0=95=20=ED=83=AD?= =?UTF-8?q?=EC=97=90=20FilterConnectionSection=20=EC=B6=94=EA=B0=80=20=20?= =?UTF-8?q?=20(text/select/date-preset/modal=20=ED=83=80=EC=9E=85=EB=B3=84?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9)=20-=20FilterConnectionSection:=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EB=90=9C=20=EB=8C=80=EC=83=81=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=9D=98=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=BB=AC=EB=9F=BC=EC=9D=84=20=20=20API=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EC=97=AC=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EA=B8=B0=EB=B0=98=20=EB=B3=B5=EC=88=98=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20UI=20=EC=A0=9C=EA=B3=B5=20=20=20("=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=ED=91=9C=EC=8B=9C=20=EC=A4=91"?= =?UTF-8?q?=20/=20"=EA=B8=B0=ED=83=80=20=EC=BB=AC=EB=9F=BC"=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EA=B5=AC=EB=B6=84)=20-=20types:=20SearchFilterMode?= =?UTF-8?q?,=20filterColumns=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20PopSearchComponent:=20filterColumns=20=EB=B0=B0=EC=97=B4?= =?UTF-8?q?=EC=9D=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20payload=EC=97=90=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=20-=20useConnectionResolver:=20filterColumns?= =?UTF-8?q?=EB=A5=BC=20targetColumns=EB=A1=9C=20=EC=A0=84=EB=8B=AC,=20=20?= =?UTF-8?q?=20auto-match=EC=97=90=EC=84=9C=20filter=5Fvalue=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=A7=A4=EC=B9=AD=20+=20filterConfig=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B6=94=EB=A1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop/designer/panels/ConnectionEditor.tsx | 464 +----------------- frontend/hooks/pop/useConnectionResolver.ts | 68 ++- .../pop-search/PopSearchComponent.tsx | 9 +- .../pop-search/PopSearchConfig.tsx | 306 +++++++++++- .../pop-components/pop-search/types.ts | 17 + 5 files changed, 386 insertions(+), 478 deletions(-) diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 52c8102f..747b8f70 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -1,11 +1,9 @@ "use client"; import React from "react"; -import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react"; +import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, @@ -19,9 +17,7 @@ import { } from "../types/pop-layout"; import { PopComponentRegistry, - type ComponentConnectionMeta, } from "@/lib/registry/PopComponentRegistry"; -import { getTableColumns } from "@/lib/api/tableManagement"; // ======================================== // Props @@ -36,15 +32,6 @@ interface ConnectionEditorProps { onRemoveConnection?: (connectionId: string) => void; } -// ======================================== -// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단 -// ======================================== - -function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean { - if (!meta?.sendable) return false; - return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value"); -} - // ======================================== // ConnectionEditor // ======================================== @@ -84,17 +71,13 @@ export default function ConnectionEditor({ ); } - const isFilterSource = hasFilterSendable(meta); - return (
{hasSendable && ( ; - const cols: string[] = []; - - if (Array.isArray(cfg.listColumns)) { - (cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => { - if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName); - }); - } - - if (Array.isArray(cfg.selectedColumns)) { - (cfg.selectedColumns as string[]).forEach((c) => { - if (!cols.includes(c)) cols.push(c); - }); - } - - return cols; -} - -function extractTableName(comp: PopComponentDefinitionV5 | undefined): string { - if (!comp?.config) return ""; - const cfg = comp.config as Record; - const ds = cfg.dataSource as { tableName?: string } | undefined; - return ds?.tableName || ""; -} - // ======================================== // 보내기 섹션 // ======================================== interface SendSectionProps { component: PopComponentDefinitionV5; - meta: ComponentConnectionMeta; allComponents: PopComponentDefinitionV5[]; outgoing: PopDataConnection[]; - isFilterSource: boolean; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; onRemoveConnection?: (connectionId: string) => void; @@ -160,10 +110,8 @@ interface SendSectionProps { function SendSection({ component, - meta, allComponents, outgoing, - isFilterSource, onAddConnection, onUpdateConnection, onRemoveConnection, @@ -180,32 +128,17 @@ function SendSection({ {outgoing.map((conn) => (
{editingId === conn.id ? ( - isFilterSource ? ( - { - onUpdateConnection?.(conn.id, data); - setEditingId(null); - }} - onCancel={() => setEditingId(null)} - submitLabel="수정" - /> - ) : ( - { - onUpdateConnection?.(conn.id, data); - setEditingId(null); - }} - onCancel={() => setEditingId(null)} - submitLabel="수정" - /> - ) + { + onUpdateConnection?.(conn.id, data); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + submitLabel="수정" + /> ) : (
@@ -230,22 +163,12 @@ function SendSection({
))} - {isFilterSource ? ( - onAddConnection?.(data)} - submitLabel="연결 추가" - /> - ) : ( - onAddConnection?.(data)} - submitLabel="연결 추가" - /> - )} + onAddConnection?.(data)} + submitLabel="연결 추가" + />
); } @@ -350,328 +273,6 @@ function SimpleConnectionForm({ ); } -// ======================================== -// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지) -// ======================================== - -interface FilterConnectionFormProps { - component: PopComponentDefinitionV5; - meta: ComponentConnectionMeta; - allComponents: PopComponentDefinitionV5[]; - initial?: PopDataConnection; - onSubmit: (data: Omit) => void; - onCancel?: () => void; - submitLabel: string; -} - -function FilterConnectionForm({ - component, - meta, - allComponents, - initial, - onSubmit, - onCancel, - submitLabel, -}: FilterConnectionFormProps) { - const [selectedOutput, setSelectedOutput] = React.useState( - initial?.sourceOutput || meta.sendable[0]?.key || "" - ); - const [selectedTargetId, setSelectedTargetId] = React.useState( - initial?.targetComponent || "" - ); - const [selectedTargetInput, setSelectedTargetInput] = React.useState( - initial?.targetInput || "" - ); - const [filterColumns, setFilterColumns] = React.useState( - initial?.filterConfig?.targetColumns || - (initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : []) - ); - const [filterMode, setFilterMode] = React.useState< - "equals" | "contains" | "starts_with" | "range" - >(initial?.filterConfig?.filterMode || "contains"); - - const targetCandidates = allComponents.filter((c) => { - if (c.id === component.id) return false; - const reg = PopComponentRegistry.getComponent(c.type); - return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; - }); - - const targetComp = selectedTargetId - ? allComponents.find((c) => c.id === selectedTargetId) - : null; - - const targetMeta = targetComp - ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta - : null; - - React.useEffect(() => { - if (!selectedOutput || !targetMeta?.receivable?.length) return; - if (selectedTargetInput) return; - - const receivables = targetMeta.receivable; - const exactMatch = receivables.find((r) => r.key === selectedOutput); - if (exactMatch) { - setSelectedTargetInput(exactMatch.key); - return; - } - if (receivables.length === 1) { - setSelectedTargetInput(receivables[0].key); - } - }, [selectedOutput, targetMeta, selectedTargetInput]); - - const displayColumns = React.useMemo( - () => extractDisplayColumns(targetComp || undefined), - [targetComp] - ); - - const tableName = React.useMemo( - () => extractTableName(targetComp || undefined), - [targetComp] - ); - const [allDbColumns, setAllDbColumns] = React.useState([]); - const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false); - - React.useEffect(() => { - if (!tableName) { - setAllDbColumns([]); - return; - } - let cancelled = false; - setDbColumnsLoading(true); - getTableColumns(tableName).then((res) => { - if (cancelled) return; - if (res.success && res.data?.columns) { - setAllDbColumns(res.data.columns.map((c) => c.columnName)); - } else { - setAllDbColumns([]); - } - setDbColumnsLoading(false); - }); - return () => { cancelled = true; }; - }, [tableName]); - - const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]); - const dataOnlyColumns = React.useMemo( - () => allDbColumns.filter((c) => !displaySet.has(c)), - [allDbColumns, displaySet] - ); - const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0; - - const toggleColumn = (col: string) => { - setFilterColumns((prev) => - prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col] - ); - }; - - const handleSubmit = () => { - if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return; - - const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput); - - onSubmit({ - sourceComponent: component.id, - sourceField: "", - sourceOutput: selectedOutput, - targetComponent: selectedTargetId, - targetField: "", - targetInput: selectedTargetInput, - filterConfig: - !isEvent && filterColumns.length > 0 - ? { - targetColumn: filterColumns[0], - targetColumns: filterColumns, - filterMode, - } - : undefined, - label: buildConnectionLabel( - component, - selectedOutput, - allComponents.find((c) => c.id === selectedTargetId), - selectedTargetInput, - filterColumns - ), - }); - - if (!initial) { - setSelectedTargetId(""); - setSelectedTargetInput(""); - setFilterColumns([]); - } - }; - - return ( -
- {onCancel && ( -
-

연결 수정

- -
- )} - {!onCancel && ( -

새 연결 추가

- )} - -
- 보내는 값 - -
- -
- 받는 컴포넌트 - -
- - {targetMeta && ( -
- 받는 방식 - -
- )} - - {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && ( -
-

필터할 컬럼

- - {dbColumnsLoading ? ( -
- - 컬럼 조회 중... -
- ) : hasAnyColumns ? ( -
- {displayColumns.length > 0 && ( -
-

화면 표시 컬럼

- {displayColumns.map((col) => ( -
- toggleColumn(col)} - /> - -
- ))} -
- )} - - {dataOnlyColumns.length > 0 && ( -
- {displayColumns.length > 0 && ( -
- )} -

데이터 전용 컬럼

- {dataOnlyColumns.map((col) => ( -
- toggleColumn(col)} - /> - -
- ))} -
- )} -
- ) : ( - setFilterColumns(e.target.value ? [e.target.value] : [])} - placeholder="컬럼명 입력" - className="h-7 text-xs" - /> - )} - - {filterColumns.length > 0 && ( -

- {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시 -

- )} - -
-

필터 방식

- -
-
- )} - - -
- ); -} - // ======================================== // 받기 섹션 (읽기 전용: 연결된 소스만 표시) // ======================================== @@ -722,32 +323,3 @@ function ReceiveSection({ ); } -// ======================================== -// 유틸 -// ======================================== - -function isEventTypeConnection( - sourceMeta: ComponentConnectionMeta | undefined, - outputKey: string, - targetMeta: ComponentConnectionMeta | null | undefined, - inputKey: string, -): boolean { - const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey); - const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey); - return sourceItem?.type === "event" || targetItem?.type === "event"; -} - -function buildConnectionLabel( - source: PopComponentDefinitionV5, - _outputKey: string, - target: PopComponentDefinitionV5 | undefined, - _inputKey: string, - columns?: string[] -): string { - const srcLabel = source.label || source.id; - const tgtLabel = target?.label || target?.id || "?"; - const colInfo = columns && columns.length > 0 - ? ` [${columns.join(", ")}]` - : ""; - return `${srcLabel} → ${tgtLabel}${colInfo}`; -} diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts index 14bd321a..a778f35f 100644 --- a/frontend/hooks/pop/useConnectionResolver.ts +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent"; import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout"; import { PopComponentRegistry, - type ConnectionMetaItem, } from "@/lib/registry/PopComponentRegistry"; interface UseConnectionResolverOptions { @@ -29,14 +28,21 @@ interface UseConnectionResolverOptions { componentTypes?: Map; } +interface AutoMatchPair { + sourceKey: string; + targetKey: string; + isFilter: boolean; +} + /** - * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다. - * 규칙: category="event"이고 key가 동일한 쌍 + * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 쌍을 찾는다. + * 규칙 1: category="event"이고 key가 동일한 쌍 (이벤트 매칭) + * 규칙 2: 소스 type="filter_value" + 타겟 type="filter_value" (필터 매칭) */ function getAutoMatchPairs( sourceType: string, targetType: string -): { sourceKey: string; targetKey: string }[] { +): AutoMatchPair[] { const sourceDef = PopComponentRegistry.getComponent(sourceType); const targetDef = PopComponentRegistry.getComponent(targetType); @@ -44,14 +50,15 @@ function getAutoMatchPairs( return []; } - const pairs: { sourceKey: string; targetKey: string }[] = []; + const pairs: AutoMatchPair[] = []; for (const s of sourceDef.connectionMeta.sendable) { - if (s.category !== "event") continue; for (const r of targetDef.connectionMeta.receivable) { - if (r.category !== "event") continue; - if (s.key === r.key) { - pairs.push({ sourceKey: s.key, targetKey: r.key }); + if (s.category === "event" && r.category === "event" && s.key === r.key) { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false }); + } + if (s.type === "filter_value" && r.type === "filter_value") { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true }); } } } @@ -93,10 +100,24 @@ export function useConnectionResolver({ const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`; const unsub = subscribe(sourceEvent, (payload: unknown) => { - publish(targetEvent, { - value: payload, - _connectionId: conn.id, - }); + if (pair.isFilter) { + const data = payload as Record | null; + const fieldName = data?.fieldName as string | undefined; + const filterColumns = data?.filterColumns as string[] | undefined; + const filterMode = (data?.filterMode as string) || "contains"; + publish(targetEvent, { + value: payload, + filterConfig: fieldName + ? { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode } + : conn.filterConfig, + _connectionId: conn.id, + }); + } else { + publish(targetEvent, { + value: payload, + _connectionId: conn.id, + }); + } }); unsubscribers.push(unsub); } @@ -121,13 +142,22 @@ export function useConnectionResolver({ const unsub = subscribe(sourceEvent, (payload: unknown) => { const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; - const enrichedPayload = { - value: payload, - filterConfig: conn.filterConfig, - _connectionId: conn.id, - }; + let resolvedFilterConfig = conn.filterConfig; + if (!resolvedFilterConfig) { + const data = payload as Record | null; + const fieldName = data?.fieldName as string | undefined; + const filterColumns = data?.filterColumns as string[] | undefined; + if (fieldName) { + const filterMode = (data?.filterMode as string) || "contains"; + resolvedFilterConfig = { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode: filterMode as "equals" | "contains" | "starts_with" | "range" }; + } + } - publish(targetEvent, enrichedPayload); + publish(targetEvent, { + value: payload, + filterConfig: resolvedFilterConfig, + _connectionId: conn.id, + }); }); unsubscribers.push(unsub); } diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index 380cc103..6b20c6a7 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -62,9 +62,11 @@ export function PopSearchComponent({ const [modalDisplayText, setModalDisplayText] = useState(""); const [simpleModalOpen, setSimpleModalOpen] = useState(false); - const fieldKey = config.fieldName || componentId || "search"; const normalizedType = normalizeInputType(config.inputType as string); const isModalType = normalizedType === "modal"; + const fieldKey = isModalType + ? (config.modalConfig?.valueField || config.fieldName || componentId || "search") + : (config.fieldName || componentId || "search"); const emitFilterChanged = useCallback( (newValue: unknown) => { @@ -72,15 +74,18 @@ export function PopSearchComponent({ setSharedData(`search_${fieldKey}`, newValue); if (componentId) { + const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; publish(`__comp_output__${componentId}__filter_value`, { fieldName: fieldKey, + filterColumns, value: newValue, + filterMode: config.filterMode || "contains", }); } publish("filter_changed", { [fieldKey]: newValue }); }, - [fieldKey, publish, setSharedData, componentId] + [fieldKey, publish, setSharedData, componentId, config.filterMode, config.filterColumns] ); useEffect(() => { diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 96507984..2f9949eb 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -13,7 +13,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown, AlertTriangle } from "lucide-react"; import { Popover, PopoverContent, @@ -30,6 +30,7 @@ import { import type { PopSearchConfig, SearchInputType, + SearchFilterMode, DatePresetOption, ModalSelectConfig, ModalDisplayStyle, @@ -38,6 +39,7 @@ import type { } from "./types"; import { SEARCH_INPUT_TYPE_LABELS, + SEARCH_FILTER_MODE_LABELS, DATE_PRESET_LABELS, MODAL_DISPLAY_STYLE_LABELS, MODAL_SEARCH_MODE_LABELS, @@ -69,9 +71,12 @@ const DEFAULT_CONFIG: PopSearchConfig = { interface ConfigPanelProps { config: PopSearchConfig | undefined; onUpdate: (config: PopSearchConfig) => void; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; + componentId?: string; } -export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) { +export function PopSearchConfigPanel({ config, onUpdate, allComponents, connections, componentId }: ConfigPanelProps) { const [step, setStep] = useState(0); const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) }; const cfg: PopSearchConfig = { @@ -110,7 +115,7 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
{step === 0 && } - {step === 1 && } + {step === 1 && }
)} +
); } @@ -224,16 +233,16 @@ function StepBasicSettings({ cfg, update }: StepProps) { // STEP 2: 타입별 상세 설정 // ======================================== -function StepDetailSettings({ cfg, update }: StepProps) { +function StepDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const normalized = normalizeInputType(cfg.inputType as string); switch (normalized) { case "text": case "number": - return ; + return ; case "select": - return ; + return ; case "date-preset": - return ; + return ; case "modal": return ; case "toggle": @@ -255,11 +264,278 @@ function StepDetailSettings({ cfg, update }: StepProps) { } } +// ======================================== +// 공통: 필터 연결 설정 섹션 +// ======================================== + +interface FilterConnectionSectionProps { + cfg: PopSearchConfig; + update: (partial: Partial) => void; + showFieldName: boolean; + fixedFilterMode?: SearchFilterMode; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; + componentId?: string; +} + +interface ConnectedComponentInfo { + tableNames: string[]; + displayedColumns: Set; +} + +/** + * 연결된 대상 컴포넌트의 tableName과 카드에서 표시 중인 컬럼을 추출한다. + */ +function getConnectedComponentInfo( + componentId?: string, + connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[], + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[], +): ConnectedComponentInfo { + const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() }; + if (!componentId || !connections || !allComponents) return empty; + + const targetIds = connections + .filter((c) => c.sourceComponent === componentId) + .map((c) => c.targetComponent); + + const tableNames = new Set(); + const displayedColumns = new Set(); + + for (const tid of targetIds) { + const comp = allComponents.find((c) => c.id === tid); + if (!comp?.config) continue; + const compCfg = comp.config as Record; + + const tn = compCfg.dataSource?.tableName; + if (tn) tableNames.add(tn); + + // pop-card-list: cardTemplate에서 사용 중인 컬럼 수집 + const tpl = compCfg.cardTemplate; + if (tpl) { + if (tpl.header?.codeField) displayedColumns.add(tpl.header.codeField); + if (tpl.header?.titleField) displayedColumns.add(tpl.header.titleField); + if (tpl.image?.imageColumn) displayedColumns.add(tpl.image.imageColumn); + if (Array.isArray(tpl.body?.fields)) { + for (const f of tpl.body.fields) { + if (f.columnName) displayedColumns.add(f.columnName); + } + } + } + + // pop-string-list: selectedColumns / listColumns + if (Array.isArray(compCfg.selectedColumns)) { + for (const col of compCfg.selectedColumns) displayedColumns.add(col); + } + if (Array.isArray(compCfg.listColumns)) { + for (const lc of compCfg.listColumns) { + if (lc.columnName) displayedColumns.add(lc.columnName); + } + } + } + + return { tableNames: Array.from(tableNames), displayedColumns }; +} + +function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode, allComponents, connections, componentId }: FilterConnectionSectionProps) { + const connInfo = useMemo( + () => getConnectedComponentInfo(componentId, connections, allComponents), + [componentId, connections, allComponents], + ); + + const [targetColumns, setTargetColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + const connectedTablesKey = connInfo.tableNames.join(","); + useEffect(() => { + if (connInfo.tableNames.length === 0) { + setTargetColumns([]); + return; + } + let cancelled = false; + setColumnsLoading(true); + + Promise.all(connInfo.tableNames.map((t) => getTableColumns(t))) + .then((results) => { + if (cancelled) return; + const allCols: ColumnTypeInfo[] = []; + const seen = new Set(); + for (const res of results) { + if (res.success && res.data?.columns) { + for (const col of res.data.columns) { + if (!seen.has(col.columnName)) { + seen.add(col.columnName); + allCols.push(col); + } + } + } + } + setTargetColumns(allCols); + }) + .finally(() => { if (!cancelled) setColumnsLoading(false); }); + + return () => { cancelled = true; }; + }, [connectedTablesKey]); // eslint-disable-line react-hooks/exhaustive-deps + + const hasConnection = connInfo.tableNames.length > 0; + + const { displayedCols, otherCols } = useMemo(() => { + if (connInfo.displayedColumns.size === 0) { + return { displayedCols: [] as ColumnTypeInfo[], otherCols: targetColumns }; + } + const displayed: ColumnTypeInfo[] = []; + const others: ColumnTypeInfo[] = []; + for (const col of targetColumns) { + if (connInfo.displayedColumns.has(col.columnName)) { + displayed.push(col); + } else { + others.push(col); + } + } + return { displayedCols: displayed, otherCols: others }; + }, [targetColumns, connInfo.displayedColumns]); + + const selectedFilterCols = cfg.filterColumns || (cfg.fieldName ? [cfg.fieldName] : []); + + const toggleFilterColumn = (colName: string) => { + const current = new Set(selectedFilterCols); + if (current.has(colName)) { + current.delete(colName); + } else { + current.add(colName); + } + const next = Array.from(current); + update({ + filterColumns: next, + fieldName: next[0] || "", + }); + }; + + const renderColumnCheckbox = (col: ColumnTypeInfo) => ( +
+ toggleFilterColumn(col.columnName)} + /> + +
+ ); + + return ( +
+
+ 필터 연결 설정 +
+ + {!hasConnection && ( +
+ +

+ 연결 탭에서 대상 컴포넌트를 먼저 연결해주세요. + 연결된 리스트의 컬럼 목록이 여기에 표시됩니다. +

+
+ )} + + {hasConnection && showFieldName && ( +
+ + {columnsLoading ? ( +
+ + 컬럼 로딩... +
+ ) : targetColumns.length > 0 ? ( +
+ {displayedCols.length > 0 && ( +
+

카드에서 표시 중

+ {displayedCols.map(renderColumnCheckbox)} +
+ )} + {displayedCols.length > 0 && otherCols.length > 0 && ( +
+ )} + {otherCols.length > 0 && ( +
+

기타 컬럼

+ {otherCols.map(renderColumnCheckbox)} +
+ )} +
+ ) : ( +

+ 연결된 테이블에서 컬럼을 찾을 수 없습니다 +

+ )} + {selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && ( +
+ +

+ 필터 대상 컬럼을 선택해야 연결된 리스트에서 검색이 작동합니다 +

+
+ )} + {selectedFilterCols.length > 0 && ( +

+ {selectedFilterCols.length}개 컬럼 선택됨 - 검색어가 선택된 모든 컬럼에서 매칭됩니다 +

+ )} + {selectedFilterCols.length === 0 && ( +

+ 연결된 리스트에서 이 검색값과 매칭할 컬럼 (복수 선택 가능) +

+ )} +
+ )} + + {fixedFilterMode ? ( +
+ +
+ {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]} +
+

+ 이 입력 타입은 {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]} 방식이 자동 적용됩니다 +

+
+ ) : ( +
+ + +

+ 연결된 리스트에 값을 보낼 때 적용되는 매칭 방식 +

+
+ )} +
+ ); +} + // ======================================== // text/number 상세 설정 // ======================================== -function TextDetailSettings({ cfg, update }: StepProps) { +function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { return (
@@ -285,6 +561,8 @@ function TextDetailSettings({ cfg, update }: StepProps) { />
+ +
); } @@ -293,7 +571,7 @@ function TextDetailSettings({ cfg, update }: StepProps) { // select 상세 설정 // ======================================== -function SelectDetailSettings({ cfg, update }: StepProps) { +function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const options = cfg.options || []; const addOption = () => { @@ -329,6 +607,8 @@ function SelectDetailSettings({ cfg, update }: StepProps) { 옵션 추가 + +
); } @@ -337,7 +617,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) { // date-preset 상세 설정 // ======================================== -function DatePresetDetailSettings({ cfg, update }: StepProps) { +function DatePresetDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"]; const activePresets = cfg.datePresets || ["today", "this-week", "this-month"]; @@ -366,6 +646,8 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) { "직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)

)} + + ); } @@ -694,6 +976,8 @@ function ModalDetailSettings({ cfg, update }: StepProps) { 연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)

+ + )} diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 6c49b1c5..1673d027 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -46,6 +46,9 @@ export type ModalDisplayStyle = "table" | "icon"; /** 모달 검색 방식 */ export type ModalSearchMode = "contains" | "starts-with" | "equals"; +/** 검색 값을 대상 리스트에 전달할 때의 필터링 방식 */ +export type SearchFilterMode = "contains" | "equals" | "starts_with" | "range"; + /** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */ export type ModalFilterTab = "korean" | "alphabet"; @@ -93,6 +96,12 @@ export interface PopSearchConfig { // 스타일 labelPosition?: "top" | "left"; + + // 연결된 리스트에 필터를 보낼 때의 매칭 방식 + filterMode?: SearchFilterMode; + + // 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상) + filterColumns?: string[]; } /** 기본 설정값 (레지스트리 + 컴포넌트 공유) */ @@ -147,6 +156,14 @@ export const MODAL_FILTER_TAB_LABELS: Record = { alphabet: "ABC", }; +/** 검색 필터 방식 라벨 (설정 패널용) */ +export const SEARCH_FILTER_MODE_LABELS: Record = { + contains: "포함", + equals: "일치", + starts_with: "시작", + range: "범위", +}; + /** 한글 초성 추출 */ const KOREAN_CONSONANTS = [ "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", From 297b14d706e4660a54b170254d7de33aa73c78be Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Mar 2026 12:22:23 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(pop-search):=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=83=80=EC=9E=85=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?+=20=EC=85=80=20=EB=B0=98=EC=9D=91=ED=98=95=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B0=9C=EC=84=A0=20pop-search?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=9D=98=20date=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=83=80=EC=9E=85=EC=9D=B4=20=EB=AF=B8?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=83=81=ED=83=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20input=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9E=84=EC=8B=9C=20=EC=B2=98=EB=A6=AC=EB=90=98=EC=96=B4=20?= =?UTF-8?q?=EC=9E=88=EB=8D=98=20=EA=B2=83=EC=9D=84=20shadcn=20Calendar=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20POP=20=EC=A0=84=EC=9A=A9=20UI=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=ED=95=98=EA=B3=A0,=20=EC=85=80=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EC=9E=85=EB=A0=A5=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EA=B0=80=20=EB=B0=98=EC=9D=91=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8D=98=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=ED=95=A8=EA=BB=98=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20[BLOCK=20P:=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=83=80=EC=9E=85]=20-=20DateSingleInput:?= =?UTF-8?q?=20Calendar=20+=20Dialog/Popover=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20=EB=82=A0=EC=A7=9C=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?-=20DateRangeInput:=20=ED=94=84=EB=A6=AC=EC=85=8B(=EC=98=A4?= =?UTF-8?q?=EB=8A=98/=EC=9D=B4=EB=B2=88=EC=A3=BC/=EC=9D=B4=EB=B2=88?= =?UTF-8?q?=EB=8B=AC)=20+=20Calendar=20=EA=B8=B0=EA=B0=84=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20-=20CalendarDisplayMode(popover/modal)=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9C=BC=EB=A1=9C=20=ED=84=B0=EC=B9=98=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=8C=80=EC=9D=91=20-=20resolveFilterMode=EB=A1=9C?= =?UTF-8?q?=20date->equals,=20range->range=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=20-=20DateDetailSettings=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8C=A8=EB=84=90=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=84=A0=ED=83=9D=20+=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=ED=91=9C=EC=8B=9C=20=EB=B0=A9=EC=8B=9D)?= =?UTF-8?q?=20-=20PopStringListComponent:=20range=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EA=B5=AC=ED=98=84=20+=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20equals=20=EB=B9=84=EA=B5=90=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?[=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B0=9C=EC=84=A0]=20-?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=ED=95=84=EB=93=9C=EA=B0=80=20=EC=85=80?= =?UTF-8?q?=20=EB=84=88=EB=B9=84/=EB=86=92=EC=9D=B4=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=ED=95=98=EB=8F=84=EB=A1=9D=20h-full=20min-h-8=20+=20w?= =?UTF-8?q?-full=20=EC=A0=81=EC=9A=A9=20-=20labelPosition("top"/"left")=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=A0=9C=EA=B1=B0=20->=20=ED=95=AD?= =?UTF-8?q?=EC=83=81=20=EB=9D=BC=EB=B2=A8=20=EC=9C=84=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20-=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=EC=97=90?= =?UTF-8?q?=EC=84=9C=20"=EB=9D=BC=EB=B2=A8=20=EC=9C=84=EC=B9=98"=20Select?= =?UTF-8?q?=20UI=20=EC=A0=9C=EA=B1=B0=20-=20=EA=B8=B0=EB=B3=B8=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=ED=81=AC=EA=B8=B0=20colSpan:4=20rowSpan:2=20->=20c?= =?UTF-8?q?olSpan:2=20rowSpan:1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop/designer/types/pop-layout.ts | 2 +- .../pop-search/PopSearchComponent.tsx | 327 +++++++++++++++++- .../pop-search/PopSearchConfig.tsx | 122 +++++-- .../pop-components/pop-search/types.ts | 14 +- .../PopStringListComponent.tsx | 42 ++- 5 files changed, 450 insertions(+), 57 deletions(-) diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index a02bf02b..faead048 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -360,7 +360,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record { + if (config.filterMode) return config.filterMode; + if (normalizedType === "date") { + const mode: DateSelectionMode = config.dateSelectionMode || "single"; + return mode === "range" ? "range" : "equals"; + } + return "contains"; + }, [config.filterMode, config.dateSelectionMode, normalizedType]); + const emitFilterChanged = useCallback( (newValue: unknown) => { setValue(newValue); @@ -79,13 +98,13 @@ export function PopSearchComponent({ fieldName: fieldKey, filterColumns, value: newValue, - filterMode: config.filterMode || "contains", + filterMode: resolveFilterMode(), }); } publish("filter_changed", { [fieldKey]: newValue }); }, - [fieldKey, publish, setSharedData, componentId, config.filterMode, config.filterColumns] + [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns] ); useEffect(() => { @@ -125,19 +144,14 @@ export function PopSearchComponent({ return (
{showLabel && ( - + {config.labelText} )} -
+
; case "select": return ; + case "date": { + const dateMode: DateSelectionMode = config.dateSelectionMode || "single"; + return dateMode === "range" + ? + : ; + } case "date-preset": return ; case "toggle": @@ -220,7 +240,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; const isNumber = config.inputType === "number"; return ( -
+
); } +// ======================================== +// date 서브타입 - 단일 날짜 +// ======================================== + +function DateSingleInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { + const [open, setOpen] = useState(false); + const useModal = config.calendarDisplay === "modal"; + const selected = value ? new Date(value + "T00:00:00") : undefined; + + const handleSelect = useCallback( + (day: Date | undefined) => { + if (!day) return; + onChange(format(day, "yyyy-MM-dd")); + setOpen(false); + }, + [onChange] + ); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onChange(""); + }, + [onChange] + ); + + const triggerButton = ( + + ); + + if (useModal) { + return ( + <> + {triggerButton} + + + + 날짜 선택 + +
+ +
+
+
+ + ); + } + + return ( + + + {triggerButton} + + + + + + ); +} + +// ======================================== +// date 서브타입 - 기간 선택 (프리셋 + Calendar Range) +// ======================================== + +interface DateRangeValue { from?: string; to?: string } + +const RANGE_PRESETS = [ + { key: "today", label: "오늘" }, + { key: "this-week", label: "이번주" }, + { key: "this-month", label: "이번달" }, +] as const; + +function computeRangePreset(key: string): DateRangeValue { + const now = new Date(); + const fmt = (d: Date) => format(d, "yyyy-MM-dd"); + switch (key) { + case "today": + return { from: fmt(now), to: fmt(now) }; + case "this-week": + return { from: fmt(startOfWeek(now, { weekStartsOn: 1 })), to: fmt(endOfWeek(now, { weekStartsOn: 1 })) }; + case "this-month": + return { from: fmt(startOfMonth(now)), to: fmt(endOfMonth(now)) }; + default: + return {}; + } +} + +function DateRangeInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) { + const [open, setOpen] = useState(false); + const useModal = config.calendarDisplay === "modal"; + + const rangeVal: DateRangeValue = (typeof value === "object" && value !== null) + ? value as DateRangeValue + : (typeof value === "string" && value ? { from: value, to: value } : {}); + + const calendarRange = useMemo(() => { + if (!rangeVal.from) return undefined; + return { + from: new Date(rangeVal.from + "T00:00:00"), + to: rangeVal.to ? new Date(rangeVal.to + "T00:00:00") : undefined, + }; + }, [rangeVal.from, rangeVal.to]); + + const activePreset = RANGE_PRESETS.find((p) => { + const preset = computeRangePreset(p.key); + return preset.from === rangeVal.from && preset.to === rangeVal.to; + })?.key ?? null; + + const handlePreset = useCallback( + (key: string) => { + const preset = computeRangePreset(key); + onChange(preset); + }, + [onChange] + ); + + const handleRangeSelect = useCallback( + (range: { from?: Date; to?: Date } | undefined) => { + if (!range?.from) return; + const from = format(range.from, "yyyy-MM-dd"); + const to = range.to ? format(range.to, "yyyy-MM-dd") : from; + onChange({ from, to }); + if (range.to) setOpen(false); + }, + [onChange] + ); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onChange({}); + }, + [onChange] + ); + + const displayText = rangeVal.from + ? rangeVal.from === rangeVal.to + ? format(new Date(rangeVal.from + "T00:00:00"), "MM/dd (EEE)", { locale: ko }) + : `${format(new Date(rangeVal.from + "T00:00:00"), "MM/dd", { locale: ko })} ~ ${rangeVal.to ? format(new Date(rangeVal.to + "T00:00:00"), "MM/dd", { locale: ko }) : ""}` + : ""; + + const presetBar = ( +
+ {RANGE_PRESETS.map((p) => ( + + ))} +
+ ); + + const calendarEl = ( + + ); + + const triggerButton = ( + + ); + + if (useModal) { + return ( + <> + {triggerButton} + + + + 기간 선택 + +
+ {presetBar} +
+ {calendarEl} +
+
+
+
+ + ); + } + + return ( + + + {triggerButton} + + +
+ {presetBar} + {calendarEl} +
+
+
+ ); +} + // ======================================== // select 서브타입 // ======================================== @@ -242,7 +533,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { return ( update({ labelText: e.target.value })} - placeholder="예: 거래처명" - className="h-8 text-xs" - /> -
-
- - -
- +
+ + update({ labelText: e.target.value })} + placeholder="예: 거래처명" + className="h-8 text-xs" + /> +
)}
@@ -241,6 +225,8 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component return ; case "select": return ; + case "date": + return ; case "date-preset": return ; case "modal": @@ -613,6 +599,88 @@ function SelectDetailSettings({ cfg, update, allComponents, connections, compone ); } +// ======================================== +// date 상세 설정 +// ======================================== + +const DATE_SELECTION_MODE_LABELS: Record = { + single: "단일 날짜", + range: "기간 선택", +}; + +const CALENDAR_DISPLAY_LABELS: Record = { + popover: "팝오버 (PC용)", + modal: "모달 (터치/POP용)", +}; + +function DateDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { + const mode: DateSelectionMode = cfg.dateSelectionMode || "single"; + const calDisplay: CalendarDisplayMode = cfg.calendarDisplay || "modal"; + const autoFilterMode = mode === "range" ? "range" : "equals"; + + return ( +
+
+ + +

+ {mode === "single" + ? "캘린더에서 날짜 하나를 선택합니다" + : "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"} +

+
+ +
+ + +

+ {calDisplay === "modal" + ? "터치 친화적인 큰 모달로 캘린더가 열립니다" + : "입력란 아래에 작은 팝오버로 열립니다"} +

+
+ + +
+ ); +} + // ======================================== // date-preset 상세 설정 // ======================================== diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 1673d027..220d5ff9 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -22,6 +22,12 @@ export function normalizeInputType(t: string): SearchInputType { return t as SearchInputType; } +/** 날짜 선택 모드 */ +export type DateSelectionMode = "single" | "range"; + +/** 캘린더 표시 방식 (POP 터치 환경에서는 modal 권장) */ +export type CalendarDisplayMode = "popover" | "modal"; + /** 날짜 프리셋 옵션 */ export type DatePresetOption = "today" | "this-week" | "this-month" | "custom"; @@ -84,6 +90,10 @@ export interface PopSearchConfig { options?: SelectOption[]; optionsDataSource?: SelectDataSource; + // date 전용 + dateSelectionMode?: DateSelectionMode; + calendarDisplay?: CalendarDisplayMode; + // date-preset 전용 datePresets?: DatePresetOption[]; @@ -94,9 +104,6 @@ export interface PopSearchConfig { labelText?: string; labelVisible?: boolean; - // 스타일 - labelPosition?: "top" | "left"; - // 연결된 리스트에 필터를 보낼 때의 매칭 방식 filterMode?: SearchFilterMode; @@ -111,7 +118,6 @@ export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = { placeholder: "검색어 입력", debounceMs: 500, triggerOnEnter: true, - labelPosition: "top", labelText: "", labelVisible: true, }; diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx index 567f6d1d..a9b77c27 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx @@ -192,10 +192,9 @@ export function PopStringListComponent({ row: RowData, filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } } ): boolean => { - const searchValue = String(filter.value).toLowerCase(); - if (!searchValue) return true; - const fc = filter.filterConfig; + const mode = fc?.filterMode || "contains"; + const columns: string[] = fc?.targetColumns?.length ? fc.targetColumns @@ -207,17 +206,46 @@ export function PopStringListComponent({ if (columns.length === 0) return true; - const mode = fc?.filterMode || "contains"; + // range 모드: { from, to } 객체 또는 단일 날짜 문자열 지원 + if (mode === "range") { + const val = filter.value as { from?: string; to?: string } | string; + let from = ""; + let to = ""; + if (typeof val === "object" && val !== null) { + from = val.from || ""; + to = val.to || ""; + } else { + from = String(val || ""); + to = from; + } + if (!from && !to) return true; + + return columns.some((col) => { + const cellDate = String(row[col] ?? "").slice(0, 10); + if (!cellDate) return false; + if (from && cellDate < from) return false; + if (to && cellDate > to) return false; + return true; + }); + } + + // 문자열 기반 필터 (contains, equals, starts_with) + const searchValue = String(filter.value ?? "").toLowerCase(); + if (!searchValue) return true; + + // 날짜 패턴 감지 (YYYY-MM-DD): equals 비교 시 ISO 타임스탬프에서 날짜만 추출 + const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(searchValue); const matchCell = (cellValue: string) => { + const target = isDateValue && mode === "equals" ? cellValue.slice(0, 10) : cellValue; switch (mode) { case "equals": - return cellValue === searchValue; + return target === searchValue; case "starts_with": - return cellValue.startsWith(searchValue); + return target.startsWith(searchValue); case "contains": default: - return cellValue.includes(searchValue); + return target.includes(searchValue); } }; From 516517eb346b0595c32e7a58d30d2bc96bccd444 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Mar 2026 14:06:53 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(pop-button):=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20UX/UI=20=EC=A0=84=EB=A9=B4=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20=EB=B9=84=EA=B0=9C=EB=B0=9C=EC=9E=90=20?= =?UTF-8?q?=EC=B9=9C=ED=99=94=EC=A0=81=20=EC=84=A4=EC=A0=95=20=EA=B2=BD?= =?UTF-8?q?=ED=97=98=20=ED=99=94=EB=A9=B4=20=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88(=EB=B9=84=EA=B0=9C=EB=B0=9C=EC=9E=90)=EA=B0=80=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=9E=91=EC=97=85=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EC=A7=81=EA=B4=80=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8C=A8=EB=84=90=EC=9D=98=20=EC=9A=A9=EC=96=B4,?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83,=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=EB=A9=B4=20=EA=B0=9C=EC=84=A0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20[=EB=94=94=EC=9E=90=EC=9D=B8=20=ED=86=B5=EC=9D=BC]?= =?UTF-8?q?=20-=20Input/Select=20=EB=86=92=EC=9D=B4=20h-8,=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=20text-xs=20font-medium,=20=EB=8F=84=EC=9B=80?= =?UTF-8?q?=EB=A7=90=20text-[11px]=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20-=20db-?= =?UTF-8?q?conditional=20UI=EB=A5=BC=20=EA=B0=80=EB=A1=9C=20=EB=82=98?= =?UTF-8?q?=EC=97=B4=EC=97=90=EC=84=9C=20=EC=84=B8=EB=A1=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=83=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20(=EC=A2=81?= =?UTF-8?q?=EC=9D=80=20=ED=8C=A8=EB=84=90=20=EC=9E=98=EB=A6=BC=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80)=20-=20=EC=9E=91=EC=97=85=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EA=B0=84=20=EA=B0=84=EA=B2=A9,=20=ED=8C=A8=EB=94=A9,=20?= =?UTF-8?q?=EB=91=A5=EA=B7=BC=20=EB=AA=A8=EC=84=9C=EB=A6=AC=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=80=EC=84=B1=20=ED=99=95=EB=B3=B4=20[=EC=9E=90=EC=97=B0?= =?UTF-8?q?=EC=96=B4=20=EB=9D=BC=EB=B2=A8]=20-=20"=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94"=20=E2=86=92=20"=EC=96=B4=EB=96=A4?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=9D=84=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=A0=EA=B9=8C=EC=9A=94=3F"=20-=20"=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC"=20=E2=86=92=20"=EC=96=B4=EB=96=A4=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9(=EC=BB=AC=EB=9F=BC)=EC=9D=84=20=EB=B0=94?= =?UTF-8?q?=EA=BF=80=EA=B9=8C=EC=9A=94=3F"=20-=20"=EC=97=B0=EC=82=B0"=20?= =?UTF-8?q?=E2=86=92=20"=EC=96=B4=EB=96=BB=EA=B2=8C=20=EB=B0=94=EA=BF=80?= =?UTF-8?q?=EA=B9=8C=EC=9A=94=3F"=20+=20=EA=B0=81=20=EC=97=B0=EC=82=B0?= =?UTF-8?q?=EB=B3=84=20=EC=84=A4=EB=AA=85=20=EB=8F=84=EC=9B=80=EB=A7=90=20?= =?UTF-8?q?-=20"=EA=B0=92=20=EC=B6=9C=EC=B2=98:=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=EA=B0=92"=20=E2=86=92=20"=EC=A7=81=EC=A0=91=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5",=20"=EC=97=B0=EA=B2=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?"=20=E2=86=92=20"=ED=99=94=EB=A9=B4=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EC=97=90=EC=84=9C=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0"=20-=20=EB=B9=84=EA=B5=90=20=EC=97=B0=EC=82=B0?= =?UTF-8?q?=EC=9E=90=EC=97=90=20=ED=95=9C=EA=B8=80=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(">=3D"=20=E2=86=92=20">=3D=20(=EC=9D=B4?= =?UTF-8?q?=EC=83=81=EC=9D=B4=EB=A9=B4)")=20[=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0]=20-=20"=EC=A1=B0=ED=9A=8C=20=ED=82=A4"?= =?UTF-8?q?=EB=A5=BC=20"=EA=B3=A0=EA=B8=89=20=EC=84=A4=EC=A0=95"=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=EB=A1=9C=20=EC=88=A8=EA=B9=80=20(=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=A0=91=ED=9E=98,=20=EB=8C=80=EB=B6=80=EB=B6=84?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=EC=B9=AD)=20-=20"=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=ED=95=84=EB=93=9C=EB=AA=85"=20=EC=88=98=EB=8F=99?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=E2=86=92=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20Select=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20-=20=EC=A0=91=ED=9E=8C=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=EC=97=90=20=EC=9A=94=EC=95=BD=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20+=20=EB=A7=88=EC=9A=B0=EC=8A=A4=20?= =?UTF-8?q?=ED=98=B8=EB=B2=84=20=EC=8B=9C=20=EC=A0=84=EC=B2=B4=20=ED=88=B4?= =?UTF-8?q?=ED=8C=81=20-=20=ED=8E=BC=EC=B9=9C=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=EC=97=90=20=EC=84=A4=EC=A0=95=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20[=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=BD=94=EB=A9=98=ED=8A=B8=20=ED=91=9C=EC=8B=9C]?= =?UTF-8?q?=20-=20=EB=B0=B1=EC=97=94=EB=93=9C:=20getTableSchema=20SQL?= =?UTF-8?q?=EC=97=90=20col=5Fdescription()=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8:=20ColumnCombobox=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=BD=94=EB=A9=98=ED=8A=B8=20=ED=91=9C=EC=8B=9C=20+=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=EB=AA=85=20=EA=B2=80=EC=83=89=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20-=20ColumnInfo=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EC=97=90=20comment=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/tableManagementService.ts | 43 +- .../registry/pop-components/pop-button.tsx | 675 +++++++++++++----- .../pop-dashboard/utils/dataFetcher.ts | 2 + .../pop-shared/ColumnCombobox.tsx | 31 +- 4 files changed, 529 insertions(+), 222 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 791940ec..d6886a25 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -4446,26 +4446,30 @@ export class TableManagementService { const rawColumns = await query( `SELECT - column_name as "columnName", - column_name as "displayName", - data_type as "dataType", - udt_name as "dbType", - is_nullable as "isNullable", - column_default as "defaultValue", - character_maximum_length as "maxLength", - numeric_precision as "numericPrecision", - numeric_scale as "numericScale", + c.column_name as "columnName", + c.column_name as "displayName", + c.data_type as "dataType", + c.udt_name as "dbType", + c.is_nullable as "isNullable", + c.column_default as "defaultValue", + c.character_maximum_length as "maxLength", + c.numeric_precision as "numericPrecision", + c.numeric_scale as "numericScale", CASE - WHEN column_name IN ( - SELECT column_name FROM information_schema.key_column_usage - WHERE table_name = $1 AND constraint_name LIKE '%_pkey' + WHEN c.column_name IN ( + SELECT kcu.column_name FROM information_schema.key_column_usage kcu + WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey' ) THEN true ELSE false - END as "isPrimaryKey" - FROM information_schema.columns - WHERE table_name = $1 - AND table_schema = 'public' - ORDER BY ordinal_position`, + END as "isPrimaryKey", + col_description( + (SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')), + c.ordinal_position + ) as "columnComment" + FROM information_schema.columns c + WHERE c.table_name = $1 + AND c.table_schema = 'public' + ORDER BY c.ordinal_position`, [tableName] ); @@ -4475,10 +4479,10 @@ export class TableManagementService { displayName: col.displayName, dataType: col.dataType, dbType: col.dbType, - webType: "text", // 기본값 + webType: "text", inputType: "direct", detailSettings: "{}", - description: "", // 필수 필드 추가 + description: col.columnComment || "", isNullable: col.isNullable, isPrimaryKey: col.isPrimaryKey, defaultValue: col.defaultValue, @@ -4489,6 +4493,7 @@ export class TableManagementService { numericScale: col.numericScale ? Number(col.numericScale) : undefined, displayOrder: 0, isVisible: true, + columnComment: col.columnComment || "", })); logger.info( diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index ae6d05d9..56889eff 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -1267,11 +1267,51 @@ interface PopButtonConfigPanelProps { componentId?: string; } +/** 화면 내 카드 컴포넌트에서 사용 가능한 필드 목록 추출 */ +function extractCardFields( + allComponents?: PopButtonConfigPanelProps["allComponents"], +): { value: string; label: string; source: string }[] { + if (!allComponents) return []; + const fields: { value: string; label: string; source: string }[] = []; + + for (const comp of allComponents) { + if (comp.type !== "pop-card-list" || !comp.config) continue; + const tpl = (comp.config as Record).cardTemplate as + | { header?: Record; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } } + | undefined; + if (!tpl) continue; + + if (tpl.header?.codeField) { + fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: "헤더 코드" }); + } + if (tpl.header?.titleField) { + fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: "헤더 제목" }); + } + for (const f of tpl.body?.fields ?? []) { + if (f.valueType === "column" && f.columnName) { + fields.push({ value: f.columnName, label: f.label || f.columnName, source: "본문" }); + } else if (f.valueType === "formula" && f.label) { + const formulaKey = `__formula_${f.id || f.label}`; + fields.push({ value: formulaKey, label: f.label, source: "수식" }); + } + } + + // 시스템 필드 추가 + fields.push({ value: "__cart_quantity", label: "수량 (장바구니)", source: "시스템" }); + fields.push({ value: "__cart_row_key", label: "원본 키", source: "시스템" }); + fields.push({ value: "__cart_id", label: "카드 항목 ID", source: "시스템" }); + } + + return fields; +} + export function PopButtonConfigPanel({ config, onUpdate, + allComponents, }: PopButtonConfigPanelProps) { const v2 = useMemo(() => migrateButtonConfig(config), [config]); + const cardFields = useMemo(() => extractCardFields(allComponents), [allComponents]); const updateV2 = useCallback( (partial: Partial) => { @@ -1449,9 +1489,9 @@ export function PopButtonConfigPanel({ {/* 작업 목록 */} -
+
{v2.tasks.length === 0 && ( -

+

작업이 없습니다. 빠른 시작 또는 아래 버튼으로 추가하세요.

)} @@ -1465,6 +1505,7 @@ export function PopButtonConfigPanel({ onUpdate={(partial) => updateTask(task.id, partial)} onRemove={() => removeTask(task.id)} onMove={(dir) => moveTask(task.id, dir)} + cardFields={cardFields} /> ))} @@ -1490,6 +1531,41 @@ export function PopButtonConfigPanel({ // 작업 항목 에디터 (접힘/펼침) // ======================================== +/** 작업 항목의 요약 텍스트 생성 */ +function buildTaskSummary(task: ButtonTask): string { + switch (task.type) { + case "data-update": { + if (!task.targetTable) return ""; + const col = task.targetColumn ? `.${task.targetColumn}` : ""; + const opLabels: Record = { + assign: "값 지정", + add: "더하기", + subtract: "빼기", + multiply: "곱하기", + divide: "나누기", + conditional: "조건 분기", + "db-conditional": "조건 비교", + }; + const op = opLabels[task.operationType || "assign"] || ""; + return `${task.targetTable}${col} ${op}`; + } + case "data-delete": + return task.targetTable || ""; + case "navigate": + return task.targetScreenId ? `화면 ${task.targetScreenId}` : ""; + case "modal-open": + return task.modalTitle || task.modalScreenId || ""; + case "cart-save": + return task.cartScreenId ? `화면 ${task.cartScreenId}` : ""; + case "api-call": + return task.apiEndpoint || ""; + case "custom-event": + return task.eventName || ""; + default: + return ""; + } +} + function TaskItemEditor({ task, index, @@ -1497,6 +1573,7 @@ function TaskItemEditor({ onUpdate, onRemove, onMove, + cardFields, }: { task: ButtonTask; index: number; @@ -1504,55 +1581,61 @@ function TaskItemEditor({ onUpdate: (partial: Partial) => void; onRemove: () => void; onMove: (direction: "up" | "down") => void; + cardFields: { value: string; label: string; source: string }[]; }) { const [expanded, setExpanded] = useState(false); const designerCtx = usePopDesignerContext(); + const summary = buildTaskSummary(task); return ( -
- {/* 헤더: 타입 + 순서 + 삭제 */} +
setExpanded(!expanded)} > - - - {index + 1}. {TASK_TYPE_LABELS[task.type]} - - {task.label && ( - - ({task.label}) - - )} -
+
+
+ + {index + 1}. {TASK_TYPE_LABELS[task.type]} + + {summary && ( + + - {summary} + + )} +
+
+
{index > 0 && ( - )} {index < totalCount - 1 && ( - )}
- {/* 펼침: 타입별 설정 폼 */} {expanded && ( -
- +
+
)}
@@ -1567,10 +1650,12 @@ function TaskDetailForm({ task, onUpdate, designerCtx, + cardFields, }: { task: ButtonTask; onUpdate: (partial: Partial) => void; designerCtx: ReturnType; + cardFields: { value: string; label: string; source: string }[]; }) { // 테이블/컬럼 조회 (data-update, data-delete용) const [tables, setTables] = useState([]); @@ -1592,7 +1677,7 @@ function TaskDetailForm({ switch (task.type) { case "data-save": return ( -

+

연결된 입력 컴포넌트의 저장 매핑을 사용합니다. 별도 설정 불필요.

); @@ -1604,13 +1689,14 @@ function TaskDetailForm({ onUpdate={onUpdate} tables={tables} columns={columns} + cardFields={cardFields} /> ); case "data-delete": return ( -
- +
+ - +
+ onUpdate({ cartScreenId: e.target.value })} placeholder="비워두면 이동 없이 저장만" - className="h-7 text-xs" + className="h-8 text-xs" />
); case "modal-open": return ( -
-
- +
+
+
{task.modalMode === "screen-ref" && ( -
- +
+ onUpdate({ modalScreenId: e.target.value })} placeholder="화면 ID" - className="h-7 text-xs" + className="h-8 text-xs" />
)} -
- +
+ onUpdate({ modalTitle: e.target.value })} placeholder="모달 제목 (선택)" - className="h-7 text-xs" + className="h-8 text-xs" />
{task.modalMode === "fullscreen" && designerCtx && (
{task.modalScreenId ? ( - ) : ( -
-
- 이면 -> - updateCondition(cIdx, { thenValue: e.target.value })} className="h-7 text-[10px]" placeholder="변경할 값" /> -
+ ) : ( + onUpdate({ sourceField: e.target.value })} + className="h-8 text-xs" + placeholder="필드명 직접 입력 (예: qty)" + /> + )} +

+ {cardFields.length > 0 + ? "카드에 표시되는 데이터 중 하나를 선택합니다" + : "카드 컴포넌트가 없으면 직접 입력해주세요"} +

- ))} - -
- 그 외 -> + )} +
+ )} + + {/* 5. 조건 비교 (db-conditional) - 세로 스택 */} + {task.operationType === "db-conditional" && ( +
+

+ DB 컬럼 값을 비교해서 결과를 정합니다 +

+ +
+ + onUpdate({ compareColumn: v })} + placeholder="비교할 컬럼 선택" + /> +
+ +
+ + +
+ +
+ + onUpdate({ compareWith: v })} + placeholder="비교 대상 컬럼 선택" + /> +
+ +
+ onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })} - className="h-7 text-[10px]" - placeholder="기본값" + value={task.dbThenValue ?? ""} + onChange={(e) => onUpdate({ dbThenValue: e.target.value })} + className="h-8 text-xs" + placeholder="예: 입고완료" + /> +
+ +
+ + onUpdate({ dbElseValue: e.target.value })} + className="h-8 text-xs" + placeholder="예: 부분입고" />
)} - {/* 조회 키 */} -
-
- - + {/* 6. 조건 분기 (conditional) */} + {task.operationType === "conditional" && ( +
+

+ 입력된 값에 따라 다른 결과를 지정합니다 +

+ + {conditions.map((cond, cIdx) => ( +
+
+ 조건 {cIdx + 1} + +
+
+ + updateCondition(cIdx, { whenColumn: v })} + placeholder="컬럼 선택" + /> +
+
+ +
+ + updateCondition(cIdx, { whenValue: e.target.value })} + className="h-8 flex-1 text-xs" + placeholder="비교할 값" + /> +
+
+
+ + updateCondition(cIdx, { thenValue: e.target.value })} + className="h-8 text-xs" + placeholder="변경할 값" + /> +
+
+ ))} + + + +
+ + onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })} + className="h-8 text-xs" + placeholder="기본값 입력" + /> +
- {task.lookupMode === "manual" && ( -
- - -> - onUpdate({ manualPkColumn: v })} placeholder="대상 PK 컬럼" /> + )} + + {/* 7. 고급 설정 (조회 키) */} +
+ + {showAdvanced && ( +
+
+ + +

+ {task.lookupMode === "manual" + ? "카드 항목의 필드를 직접 지정하여 대상 행을 찾습니다" + : "카드 항목과 테이블 PK를 자동으로 매칭합니다"} +

+
+ {task.lookupMode === "manual" && ( +
+
+ + +
+
+ + onUpdate({ manualPkColumn: v })} + placeholder="PK 컬럼 선택" + /> +
+
+ )}
)}
+ + {/* 8. 설정 요약 */} + {summaryText && ( +
+

설정 요약

+

{summaryText}

+
+ )} )}
@@ -2326,10 +2605,10 @@ function PopButtonPreviewComponent({ // ======================================== const KNOWN_ITEM_FIELDS = [ - { value: "__cart_id", label: "__cart_id (카드 항목 ID)" }, - { value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" }, - { value: "id", label: "id" }, - { value: "row_key", label: "row_key" }, + { value: "__cart_row_key", label: "카드 항목의 원본 키", desc: "DB에서 가져온 데이터의 PK (가장 일반적)" }, + { value: "__cart_id", label: "카드 항목 ID", desc: "장바구니 내부 고유 ID" }, + { value: "id", label: "id", desc: "데이터의 id 컬럼" }, + { value: "row_key", label: "row_key", desc: "데이터의 row_key 컬럼" }, ]; function StatusChangeRuleEditor({ diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index 0f6adda6..b05846ef 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -34,6 +34,7 @@ export interface ColumnInfo { type: string; udtName: string; isPrimaryKey?: boolean; + comment?: string; } // ===== SQL 값 이스케이프 ===== @@ -330,6 +331,7 @@ export async function fetchTableColumns( type: col.dataType || col.data_type || col.type || "unknown", udtName: col.dbType || col.udt_name || col.udtName || "unknown", isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true", + comment: col.columnComment || col.description || "", })); } } diff --git a/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx b/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx index 62d63f02..99444d95 100644 --- a/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx +++ b/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx @@ -38,9 +38,23 @@ export function ColumnCombobox({ const filtered = useMemo(() => { if (!search) return columns; const q = search.toLowerCase(); - return columns.filter((c) => c.name.toLowerCase().includes(q)); + return columns.filter( + (c) => + c.name.toLowerCase().includes(q) || + (c.comment && c.comment.toLowerCase().includes(q)) + ); }, [columns, search]); + const selectedCol = useMemo( + () => columns.find((c) => c.name === value), + [columns, value], + ); + const displayValue = selectedCol + ? selectedCol.comment + ? `${selectedCol.name} (${selectedCol.comment})` + : selectedCol.name + : ""; + return ( @@ -50,7 +64,7 @@ export function ColumnCombobox({ aria-expanded={open} className="mt-1 h-8 w-full justify-between text-xs" > - {value || placeholder} + {displayValue || placeholder} @@ -61,7 +75,7 @@ export function ColumnCombobox({ > -
- {col.name} +
+
+ {col.name} + {col.comment && ( + + ({col.comment}) + + )} +
{col.type} From 955da6ae87d7ab7d442182a865709cb795c01055 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Mar 2026 15:59:40 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(pop):=20=EC=9D=BC=EA=B4=84=20=EC=B1=84?= =?UTF-8?q?=EB=B2=88=20+=20=EB=AA=A8=EB=8B=AC=20distinct=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20+=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=EC=97=90=EC=84=9C=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=ED=92=88=EB=AA=A9=EC=9D=84=20=ED=95=9C=EA=BA=BC=EB=B2=88?= =?UTF-8?q?=EC=97=90=20=EC=9E=85=EA=B3=A0=20=ED=99=95=EC=A0=95=ED=95=A0=20?= =?UTF-8?q?=EB=95=8C=20=EB=8F=99=EC=9D=BC=ED=95=9C=20=EC=9E=85=EA=B3=A0?= =?UTF-8?q?=EB=B2=88=ED=98=B8=EB=A5=BC=20=EA=B3=B5=EC=9C=A0=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=9D=BC=EA=B4=84=20=EC=B1=84=EB=B2=88(sh?= =?UTF-8?q?areAcrossItems)=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=98=EA=B3=A0,=20=EC=9E=85=EA=B3=A0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EA=B2=80=EC=83=89=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=ED=95=AD=EB=AA=A9=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=98?= =?UTF-8?q?=EB=8A=94=20distinct=20=EC=98=B5=EC=85=98=EA=B3=BC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EB=90=9C=20=ED=95=84=ED=84=B0=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=ED=95=98=EB=8A=94=20X=20=EB=B2=84=ED=8A=BC=EC=9D=84?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4.=20[=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=20=EC=B1=84=EB=B2=88]=20-=20pop-field=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=84=A4=EC=A0=95=EC=97=90=20shareAcrossI?= =?UTF-8?q?tems=20=EC=8A=A4=EC=9C=84=EC=B9=98=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20data-save=20/=20inbound-confirm:?= =?UTF-8?q?=20shareAcrossItems=3Dtrue=20=EB=A7=A4=ED=95=91=EC=9D=80=20=20?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=A3=A8=ED=94=84=20=EC=A0=84?= =?UTF-8?q?=201=ED=9A=8C=EB=A7=8C=20allocateCode=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EA=B3=B5=EC=9C=A0=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20-=20PopFieldComponent=EC=97=90=EC=84=9C=20?= =?UTF-8?q?shareAcrossItems=20=EA=B0=92=EC=9D=84=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EC=A0=84=EB=8B=AC=20[=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20distinct]=20-=20ModalSelectConfig=EC=97=90=20distinct=3F:=20?= =?UTF-8?q?boolean=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=ED=83=AD=20=EC=98=81=EC=97=AD=EC=97=90=20"=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0"=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20-=20ModalDialog=20fetchData=EC=97=90?= =?UTF-8?q?=EC=84=9C=20displayField=20=EA=B8=B0=EC=A4=80=20Set=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20[=EC=84=A0=ED=83=9D=20=ED=95=B4=EC=A0=9C]?= =?UTF-8?q?=20-=20ModalSearchInput:=20=EA=B0=92=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=8B=9C=20>=20=EC=95=84=EC=9D=B4=EC=BD=98=20->=20X=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20-?= =?UTF-8?q?=20X=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20modalDisplayText=20+=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EA=B0=92=20=EC=B4=88=EA=B8=B0=ED=99=94=20(st?= =?UTF-8?q?opPropagation)=20-=20handleModalClear=20=EC=BD=9C=EB=B0=B1=20+?= =?UTF-8?q?=20onModalClear=20prop=20=EC=B2=B4=EC=9D=B8=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/popActionRoutes.ts | 118 ++++++++++++------ .../pop-field/PopFieldComponent.tsx | 1 + .../pop-field/PopFieldConfig.tsx | 12 ++ .../pop-components/pop-field/types.ts | 1 + .../pop-search/PopSearchComponent.tsx | 49 ++++++-- .../pop-search/PopSearchConfig.tsx | 15 +++ .../pop-components/pop-search/types.ts | 3 + 7 files changed, 156 insertions(+), 43 deletions(-) diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 730572d8..b36bc39e 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -17,6 +17,7 @@ interface AutoGenMappingInfo { numberingRuleId: string; targetColumn: string; showResultModal?: boolean; + shareAcrossItems?: boolean; } interface HiddenMappingInfo { @@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + for (const ag of allAutoGen) { + if (!ag.shareAcrossItems) continue; + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + try { + const code = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) }, + ); + sharedCodes[ag.targetColumn] = code; + generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 일괄 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + for (const item of items) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -225,23 +251,25 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } @@ -448,6 +476,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + for (const ag of allAutoGen) { + if (!ag.shareAcrossItems) continue; + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + try { + const code = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) }, + ); + sharedCodes[ag.targetColumn] = code; + generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 일괄 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + for (const item of items) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -467,7 +520,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } - // 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼) const allHidden = [ ...(fieldMapping?.hiddenMappings ?? []), ...(cardMapping?.hiddenMappings ?? []), @@ -494,34 +546,28 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, - companyCode, - { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - logger.info("[pop/execute-action] 채번 완료", { - ruleId: ag.numberingRuleId, - targetColumn: ag.targetColumn, - generatedCode, - }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { - ruleId: ag.numberingRuleId, - error: err.message, - }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx index dace22f6..7ad256ff 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx @@ -228,6 +228,7 @@ export function PopFieldComponent({ numberingRuleId: m.numberingRuleId!, targetColumn: m.targetColumn, showResultModal: m.showResultModal, + shareAcrossItems: m.shareAcrossItems ?? false, })), hiddenMappings: (cfg.saveConfig.hiddenMappings || []) .filter((m) => m.targetColumn) diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx index 8b5beb84..20fccca3 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx @@ -1337,7 +1337,19 @@ function SaveTabContent({ />
+
+ updateAutoGenMapping(m.id, { shareAcrossItems: v })} + /> + +
+ {m.shareAcrossItems && ( +

+ 저장되는 모든 행에 동일한 번호를 부여합니다 +

+ )}
); })} diff --git a/frontend/lib/registry/pop-components/pop-field/types.ts b/frontend/lib/registry/pop-components/pop-field/types.ts index 7118d0a6..f0813e6c 100644 --- a/frontend/lib/registry/pop-components/pop-field/types.ts +++ b/frontend/lib/registry/pop-components/pop-field/types.ts @@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping { numberingRuleId?: string; showInForm: boolean; showResultModal: boolean; + shareAcrossItems?: boolean; } export interface PopFieldSaveConfig { diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index 0cb0d1a9..44fc6dcc 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -140,6 +140,11 @@ export function PopSearchComponent({ [config.modalConfig, emitFilterChanged] ); + const handleModalClear = useCallback(() => { + setModalDisplayText(""); + emitFilterChanged(""); + }, [emitFilterChanged]); + const showLabel = config.labelVisible !== false && !!config.labelText; return ( @@ -158,6 +163,7 @@ export function PopSearchComponent({ onChange={emitFilterChanged} modalDisplayText={modalDisplayText} onModalOpen={handleModalOpen} + onModalClear={handleModalClear} />
@@ -184,9 +190,10 @@ interface InputRendererProps { onChange: (v: unknown) => void; modalDisplayText?: string; onModalOpen?: () => void; + onModalClear?: () => void; } -function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) { +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) { const normalized = normalizeInputType(config.inputType as string); switch (normalized) { case "text": @@ -205,7 +212,7 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa case "toggle": return ; case "modal": - return ; + return ; default: return ; } @@ -589,7 +596,9 @@ function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: // modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기 // ======================================== -function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) { +function ModalSearchInput({ config, displayText, onClick, onClear }: { config: PopSearchConfig; displayText: string; onClick?: () => void; onClear?: () => void }) { + const hasValue = !!displayText; + return (
{ if (e.key === "Enter" || e.key === " ") onClick?.(); }} > - {displayText || config.placeholder || "선택..."} - + + {displayText || config.placeholder || "선택..."} + + {hasValue && onClear ? ( + + ) : ( + + )}
); } @@ -678,6 +700,7 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal columnLabels, displayStyle = "table", displayField, + distinct, } = modalConfig; const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : []; @@ -689,13 +712,25 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal setLoading(true); try { const result = await dataApi.getTableData(tableName, { page: 1, size: 200 }); - setAllRows(result.data || []); + let rows = result.data || []; + + if (distinct && displayField) { + const seen = new Set(); + rows = rows.filter((row) => { + const val = String(row[displayField] ?? ""); + if (seen.has(val)) return false; + seen.add(val); + return true; + }); + } + + setAllRows(rows); } catch { setAllRows([]); } finally { setLoading(false); } - }, [tableName]); + }, [tableName, distinct, displayField]); useEffect(() => { if (open) { diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index e91ce7d9..4c52961b 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -997,6 +997,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {

+ {/* 중복 제거 (Distinct) */} +
+
+ updateModal({ distinct: !!checked })} + /> + +
+

+ 표시 필드 기준으로 동일한 값이 여러 건이면 하나만 표시 +

+
+ {/* 검색창에 보일 값 */}
diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 220d5ff9..6da0ae32 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -73,6 +73,9 @@ export interface ModalSelectConfig { displayField: string; valueField: string; + + /** displayField 기준 중복 제거 */ + distinct?: boolean; } /** pop-search 전체 설정 */ From 20ad1d6829be5b1ca13bfa7ef84b5e3ef4869cc0 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Mar 2026 19:52:18 +0900 Subject: [PATCH 5/9] =?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; From 62e11127a7724659f119d9cfc2783d4d5d2b1f38 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Sat, 7 Mar 2026 09:56:58 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(pop-button):=20=EC=A0=9C=EC=96=B4=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?+=20=EC=97=B0=EA=B2=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20UX=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20+=20=ED=95=84=ED=84=B0=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20POP=20=EB=B2=84=ED=8A=BC=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=97=90=EC=84=9C=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EC=96=B4=EA=B4=80=EB=A6=AC(node=5Fflows)?= =?UTF-8?q?=EB=A5=BC=20=EC=A7=81=EC=A0=91=20=EC=8B=A4=ED=96=89=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20"=EC=A0=9C=EC=96=B4?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89"=20=EC=9E=91=EC=97=85=20=EC=9C=A0?= =?UTF-8?q?=ED=98=95=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EA=B3=A0,=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A0=95=20=EC=8B=9C=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EB=90=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=84=A0=ED=83=9D=ED=95=98=EB=8A=94=20UX?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=EC=84=A0=ED=95=9C=EB=8B=A4.=20[=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EC=8B=A4=ED=96=89=20(custom-event=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5)]=20-=20ButtonTask=EC=97=90=20flowId/flowName=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20-=20ControlFlowTaskFo?= =?UTF-8?q?rm:=20Combobox(Popover+Command)=EB=A1=9C=20=EA=B2=80=EC=83=89/?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20UI=20-=20executePopAction:=20flowId=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20POST=20/dataflow/node-flows/:flowId/execut?= =?UTF-8?q?e=20-=20=EA=B8=B0=EC=A1=B4=20eventName=20=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=EB=A9=94=EC=BB=A4=EB=8B=88=EC=A6=98=EC=9D=80=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1=EC=9C=BC=EB=A1=9C=20=EC=9C=A0=EC=A7=80=20[=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20UX=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0]=20-=20extractCardFields=20->=20extractConnectedField?= =?UTF-8?q?s=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=20=20(connections?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EC=97=B0=EA=B2=B0=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=97=90=EC=84=9C=EB=A7=8C=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EC=B6=9C)=20-=20pop-card-list/pop-field/p?= =?UTF-8?q?op-search=20=ED=83=80=EC=9E=85=EB=B3=84=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20-=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=ED=95=84=EB=93=9C(=5F=5Fcart=5Fquantity?= =?UTF-8?q?=20=EB=93=B1)=EC=97=90=20=ED=95=9C=EA=B8=80=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=20=EB=B6=80=EC=97=AC=20-=20UI=20=EB=9D=BC=EB=B2=A8:?= =?UTF-8?q?=20"=ED=99=94=EB=A9=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0"=20->=20"?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EB=90=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0"=20[p?= =?UTF-8?q?op-card-list=20=ED=95=84=ED=84=B0=20UI]=20-=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=EA=B1=B4=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=EC=9D=84=20=EA=B0=80=EB=A1=9C=20->=20=EC=84=B8?= =?UTF-8?q?=EB=A1=9C=20=EC=8A=A4=ED=83=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20-=20=EC=A1=B0=EA=B1=B4=20=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20+=20=EC=9E=85=EB=A0=A5=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EB=86=92=EC=9D=B4=20=ED=99=95=EB=8C=80=20[?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95]=20-=20apiClient=20base?= =?UTF-8?q?URL=20=EC=9D=B4=EC=A4=91=20/api=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EC=9D=91=EB=8B=B5=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20camelCase=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/hooks/pop/executePopAction.ts | 4 +- .../registry/pop-components/pop-button.tsx | 246 ++++++++++++++---- .../pop-card-list/PopCardListConfig.tsx | 169 ++++++------ 3 files changed, 294 insertions(+), 125 deletions(-) diff --git a/frontend/hooks/pop/executePopAction.ts b/frontend/hooks/pop/executePopAction.ts index ada6ad77..ad0981b6 100644 --- a/frontend/hooks/pop/executePopAction.ts +++ b/frontend/hooks/pop/executePopAction.ts @@ -322,7 +322,9 @@ export async function executeTaskList( } case "custom-event": - if (task.eventName) { + if (task.flowId) { + await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {}); + } else if (task.eventName) { publish(task.eventName, task.eventPayload ?? {}); } break; diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index 56889eff..67aaabad 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -23,6 +23,19 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; import { usePopAction } from "@/hooks/pop/usePopAction"; import { executeTaskList, type CollectedPayload } from "@/hooks/pop/executePopAction"; @@ -51,6 +64,7 @@ import { PackageCheck, ChevronRight, GripVertical, + ChevronsUpDown, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -227,9 +241,11 @@ export interface ButtonTask { apiEndpoint?: string; apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; - // custom-event + // custom-event (제어 실행) eventName?: string; eventPayload?: Record; + flowId?: number; + flowName?: string; } /** 빠른 시작 템플릿 */ @@ -345,7 +361,7 @@ export const TASK_TYPE_LABELS: Record = { "close-modal": "모달 닫기", "refresh": "새로고침", "api-call": "API 호출", - "custom-event": "커스텀 이벤트", + "custom-event": "제어 실행", }; /** 빠른 시작 템플릿별 기본 작업 목록 + 외형 */ @@ -1267,39 +1283,80 @@ interface PopButtonConfigPanelProps { componentId?: string; } -/** 화면 내 카드 컴포넌트에서 사용 가능한 필드 목록 추출 */ -function extractCardFields( +/** 연결된 컴포넌트에서 사용 가능한 필드 목록 추출 (연결 기반) */ +function extractConnectedFields( + componentId?: string, + connections?: PopButtonConfigPanelProps["connections"], allComponents?: PopButtonConfigPanelProps["allComponents"], ): { value: string; label: string; source: string }[] { - if (!allComponents) return []; + if (!componentId || !connections || !allComponents) return []; + + const targetIds = connections + .filter((c) => c.sourceComponent === componentId || c.targetComponent === componentId) + .map((c) => (c.sourceComponent === componentId ? c.targetComponent : c.sourceComponent)); + const uniqueIds = [...new Set(targetIds)]; + if (uniqueIds.length === 0) return []; + const fields: { value: string; label: string; source: string }[] = []; - for (const comp of allComponents) { - if (comp.type !== "pop-card-list" || !comp.config) continue; - const tpl = (comp.config as Record).cardTemplate as - | { header?: Record; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } } - | undefined; - if (!tpl) continue; + for (const tid of uniqueIds) { + const comp = allComponents.find((c) => c.id === tid); + if (!comp?.config) continue; + const cfg = comp.config as Record; + const compLabel = (comp as Record).label as string || comp.type || tid; - if (tpl.header?.codeField) { - fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: "헤더 코드" }); + if (comp.type === "pop-card-list") { + const tpl = cfg.cardTemplate as + | { header?: Record; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } } + | undefined; + if (tpl) { + if (tpl.header?.codeField) { + fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: compLabel }); + } + if (tpl.header?.titleField) { + fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: compLabel }); + } + for (const f of tpl.body?.fields ?? []) { + if (f.valueType === "column" && f.columnName) { + fields.push({ value: f.columnName, label: f.label || f.columnName, source: compLabel }); + } else if (f.valueType === "formula" && f.label) { + fields.push({ value: `__formula_${f.id || f.label}`, label: f.label, source: `${compLabel} (수식)` }); + } + } + } + fields.push({ value: "__cart_quantity", label: "사용자 입력 수량", source: `${compLabel} (장바구니)` }); + fields.push({ value: "__cart_row_key", label: "선택한 카드의 원본 키", source: `${compLabel} (장바구니)` }); + fields.push({ value: "__cart_id", label: "장바구니 항목 ID", source: `${compLabel} (장바구니)` }); } - if (tpl.header?.titleField) { - fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: "헤더 제목" }); - } - for (const f of tpl.body?.fields ?? []) { - if (f.valueType === "column" && f.columnName) { - fields.push({ value: f.columnName, label: f.label || f.columnName, source: "본문" }); - } else if (f.valueType === "formula" && f.label) { - const formulaKey = `__formula_${f.id || f.label}`; - fields.push({ value: formulaKey, label: f.label, source: "수식" }); + + if (comp.type === "pop-field") { + const sections = cfg.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({ value: f.fieldName, label: f.labelText || f.fieldName, source: compLabel }); + } + } + } } } - // 시스템 필드 추가 - fields.push({ value: "__cart_quantity", label: "수량 (장바구니)", source: "시스템" }); - fields.push({ value: "__cart_row_key", label: "원본 키", source: "시스템" }); - fields.push({ value: "__cart_id", label: "카드 항목 ID", source: "시스템" }); + if (comp.type === "pop-search") { + const filterCols = cfg.filterColumns as string[] | undefined; + const modalCfg = cfg.modalConfig as { valueField?: string } | undefined; + if (Array.isArray(filterCols) && filterCols.length > 0) { + for (const col of filterCols) { + fields.push({ value: col, label: col, source: compLabel }); + } + } else if (modalCfg?.valueField) { + fields.push({ value: modalCfg.valueField, label: modalCfg.valueField, source: compLabel }); + } else if (cfg.fieldName && typeof cfg.fieldName === "string") { + fields.push({ value: cfg.fieldName, label: (cfg.placeholder as string) || cfg.fieldName, source: compLabel }); + } + } } return fields; @@ -1309,9 +1366,14 @@ export function PopButtonConfigPanel({ config, onUpdate, allComponents, + connections, + componentId, }: PopButtonConfigPanelProps) { const v2 = useMemo(() => migrateButtonConfig(config), [config]); - const cardFields = useMemo(() => extractCardFields(allComponents), [allComponents]); + const cardFields = useMemo( + () => extractConnectedFields(componentId, connections, allComponents), + [componentId, connections, allComponents], + ); const updateV2 = useCallback( (partial: Partial) => { @@ -1560,7 +1622,7 @@ function buildTaskSummary(task: ButtonTask): string { case "api-call": return task.apiEndpoint || ""; case "custom-event": - return task.eventName || ""; + return task.flowName || task.eventName || ""; default: return ""; } @@ -1829,15 +1891,10 @@ function TaskDetailForm({ case "custom-event": return ( -
- - onUpdate({ eventName: e.target.value })} - placeholder="예: data-saved, item-selected" - className="h-8 text-xs" - /> -
+ ); case "refresh": @@ -1897,7 +1954,7 @@ function buildUpdateSummaryText(task: ButtonTask): string | null { } const val = task.valueSource === "linked" - ? `화면 데이터(${task.sourceField || "?"})` + ? `연결 데이터(${task.sourceField || "?"})` : `"${task.fixedValue || "?"}"`; return `[${task.targetTable}].${task.targetColumn} ${opLabel} ${val}`; } @@ -2003,7 +2060,7 @@ function DataUpdateTaskForm({ 직접 입력 - 화면 데이터에서 가져오기 + 연결된 데이터 가져오기
@@ -2022,19 +2079,19 @@ function DataUpdateTaskForm({ {task.valueSource === "linked" && (
- + {cardFields.length > 0 ? ( updateFilter(index, { ...filter, column: val }) } > - - + + {columns.map((col) => ( @@ -2058,45 +2071,36 @@ function FilterSettingsSection({ ))} - - - - - updateFilter(index, { ...filter, value: e.target.value }) - } - placeholder="값" - className="h-7 flex-1 text-xs" - /> - - +
+ + + updateFilter(index, { ...filter, value: e.target.value }) + } + placeholder="값 입력" + className="h-8 flex-1 text-xs" + /> +
))}
@@ -2663,46 +2667,51 @@ function FilterCriteriaSection({ ) : (
{filters.map((filter, index) => ( -
-
- updateFilter(index, { ...filter, column: val || "" })} - placeholder="컬럼 선택" +
+
+ + 조건 {index + 1} + + +
+ updateFilter(index, { ...filter, column: val || "" })} + placeholder="컬럼 선택" + /> +
+ + updateFilter(index, { ...filter, value: e.target.value })} + placeholder="값 입력" + className="h-8 flex-1 text-xs" />
- - updateFilter(index, { ...filter, value: e.target.value })} - placeholder="값" - className="h-7 flex-1 text-xs" - /> -
))}
From 3933f1e9661a566851b87e77018b618c83cbebe8 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 9 Mar 2026 12:16:26 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat(pop):=20PC=20<->=20POP=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EC=A0=84=ED=99=98=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20+=20POP=20=EA=B8=B0=EB=B3=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4(Landing)=20=EA=B8=B0=EB=8A=A5=20PC=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=EC=9D=84=20=ED=86=B5?= =?UTF-8?q?=ED=95=B4=20POP=20=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=ED=95=98=EA=B3=A0,=20POP=EC=97=90=EC=84=9C?= =?UTF-8?q?=20PC=EB=A1=9C=20=EB=8F=8C=EC=95=84=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EC=96=91=EB=B0=A9=ED=96=A5=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20=EA=B8=B0=EC=A1=B4=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C(menu=5Finfo)=EC=9D=84=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=97=AC=20POP=20=ED=99=94=EB=A9=B4=EC=9D=98?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20=EC=A0=9C=EC=96=B4=EC=99=80=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=EB=B3=84=20=EA=B4=80=EB=A6=AC=EA=B0=80=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=ED=95=9C=EB=8B=A4.=20[?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C:=20POP=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API]=20-=20AdminService.getPopMenuList:=20?= =?UTF-8?q?L1=20POP=20=EB=A9=94=EB=89=B4(menu=5Fdesc=20[POP]=20=EB=98=90?= =?UTF-8?q?=EB=8A=94=20=20=20menu=5Fname=5Fkor=20POP=20=ED=8F=AC=ED=95=A8)?= =?UTF-8?q?=20=ED=95=98=EC=9C=84=EC=9D=98=20active=20L2=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=A1=B0=ED=9A=8C=20-=20company=5Fcode=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=A0=81=EC=9A=A9=20(L1=20+=20L2=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90)=20-=20landingMenu=20=EB=B0=98=ED=99=98:=20m?= =?UTF-8?q?enu=5Fdesc=EC=97=90=20[POP=5FLANDING]=20=ED=83=9C=EA=B7=B8?= =?UTF-8?q?=EA=B0=80=20=EC=9E=88=EB=8A=94=20=EB=A9=94=EB=89=B4=20-=20GET?= =?UTF-8?q?=20/admin/pop-menus=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20[=ED=94=84=EB=A1=A0=ED=8A=B8:=20PC=20->=20POP=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85]=20-=20AppLayout:=20handlePopModeClick=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20=20=20-=20landingMenu?= =?UTF-8?q?=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20=ED=95=B4=EB=8B=B9=20URL?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=94=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20=20=20-?= =?UTF-8?q?=20=EC=97=86=EC=9C=BC=EB=A9=B4=20childMenus=20=EC=88=98?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EB=8B=A8=EC=9D=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4/=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C/=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=B6=84=EA=B8=B0=20-=20UserDropdown:=20onPopModeC?= =?UTF-8?q?lick=20prop=20+=20"POP=20=EB=AA=A8=EB=93=9C"=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=ED=95=AD=EB=AA=A9=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=ED=95=98=EB=8B=A8=20+?= =?UTF-8?q?=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=97=A4=EB=8D=94=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=202?= =?UTF-8?q?=EA=B3=B3=20=EB=AA=A8=EB=91=90=20=EC=A0=81=EC=9A=A9=20[?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8:=20POP=20->=20PC=20=EB=B3=B5?= =?UTF-8?q?=EA=B7=80]=20-=20DashboardHeader:=20"PC=20=EB=AA=A8=EB=93=9C"?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80=20(router.push=20"/"?= =?UTF-8?q?)=20-=20POP=20=EA=B0=9C=EB=B3=84=20=ED=99=94=EB=A9=B4=20page.ts?= =?UTF-8?q?x:=20=EC=83=81=EB=8B=A8=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=B0=94=20=EC=B6=94=EA=B0=80=20=20=20(POP=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20/=20PC=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EB=B2=84=ED=8A=BC)=20[=ED=94=84=EB=A1=A0=ED=8A=B8:?= =?UTF-8?q?=20POP=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EB=A9=94=EB=89=B4]=20-=20PopDashboard:=20=ED=95=98?= =?UTF-8?q?=EB=93=9C=EC=BD=94=EB=94=A9=20MENU=5FITEMS=20->=20menuApi.getPo?= =?UTF-8?q?pMenus()=20API=20=EC=A1=B0=ED=9A=8C=20-=20API=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20?= =?UTF-8?q?fallback=20=EC=9C=A0=EC=A7=80=20[=ED=94=84=EB=A1=A0=ED=8A=B8:?= =?UTF-8?q?=20POP=20=EA=B8=B0=EB=B3=B8=20=ED=99=94=EB=A9=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(MenuFormModal)]=20-=20L2=20POP=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=88=98=EC=A0=95=20=EC=8B=9C=20"POP=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95"=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=B2=B4=ED=81=AC=20=EC=8B=9C=20menu=5Fdesc?= =?UTF-8?q?=EC=97=90=20[POP=5FLANDING]=20=ED=83=9C=EA=B7=B8=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B6=94=EA=B0=80/=EC=A0=9C=EA=B1=B0=20-=20?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=EB=8B=B9=201=EA=B0=9C=EB=A7=8C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B0=80=EB=8A=A5=20(=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=EC=97=90=20=EC=9D=B4=EB=AF=B8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94)?= =?UTF-8?q?=20[API=20=ED=83=80=EC=9E=85]=20-=20PopMenuItem,=20PopMenuRespo?= =?UTF-8?q?nse(landingMenu=20=ED=8F=AC=ED=95=A8)=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80=20-=20menuApi.?= =?UTF-8?q?getPopMenus()=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 40 +++ backend-node/src/routes/adminRoutes.ts | 2 + backend-node/src/services/adminService.ts | 68 +++++ .../app/(pop)/pop/screens/[screenId]/page.tsx | 23 +- frontend/components/admin/MenuFormModal.tsx | 273 ++++++++++++++++-- frontend/components/layout/AppLayout.tsx | 35 ++- frontend/components/layout/MainHeader.tsx | 5 +- frontend/components/layout/UserDropdown.tsx | 11 +- .../pop/dashboard/DashboardHeader.tsx | 15 +- .../components/pop/dashboard/PopDashboard.tsx | 53 +++- frontend/lib/api/menu.ts | 23 ++ 11 files changed, 506 insertions(+), 42 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 7b3b1033..ef1df939 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -107,6 +107,46 @@ export async function getUserMenus( } } +/** + * POP 메뉴 목록 조회 + * [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환 + */ +export async function getPopMenus( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const userCompanyCode = req.user?.companyCode || "ILSHIN"; + const userType = req.user?.userType; + + const result = await AdminService.getPopMenuList({ + userCompanyCode, + userType, + }); + + const response: ApiResponse = { + success: true, + message: "POP 메뉴 목록 조회 성공", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("POP 메뉴 목록 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.", + error: { + code: "POP_MENU_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + /** * 메뉴 정보 조회 */ diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index b9964962..a0779d50 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { getAdminMenus, getUserMenus, + getPopMenus, getMenuInfo, saveMenu, // 메뉴 추가 updateMenu, // 메뉴 수정 @@ -40,6 +41,7 @@ router.use(authenticateToken); // 메뉴 관련 API router.get("/menus", getAdminMenus); router.get("/user-menus", getUserMenus); +router.get("/pop-menus", getPopMenus); router.get("/menus/:menuId", getMenuInfo); router.post("/menus", saveMenu); // 메뉴 추가 router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!) diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index ef41012f..0ae309b7 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -615,6 +615,74 @@ export class AdminService { } } + /** + * POP 메뉴 목록 조회 + * menu_name_kor에 'POP'이 포함되거나 menu_desc에 [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환 + * [POP_LANDING] 태그가 있는 하위 메뉴를 landingMenu로 별도 반환 + */ + static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> { + try { + const { userCompanyCode, userType } = paramMap; + logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType }); + + let queryParams: any[] = []; + let paramIndex = 1; + + let companyFilter = ""; + if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { + companyFilter = `AND COMPANY_CODE = '*'`; + } else { + companyFilter = `AND COMPANY_CODE = $${paramIndex}`; + queryParams.push(userCompanyCode); + paramIndex++; + } + + // POP L1 메뉴 조회 + const parentMenus = await query( + `SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS + FROM MENU_INFO + WHERE PARENT_OBJ_ID = 0 + AND MENU_TYPE = 1 + AND ( + MENU_DESC LIKE '%[POP]%' + OR UPPER(MENU_NAME_KOR) LIKE '%POP%' + ) + ${companyFilter} + ORDER BY SEQ + LIMIT 1`, + queryParams + ); + + if (parentMenus.length === 0) { + logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)"); + return { parentMenu: null, childMenus: [], landingMenu: null }; + } + + const parentMenu = parentMenus[0]; + + // 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링) + const childMenus = await query( + `SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS + FROM MENU_INFO + WHERE PARENT_OBJ_ID = $1 + AND STATUS = 'active' + AND COMPANY_CODE = $2 + ORDER BY SEQ`, + [parentMenu.objid, parentMenu.company_code] + ); + + // [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정 + const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null; + + logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`); + + return { parentMenu, childMenus, landingMenu }; + } catch (error) { + logger.error("AdminService.getPopMenuList 오류:", error); + throw error; + } + } + /** * 메뉴 정보 조회 */ diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 3d49aeb9..6919737e 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; +import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { useRouter } from "next/navigation"; @@ -284,14 +284,23 @@ function PopScreenViewPage() {
)} + {/* 일반 모드 네비게이션 바 */} + {!isPreviewMode && ( +
+ + {screen.screenName} + +
+ )} + {/* POP 화면 컨텐츠 */}
- {/* 현재 모드 표시 (일반 모드) */} - {!isPreviewMode && ( -
- {currentModeKey.replace("_", " ")} -
- )}
= ({ }); // 화면 할당 관련 상태 - const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당) + const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen"); const [selectedScreen, setSelectedScreen] = useState(null); const [screens, setScreens] = useState([]); const [screenSearchText, setScreenSearchText] = useState(""); const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false); + // POP 화면 할당 관련 상태 + const [selectedPopScreen, setSelectedPopScreen] = useState(null); + const [popScreenSearchText, setPopScreenSearchText] = useState(""); + const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false); + const [isPopLanding, setIsPopLanding] = useState(false); + const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false); + // 대시보드 할당 관련 상태 const [selectedDashboard, setSelectedDashboard] = useState(null); const [dashboards, setDashboards] = useState([]); @@ -194,8 +201,27 @@ export const MenuFormModal: React.FC = ({ toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`); }; + // POP 화면 선택 시 URL 자동 설정 + const handlePopScreenSelect = (screen: ScreenDefinition) => { + const actualScreenId = screen.screenId || screen.id; + if (!actualScreenId) { + toast.error("화면 ID를 찾을 수 없습니다."); + return; + } + + setSelectedPopScreen(screen); + setIsPopScreenDropdownOpen(false); + + const popUrl = `/pop/screens/${actualScreenId}`; + + setFormData((prev) => ({ + ...prev, + menuUrl: popUrl, + })); + }; + // URL 타입 변경 시 처리 - const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => { + const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => { // console.log("🔄 URL 타입 변경:", { // from: urlType, // to: type, @@ -206,36 +232,53 @@ export const MenuFormModal: React.FC = ({ setUrlType(type); if (type === "direct") { - // 직접 입력 모드로 변경 시 선택된 화면 초기화 setSelectedScreen(null); - // URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록) + setSelectedPopScreen(null); setFormData((prev) => ({ ...prev, menuUrl: "", - screenCode: undefined, // 화면 코드도 함께 초기화 + screenCode: undefined, })); - } else { - // 화면 할당 모드로 변경 시 - // 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지 + } else if (type === "pop") { + setSelectedScreen(null); + if (selectedPopScreen) { + const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id; + setFormData((prev) => ({ + ...prev, + menuUrl: `/pop/screens/${actualScreenId}`, + })); + } else { + setFormData((prev) => ({ + ...prev, + menuUrl: "", + })); + } + } else if (type === "screen") { + setSelectedPopScreen(null); if (selectedScreen) { - console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName); - // 현재 선택된 화면으로 URL 재생성 const actualScreenId = selectedScreen.screenId || selectedScreen.id; let screenUrl = `/screens/${actualScreenId}`; - - // 관리자 메뉴인 경우 mode=admin 파라미터 추가 const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0"; if (isAdminMenu) { screenUrl += "?mode=admin"; } - setFormData((prev) => ({ ...prev, menuUrl: screenUrl, - screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지 + screenCode: selectedScreen.screenCode, })); } else { - // 선택된 화면이 없으면 URL과 screenCode 초기화 + setFormData((prev) => ({ + ...prev, + menuUrl: "", + screenCode: undefined, + })); + } + } else { + // dashboard + setSelectedScreen(null); + setSelectedPopScreen(null); + if (!selectedDashboard) { setFormData((prev) => ({ ...prev, menuUrl: "", @@ -294,8 +337,8 @@ export const MenuFormModal: React.FC = ({ const menuUrl = menu.menu_url || menu.MENU_URL || ""; - // URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정) - const isScreenUrl = menuUrl.startsWith("/screens/"); + const isPopScreenUrl = menuUrl.startsWith("/pop/screens/"); + const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/"); setFormData({ objid: menu.objid || menu.OBJID, @@ -356,10 +399,31 @@ export const MenuFormModal: React.FC = ({ }, 500); } } + } else if (isPopScreenUrl) { + setUrlType("pop"); + setSelectedScreen(null); + + // [POP_LANDING] 태그 감지 + const menuDesc = menu.menu_desc || menu.MENU_DESC || ""; + setIsPopLanding(menuDesc.includes("[POP_LANDING]")); + + const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1]; + if (popScreenId) { + const setPopScreenFromId = () => { + const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId); + if (screen) { + setSelectedPopScreen(screen); + } + }; + if (screens.length > 0) { + setPopScreenFromId(); + } else { + setTimeout(setPopScreenFromId, 500); + } + } } else if (menuUrl.startsWith("/dashboard/")) { setUrlType("dashboard"); setSelectedScreen(null); - // 대시보드 ID 추출 및 선택은 useEffect에서 처리됨 } else { setUrlType("direct"); setSelectedScreen(null); @@ -404,6 +468,7 @@ export const MenuFormModal: React.FC = ({ } else { console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType); setIsEdit(false); + setIsPopLanding(false); // 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1) let defaultMenuType = "1"; // 기본값은 사용자 @@ -420,9 +485,9 @@ export const MenuFormModal: React.FC = ({ menuDesc: "", seq: 1, menuType: defaultMenuType, - status: "ACTIVE", // 기본값은 활성화 - companyCode: parentCompanyCode || "none", // 상위 메뉴의 회사 코드를 기본값으로 설정 - langKey: "", // 다국어 키 초기화 + status: "ACTIVE", + companyCode: parentCompanyCode || "none", + langKey: "", }); // console.log("메뉴 등록 기본값 설정:", { @@ -465,6 +530,31 @@ export const MenuFormModal: React.FC = ({ } }, [isOpen, formData.companyCode]); + // POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인 + useEffect(() => { + if (!isOpen) return; + + const checkOtherPopLanding = async () => { + try { + const res = await menuApi.getPopMenus(); + if (res.success && res.data?.landingMenu) { + const landingObjId = res.data.landingMenu.objid?.toString(); + const currentObjId = formData.objid?.toString(); + // 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복 + setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId); + } else { + setHasOtherPopLanding(false); + } + } catch { + setHasOtherPopLanding(false); + } + }; + + if (urlType === "pop") { + checkOtherPopLanding(); + } + }, [isOpen, urlType, formData.objid]); + // 화면 목록 및 대시보드 목록 로드 useEffect(() => { if (isOpen) { @@ -512,6 +602,22 @@ export const MenuFormModal: React.FC = ({ } }, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]); + // POP 화면 목록 로드 완료 후 기존 할당 설정 + useEffect(() => { + if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") { + const menuUrl = formData.menuUrl; + if (menuUrl.startsWith("/pop/screens/")) { + const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1]; + if (popScreenId && !selectedPopScreen) { + const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId); + if (screen) { + setSelectedPopScreen(screen); + } + } + } + } + }, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]); + // 드롭다운 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -528,16 +634,20 @@ export const MenuFormModal: React.FC = ({ setIsDashboardDropdownOpen(false); setDashboardSearchText(""); } + if (!target.closest(".pop-screen-dropdown")) { + setIsPopScreenDropdownOpen(false); + setPopScreenSearchText(""); + } }; - if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) { + if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; - }, [isLangKeyDropdownOpen, isScreenDropdownOpen]); + }, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]); const loadCompanies = async () => { try { @@ -585,10 +695,17 @@ export const MenuFormModal: React.FC = ({ try { setLoading(true); + // POP 기본 화면 태그 처리 + let finalMenuDesc = formData.menuDesc; + if (urlType === "pop") { + const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim(); + finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag; + } + // 백엔드에 전송할 데이터 변환 const submitData = { ...formData, - // 상태를 소문자로 변환 (백엔드에서 소문자 기대) + menuDesc: finalMenuDesc, status: formData.status.toLowerCase(), }; @@ -843,7 +960,7 @@ export const MenuFormModal: React.FC = ({ {/* URL 타입 선택 */} - +
+
+ + +
)} + {/* POP 화면 할당 */} + {urlType === "pop" && ( +
+
+ + + {isPopScreenDropdownOpen && ( +
+
+
+ + setPopScreenSearchText(e.target.value)} + className="pl-8" + /> +
+
+ +
+ {screens + .filter( + (screen) => + screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()), + ) + .map((screen, index) => ( +
handlePopScreenSelect(screen)} + className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100" + > +
+
+
{screen.screenName}
+
{screen.screenCode}
+
+
ID: {screen.screenId || screen.id || "N/A"}
+
+
+ ))} + {screens.filter( + (screen) => + screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()), + ).length === 0 &&
검색 결과가 없습니다.
} +
+
+ )} +
+ + {selectedPopScreen && ( +
+
{selectedPopScreen.screenName}
+
코드: {selectedPopScreen.screenCode}
+
생성된 URL: {formData.menuUrl}
+
+ )} + + {/* POP 기본 화면 설정 */} +
+ setIsPopLanding(e.target.checked)} + className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50" + /> + + {!isPopLanding && hasOtherPopLanding && ( + + (이미 다른 메뉴가 기본 화면으로 설정되어 있습니다) + + )} +
+ {isPopLanding && ( +

+ 프로필에서 POP 모드 전환 시 이 화면으로 바로 이동합니다. +

+ )} +
+ )} + {/* URL 직접 입력 */} {urlType === "direct" && ( { + try { + const response = await menuApi.getPopMenus(); + if (response.success && response.data) { + const { childMenus, landingMenu } = response.data; + + if (landingMenu?.menu_url) { + router.push(landingMenu.menu_url); + } else if (childMenus.length === 0) { + toast.info("설정된 POP 화면이 없습니다"); + } else if (childMenus.length === 1) { + router.push(childMenus[0].menu_url); + } else { + router.push("/pop"); + } + } else { + toast.info("설정된 POP 화면이 없습니다"); + } + } catch (error) { + toast.error("POP 메뉴 조회 중 오류가 발생했습니다"); + } + }; + // 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용) const renderMenu = (menu: any, level: number = 0) => { const isExpanded = expandedMenus.has(menu.id); @@ -518,6 +543,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { 프로필 + + + POP 모드 + 로그아웃 @@ -686,6 +715,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { 프로필 + + + POP 모드 + 로그아웃 diff --git a/frontend/components/layout/MainHeader.tsx b/frontend/components/layout/MainHeader.tsx index f04dcca3..2b6ab40d 100644 --- a/frontend/components/layout/MainHeader.tsx +++ b/frontend/components/layout/MainHeader.tsx @@ -6,13 +6,14 @@ interface MainHeaderProps { user: any; onSidebarToggle: () => void; onProfileClick: () => void; + onPopModeClick?: () => void; onLogout: () => void; } /** * 메인 헤더 컴포넌트 */ -export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) { +export function MainHeader({ user, onSidebarToggle, onProfileClick, onPopModeClick, onLogout }: MainHeaderProps) { return (
@@ -27,7 +28,7 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: {/* Right side - Admin Button + User Menu */}
- +
diff --git a/frontend/components/layout/UserDropdown.tsx b/frontend/components/layout/UserDropdown.tsx index 4ce4ef01..45ec6bfd 100644 --- a/frontend/components/layout/UserDropdown.tsx +++ b/frontend/components/layout/UserDropdown.tsx @@ -8,18 +8,19 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { LogOut, User } from "lucide-react"; +import { LogOut, Monitor, User } from "lucide-react"; interface UserDropdownProps { user: any; onProfileClick: () => void; + onPopModeClick?: () => void; onLogout: () => void; } /** * 사용자 드롭다운 메뉴 컴포넌트 */ -export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) { +export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) { if (!user) return null; return ( @@ -79,6 +80,12 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro 프로필
+ {onPopModeClick && ( + + + POP 모드 + + )} 로그아웃 diff --git a/frontend/components/pop/dashboard/DashboardHeader.tsx b/frontend/components/pop/dashboard/DashboardHeader.tsx index a16cbb05..20136c59 100644 --- a/frontend/components/pop/dashboard/DashboardHeader.tsx +++ b/frontend/components/pop/dashboard/DashboardHeader.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Moon, Sun } from "lucide-react"; +import { Moon, Sun, Monitor } from "lucide-react"; import { WeatherInfo, UserInfo, CompanyInfo } from "./types"; interface DashboardHeaderProps { @@ -11,6 +11,7 @@ interface DashboardHeaderProps { company: CompanyInfo; onThemeToggle: () => void; onUserClick: () => void; + onPcModeClick?: () => void; } export function DashboardHeader({ @@ -20,6 +21,7 @@ export function DashboardHeader({ company, onThemeToggle, onUserClick, + onPcModeClick, }: DashboardHeaderProps) { const [mounted, setMounted] = useState(false); const [currentTime, setCurrentTime] = useState(new Date()); @@ -81,6 +83,17 @@ export function DashboardHeader({
{company.subTitle}
+ {/* PC 모드 복귀 */} + {onPcModeClick && ( + + )} + {/* 사용자 배지 */} + + + {isLoggedIn && user ? ( + <> + {/* 사용자 정보 */} +
+
+ {user.photo && user.photo.trim() !== "" && user.photo !== "null" ? ( + {user.userName + ) : ( + initial + )} +
+
+ + {user.userName || "사용자"} ({user.userId || ""}) + + + {user.deptName || "부서 정보 없음"} + +
+
+ + {/* 메뉴 항목 */} +
+ {config.showDashboardLink && ( + + )} + {config.showPcMode && ( + + )} + {config.showLogout && ( + <> +
+ + + )} +
+ + ) : ( +
+

+ 로그인이 필요합니다 +

+ +
+ )} + + +
+ ); +} + +// ======================================== +// 설정 패널 +// ======================================== + +interface PopProfileConfigPanelProps { + config: PopProfileConfig; + onUpdate: (config: PopProfileConfig) => void; +} + +function PopProfileConfigPanel({ config: rawConfig, onUpdate }: PopProfileConfigPanelProps) { + const config = useMemo(() => ({ + ...DEFAULT_CONFIG, + ...rawConfig, + }), [rawConfig]); + + const updateConfig = (partial: Partial) => { + onUpdate({ ...config, ...partial }); + }; + + return ( +
+ {/* 아바타 크기 */} +
+ + +
+ + {/* 메뉴 항목 토글 */} +
+ + +
+ + updateConfig({ showDashboardLink: v })} + /> +
+ +
+ + updateConfig({ showPcMode: v })} + /> +
+ +
+ + updateConfig({ showLogout: v })} + /> +
+
+
+ ); +} + +// ======================================== +// 디자이너 미리보기 +// ======================================== + +function PopProfilePreview({ config }: { config?: PopProfileConfig }) { + const size = AVATAR_SIZE_MAP[config?.avatarSize || "md"]; + return ( +
+
+ +
+ 프로필 +
+ ); +} + +// ======================================== +// 레지스트리 등록 +// ======================================== + +PopComponentRegistry.registerComponent({ + id: "pop-profile", + name: "프로필", + description: "사용자 프로필 / PC 전환 / 로그아웃", + category: "action", + icon: "UserCircle", + component: PopProfileComponent, + configPanel: PopProfileConfigPanel, + preview: PopProfilePreview, + defaultProps: { + avatarSize: "md", + showDashboardLink: true, + showPcMode: true, + showLogout: true, + }, + connectionMeta: { + sendable: [], + receivable: [], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); From 48e9ece4f7adf14107ff880f5a3cbbf32bfe6513 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 9 Mar 2026 15:15:15 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat(login):=20POP=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=EC=B6=94=EA=B0=80=20-=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20POP/PC=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=8F=BC?= =?UTF-8?q?=EC=97=90=20POP=20=EB=AA=A8=EB=93=9C=20Switch=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=ED=98=84=EC=9E=A5=20=EC=9E=91=EC=97=85=EC=9E=90=EA=B0=80=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=EC=A0=90=EC=97=90?= =?UTF-8?q?=EC=84=9C=20POP=20=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=A7=84=EC=9E=85=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8F=84=EB=A1=9D=20=ED=95=9C=EB=8B=A4.=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EC=83=81=ED=83=9C=EB=8A=94=20localStorage=EC=97=90?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EB=90=98=EC=96=B4=20=EB=8B=A4=EC=9D=8C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=EB=90=9C=EB=8B=A4.=20[=EB=B0=B1=EC=97=94=EB=93=9C]=20-=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=91=EB=8B=B5=EC=97=90=20pop?= =?UTF-8?q?LandingPath=20=EC=B6=94=EA=B0=80=20(getPopMenuList=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9)=20-=20AdminService/paramMap=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=8A=A4=EC=BD=94=ED=94=84=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=20=20(try=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20=EC=84=A0=EC=96=B8=20->=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99)=20[=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C]=20-=20useLogin:=20isPopMode=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20+=20localStorage=20=EC=97=B0=EB=8F=99=20+?= =?UTF-8?q?=20POP=20=EB=B6=84=EA=B8=B0=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20-?= =?UTF-8?q?=20LoginForm:=20POP=20=EB=AA=A8=EB=93=9C=20Switch=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20UI=20(Monitor=20=EC=95=84=EC=9D=B4=EC=BD=98)=20-=20?= =?UTF-8?q?POP=20=EB=AF=B8=EC=84=A4=EC=A0=95=20=EC=8B=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=ED=9B=84=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=A4=91=EB=8B=A8=20?= =?UTF-8?q?-=20LoginResponse=20=ED=83=80=EC=9E=85=EC=97=90=20popLandingPat?= =?UTF-8?q?h=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/authController.ts | 42 +++++++++++------- frontend/app/(auth)/login/page.tsx | 15 ++++++- frontend/components/auth/LoginForm.tsx | 20 ++++++++- frontend/hooks/useLogin.ts | 44 ++++++++++++++----- frontend/types/auth.ts | 1 + 5 files changed, 93 insertions(+), 29 deletions(-) diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index ebf3e8f5..21673369 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -50,29 +50,24 @@ export class AuthController { logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); + // 메뉴 조회를 위한 공통 파라미터 + const { AdminService } = await import("../services/adminService"); + const paramMap = { + userId: loginResult.userInfo.userId, + userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", + userType: loginResult.userInfo.userType, + userLang: "ko", + }; + // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; try { - const { AdminService } = await import("../services/adminService"); - const paramMap = { - userId: loginResult.userInfo.userId, - userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", - userType: loginResult.userInfo.userType, - userLang: "ko", - }; - const menuList = await AdminService.getUserMenuList(paramMap); logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); - // 접근 가능한 첫 번째 메뉴 찾기 - // 조건: - // 1. LEV (레벨)이 2 이상 (최상위 폴더 제외) - // 2. MENU_URL이 있고 비어있지 않음 - // 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴 const firstMenu = menuList.find((menu: any) => { const level = menu.lev || menu.level; const url = menu.menu_url || menu.url; - return level >= 2 && url && url.trim() !== "" && url !== "#"; }); @@ -86,13 +81,30 @@ export class AuthController { logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); } + // POP 랜딩 경로 조회 + let popLandingPath: string | null = null; + try { + const popResult = await AdminService.getPopMenuList(paramMap); + if (popResult.landingMenu?.menu_url) { + popLandingPath = popResult.landingMenu.menu_url; + } else if (popResult.childMenus.length === 1) { + popLandingPath = popResult.childMenus[0].menu_url; + } else if (popResult.childMenus.length > 1) { + popLandingPath = "/pop"; + } + logger.debug(`POP 랜딩 경로: ${popLandingPath}`); + } catch (popError) { + logger.warn("POP 메뉴 조회 중 오류 (무시):", popError); + } + res.status(200).json({ success: true, message: "로그인 성공", data: { userInfo, token: loginResult.token, - firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가 + firstMenuPath, + popLandingPath, }, }); } else { diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index fe697cee..d105df77 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -10,8 +10,17 @@ import { LoginFooter } from "@/components/auth/LoginFooter"; * 비즈니스 로직은 useLogin 훅에서 처리하고, UI 컴포넌트들을 조합하여 구성 */ export default function LoginPage() { - const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } = - useLogin(); + const { + formData, + isLoading, + error, + showPassword, + isPopMode, + handleInputChange, + handleLogin, + togglePasswordVisibility, + togglePopMode, + } = useLogin(); return (
@@ -23,9 +32,11 @@ export default function LoginPage() { isLoading={isLoading} error={error} showPassword={showPassword} + isPopMode={isPopMode} onInputChange={handleInputChange} onSubmit={handleLogin} onTogglePassword={togglePasswordVisibility} + onTogglePop={togglePopMode} /> diff --git a/frontend/components/auth/LoginForm.tsx b/frontend/components/auth/LoginForm.tsx index dda3736f..7eadbfeb 100644 --- a/frontend/components/auth/LoginForm.tsx +++ b/frontend/components/auth/LoginForm.tsx @@ -2,7 +2,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Eye, EyeOff, Loader2 } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Eye, EyeOff, Loader2, Monitor } from "lucide-react"; import { LoginFormData } from "@/types/auth"; import { ErrorMessage } from "./ErrorMessage"; @@ -11,9 +12,11 @@ interface LoginFormProps { isLoading: boolean; error: string; showPassword: boolean; + isPopMode: boolean; onInputChange: (e: React.ChangeEvent) => void; onSubmit: (e: React.FormEvent) => void; onTogglePassword: () => void; + onTogglePop: () => void; } /** @@ -24,9 +27,11 @@ export function LoginForm({ isLoading, error, showPassword, + isPopMode, onInputChange, onSubmit, onTogglePassword, + onTogglePop, }: LoginFormProps) { return ( @@ -82,6 +87,19 @@ export function LoginForm({
+ {/* POP 모드 토글 */} +
+
+ + POP 모드 +
+ +
+ {/* 로그인 버튼 */}