From 47384e1c2ba934b61b51eb138d3c9ee9f045a9f9 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Mar 2026 11:00:31 +0900 Subject: [PATCH 01/16] =?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 02/16] =?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 03/16] =?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 04/16] =?UTF-8?q?feat(pop):=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=B1=84=EB=B2=88=20+=20=EB=AA=A8=EB=8B=AC=20distinct=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20+=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=ED=95=B4=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=9E=A5?= =?UTF-8?q?=EB=B0=94=EA=B5=AC=EB=8B=88=EC=97=90=EC=84=9C=20=EC=97=AC?= =?UTF-8?q?=EB=9F=AC=20=ED=92=88=EB=AA=A9=EC=9D=84=20=ED=95=9C=EA=BA=BC?= =?UTF-8?q?=EB=B2=88=EC=97=90=20=EC=9E=85=EA=B3=A0=20=ED=99=95=EC=A0=95?= =?UTF-8?q?=ED=95=A0=20=EB=95=8C=20=EB=8F=99=EC=9D=BC=ED=95=9C=20=EC=9E=85?= =?UTF-8?q?=EA=B3=A0=EB=B2=88=ED=98=B8=EB=A5=BC=20=EA=B3=B5=EC=9C=A0?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=9D=BC=EA=B4=84=20=EC=B1=84?= =?UTF-8?q?=EB=B2=88(shareAcrossItems)=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EA=B3=A0,=20=EC=9E=85=EA=B3=A0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EA=B2=80=EC=83=89=20=EC=8B=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=95=AD=EB=AA=A9=EC=9D=84=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=98=EB=8A=94=20distinct=20=EC=98=B5=EC=85=98=EA=B3=BC=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EB=90=9C=20=ED=95=84=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=ED=95=98=EB=8A=94=20X=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4.=20[=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EC=B1=84=EB=B2=88]=20-=20pop-field=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=83=9D=EC=84=B1=20=EC=84=A4=EC=A0=95=EC=97=90=20sha?= =?UTF-8?q?reAcrossItems=20=EC=8A=A4=EC=9C=84=EC=B9=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EB=B0=B1=EC=97=94=EB=93=9C=20data-save=20/=20i?= =?UTF-8?q?nbound-confirm:=20shareAcrossItems=3Dtrue=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=EC=9D=80=20=20=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=A3=A8?= =?UTF-8?q?=ED=94=84=20=EC=A0=84=201=ED=9A=8C=EB=A7=8C=20allocateCode=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=ED=95=98=EC=97=AC=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=9C=EA=B8=89=20-=20PopFieldComponent?= =?UTF-8?q?=EC=97=90=EC=84=9C=20shareAcrossItems=20=EA=B0=92=EC=9D=84=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=EB=A1=9C=20=EC=A0=84=EB=8B=AC=20[?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20distinct]=20-=20ModalSelectConfig=EC=97=90?= =?UTF-8?q?=20distinct=3F:=20boolean=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=ED=83=AD=20=EC=98=81=EC=97=AD=EC=97=90=20?= =?UTF-8?q?"=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0"=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=EB=B0=B0=EC=B9=98=20-=20ModalDialog=20fet?= =?UTF-8?q?chData=EC=97=90=EC=84=9C=20displayField=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?Set=20=ED=95=84=ED=84=B0=EB=A7=81=20[=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C]=20-=20ModalSearchInput:=20=EA=B0=92=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=8B=9C=20>=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20->=20X=20=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20-=20X=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20m?= =?UTF-8?q?odalDisplayText=20+=20=ED=95=84=ED=84=B0=EA=B0=92=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20(stopPropagation)=20-=20handleModalClear?= =?UTF-8?q?=20=EC=BD=9C=EB=B0=B1=20+=20onModalClear=20prop=20=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=20=EC=97=B0=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 05/16] =?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 06/16] =?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 07/16] =?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 09/16] =?UTF-8?q?feat(login):=20POP=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20=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 모드 +
+ +
+ {/* 로그인 버튼 */} + + {filteredRows.length}건{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}건` : ""} + +
+ {isExpanded && needsPagination && ( +
+ + {currentPage} / {totalPages} + +
+ )} +
+
+ )} + + )} +
+ ); +} + +// ===== 카드 V2 ===== + +interface CardV2Props { + row: RowData; + cardGrid?: CardGridConfigV2; + spec: CardPresetSpec; + config?: PopCardListV2Config; + onSelect?: (row: RowData) => void; + cart: ReturnType; + publish: (eventName: string, payload?: unknown) => void; + parentComponentId?: string; + isCartListMode?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + onDeleteItem?: (cartId: string) => void; + onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void; + onRefresh?: () => void; +} + +function CardV2({ + row, cardGrid, spec, config, onSelect, cart, publish, + parentComponentId, isCartListMode, isSelected, onToggleSelect, + onDeleteItem, onUpdateQuantity, onRefresh, +}: CardV2Props) { + const inputField = config?.inputField; + const cartAction = config?.cartAction; + const packageConfig = config?.packageConfig; + const keyColumnName = cartAction?.keyColumn || "id"; + + const [inputValue, setInputValue] = useState(0); + const [packageUnit, setPackageUnit] = useState(undefined); + const [packageEntries, setPackageEntries] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + + const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; + const isCarted = cart.isItemInCart(rowKey); + const existingCartItem = cart.getCartItem(rowKey); + + // DB 장바구니 복원 + useEffect(() => { + if (isCartListMode) return; + if (existingCartItem && existingCartItem._origin === "db") { + setInputValue(existingCartItem.quantity); + setPackageUnit(existingCartItem.packageUnit); + setPackageEntries(existingCartItem.packageEntries || []); + } + }, [isCartListMode, existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); + + // 장바구니 목록 모드 초기값 + useEffect(() => { + if (!isCartListMode) return; + setInputValue(Number(row.__cart_quantity) || 0); + setPackageUnit(row.__cart_package_unit ? String(row.__cart_package_unit) : undefined); + }, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]); + + // 제한 컬럼 자동 초기화 + const limitCol = inputField?.limitColumn || inputField?.maxColumn; + const effectiveMax = useMemo(() => { + if (limitCol) { const v = Number(row[limitCol]); if (!isNaN(v) && v > 0) return v; } + return 999999; + }, [limitCol, row]); + + useEffect(() => { + if (isCartListMode) return; + if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) { + setInputValue(effectiveMax); + } + }, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]); + + const handleInputClick = (e: React.MouseEvent) => { e.stopPropagation(); setIsModalOpen(true); }; + const handleInputConfirm = (value: number, unit?: string, entries?: PackageEntry[]) => { + setInputValue(value); + setPackageUnit(unit); + setPackageEntries(entries || []); + if (isCartListMode) onUpdateQuantity?.(String(row.__cart_id), value, unit, entries); + }; + + const handleCartAdd = () => { + if (!rowKey) return; + cart.addItem({ row, quantity: inputValue, packageUnit, packageEntries: packageEntries.length > 0 ? packageEntries : undefined }, rowKey); + if (parentComponentId) publish(`__comp_output__${parentComponentId}__cart_updated`, { count: cart.cartCount + 1, isDirty: true }); + }; + + const handleCartCancel = () => { + if (!rowKey) return; + cart.removeItem(rowKey); + if (parentComponentId) publish(`__comp_output__${parentComponentId}__cart_updated`, { count: Math.max(0, cart.cartCount - 1), isDirty: true }); + }; + + const handleCartDelete = async (e: React.MouseEvent) => { + e.stopPropagation(); + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; + if (!cartId) return; + if (!window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?")) return; + try { + await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" }); + onDeleteItem?.(cartId); + } catch { toast.error("삭제에 실패했습니다."); } + }; + + const borderClass = isCartListMode + ? isSelected ? "border-primary border-2 hover:border-primary/80" : "hover:border-2 hover:border-blue-500" + : isCarted ? "border-emerald-500 border-2 hover:border-emerald-600" : "hover:border-2 hover:border-blue-500"; + + if (!cardGrid || cardGrid.cells.length === 0) { + return ( +
+ 카드 레이아웃을 설정하세요 +
+ ); + } + + const gridStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: cardGrid.colWidths.length > 0 + ? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ") + : "1fr", + gridTemplateRows: cardGrid.rowHeights?.length + ? cardGrid.rowHeights.map((h) => { + if (!h) return "minmax(24px, auto)"; + if (h.endsWith("px")) return `minmax(${h}, auto)`; + const px = Math.round(parseFloat(h) * 24) || 24; + return `minmax(${px}px, auto)`; + }).join(" ") + : `repeat(${cardGrid.rows || 1}, minmax(24px, auto))`, + gap: `${cardGrid.gap || 0}px`, + }; + + const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const; + const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const; + + return ( +
onSelect?.(row)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect?.(row); }} + > + {/* 장바구니 목록 모드: 체크박스 + 삭제 */} + {isCartListMode && ( +
+ { e.stopPropagation(); onToggleSelect?.(); }} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4 rounded border-input" + /> + +
+ )} + + {/* CSS Grid 기반 셀 렌더링 */} +
+ {cardGrid.cells.map((cell) => ( +
+ {renderCellV2({ + cell, + row, + inputValue, + isCarted, + onInputClick: handleInputClick, + onCartAdd: handleCartAdd, + onCartCancel: handleCartCancel, + onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => { + const cfg = buttonConfig as { + updates?: ActionButtonUpdate[]; + targetTable?: string; + confirmMessage?: string; + } | undefined; + + if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) { + if (cfg.confirmMessage) { + if (!window.confirm(cfg.confirmMessage)) return; + } + try { + const rowId = actionRow.id ?? actionRow.pk; + if (!rowId) { + toast.error("대상 레코드의 ID를 찾을 수 없습니다."); + return; + } + const tasks = cfg.updates.map((u, idx) => ({ + id: `btn-update-${idx}`, + type: "data-update" as const, + targetTable: cfg.targetTable!, + targetColumn: u.column, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: u.valueType === "static" ? (u.value ?? "") : + u.valueType === "currentUser" ? "__CURRENT_USER__" : + u.valueType === "currentTime" ? "__CURRENT_TIME__" : + u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") : + (u.value ?? ""), + })); + const result = await apiClient.post("/pop/execute-action", { + tasks, + data: { items: [actionRow], fieldValues: {} }, + mappings: {}, + }); + if (result.data?.success) { + toast.success(result.data.message || "처리 완료"); + onRefresh?.(); + } else { + toast.error(result.data?.message || "처리 실패"); + } + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } + return; + } + if (parentComponentId) { + publish(`__comp_output__${parentComponentId}__action`, { + taskPreset, + row: actionRow, + }); + } + }, + packageEntries, + inputUnit: inputField?.unit, + })} +
+ ))} +
+ + {inputField?.enabled && ( + + )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx new file mode 100644 index 00000000..a24d6402 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -0,0 +1,2125 @@ +"use client"; + +/** + * pop-card-list-v2 설정 패널 (3탭) + * + * 탭 1: 데이터 — 테이블/컬럼 선택, 조인, 정렬 + * 탭 2: 카드 디자인 — 열 수, 시각적 그리드 디자이너, 셀 클릭 시 타입별 상세 인라인 + * 탭 3: 동작 — 카드 선택 동작, 오버플로우, 카트 + */ + +import { useState, useEffect, useRef, useCallback, Fragment } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Check, ChevronsUpDown, Plus, Minus, Trash2 } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import type { + PopCardListV2Config, + CardGridConfigV2, + CardCellDefinitionV2, + CardCellType, + CardListDataSource, + CardColumnJoin, + CardSortConfig, + V2OverflowConfig, + V2CardClickAction, + ActionButtonUpdate, + TimelineDataSource, +} from "../types"; +import type { ButtonVariant } from "../pop-button"; +import { + fetchTableList, + fetchTableColumns, + type TableInfo, + type ColumnInfo, +} from "../pop-dashboard/utils/dataFetcher"; + +// ===== Props ===== + +interface ConfigPanelProps { + config: PopCardListV2Config | undefined; + onUpdate: (config: PopCardListV2Config) => void; +} + +// ===== 기본 설정값 ===== + +const V2_DEFAULT_CONFIG: PopCardListV2Config = { + dataSource: { tableName: "" }, + cardGrid: { + rows: 1, + cols: 1, + colWidths: ["1fr"], + rowHeights: ["32px"], + gap: 4, + showCellBorder: true, + cells: [], + }, + gridColumns: 3, + cardGap: 8, + scrollDirection: "vertical", + overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 }, + cardClickAction: "none", +}; + +// ===== 탭 정의 ===== + +type V2ConfigTab = "data" | "design" | "actions"; + +const TAB_LABELS: { id: V2ConfigTab; label: string }[] = [ + { id: "data", label: "데이터" }, + { id: "design", label: "카드 디자인" }, + { id: "actions", label: "동작" }, +]; + +// ===== 셀 타입 라벨 ===== + +const V2_CELL_TYPE_LABELS: Record = { + text: { label: "텍스트", group: "기본" }, + field: { label: "필드 (라벨+값)", group: "기본" }, + image: { label: "이미지", group: "기본" }, + badge: { label: "배지", group: "기본" }, + button: { label: "버튼", group: "동작" }, + "number-input": { label: "숫자 입력", group: "입력" }, + "cart-button": { label: "담기 버튼", group: "입력" }, + "package-summary": { label: "포장 요약", group: "요약" }, + "status-badge": { label: "상태 배지", group: "표시" }, + timeline: { label: "타임라인", group: "표시" }, + "footer-status": { label: "하단 상태", group: "표시" }, + "action-buttons": { label: "액션 버튼", group: "동작" }, +}; + +const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작", "요약"] as const; + +// ===== 그리드 유틸 ===== + +const parseFr = (v: string): number => { + const num = parseFloat(v); + return isNaN(num) || num <= 0 ? 1 : num; +}; + +const GRID_LIMITS = { + cols: { min: 1, max: 6 }, + rows: { min: 1, max: 6 }, + gap: { min: 0, max: 16 }, + minFr: 0.3, +} as const; + +const DEFAULT_ROW_HEIGHT = 32; +const MIN_ROW_HEIGHT = 24; + +const parsePx = (v: string): number => { + const num = parseInt(v); + return isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num; +}; + +const migrateRowHeight = (v: string): string => { + if (!v || v.endsWith("fr")) { + return `${Math.round(parseFr(v) * DEFAULT_ROW_HEIGHT)}px`; + } + if (v.endsWith("px")) return v; + const num = parseInt(v); + return `${isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num}px`; +}; + +const shortType = (t: string): string => { + const lower = t.toLowerCase(); + if (lower.includes("character varying") || lower === "varchar") return "varchar"; + if (lower === "text") return "text"; + if (lower.includes("timestamp")) return "ts"; + if (lower === "integer" || lower === "int4") return "int"; + if (lower === "bigint" || lower === "int8") return "bigint"; + if (lower === "numeric" || lower === "decimal") return "num"; + if (lower === "boolean" || lower === "bool") return "bool"; + if (lower === "date") return "date"; + if (lower === "jsonb" || lower === "json") return "json"; + return t.length > 8 ? t.slice(0, 6) + ".." : t; +}; + +// ===== 메인 컴포넌트 ===== + +export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) { + const [tab, setTab] = useState("data"); + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [selectedColumns, setSelectedColumns] = useState([]); + + const cfg: PopCardListV2Config = { + ...V2_DEFAULT_CONFIG, + ...config, + dataSource: { ...V2_DEFAULT_CONFIG.dataSource, ...config?.dataSource }, + cardGrid: { ...V2_DEFAULT_CONFIG.cardGrid, ...config?.cardGrid }, + overflow: { ...V2_DEFAULT_CONFIG.overflow, ...config?.overflow } as V2OverflowConfig, + }; + + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + useEffect(() => { + fetchTableList() + .then(setTables) + .catch(() => setTables([])); + }, []); + + useEffect(() => { + if (!cfg.dataSource.tableName) { + setColumns([]); + return; + } + fetchTableColumns(cfg.dataSource.tableName) + .then(setColumns) + .catch(() => setColumns([])); + }, [cfg.dataSource.tableName]); + + useEffect(() => { + if (cfg.selectedColumns && cfg.selectedColumns.length > 0) { + setSelectedColumns(cfg.selectedColumns); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cfg.dataSource.tableName]); + + return ( +
+ {/* 탭 바 */} +
+ {TAB_LABELS.map((t) => ( + + ))} +
+ + {/* 탭 컨텐츠 */} + {tab === "data" && ( + { + setSelectedColumns([]); + update({ + dataSource: { ...cfg.dataSource, tableName }, + selectedColumns: [], + cardGrid: { ...cfg.cardGrid, cells: [] }, + }); + }} + onColumnsChange={(cols) => { + setSelectedColumns(cols); + update({ selectedColumns: cols }); + }} + onDataSourceChange={(dataSource) => update({ dataSource })} + onSortChange={(sort) => + update({ dataSource: { ...cfg.dataSource, sort } }) + } + /> + )} + + {tab === "design" && ( + update({ cardGrid })} + onGridColumnsChange={(gridColumns) => update({ gridColumns })} + onCardGapChange={(cardGap) => update({ cardGap })} + /> + )} + + {tab === "actions" && ( + + )} +
+ ); +} + +// ===== 탭 1: 데이터 ===== + +function TabData({ + cfg, + tables, + columns, + selectedColumns, + onTableChange, + onColumnsChange, + onDataSourceChange, + onSortChange, +}: { + cfg: PopCardListV2Config; + tables: TableInfo[]; + columns: ColumnInfo[]; + selectedColumns: string[]; + onTableChange: (tableName: string) => void; + onColumnsChange: (cols: string[]) => void; + onDataSourceChange: (ds: CardListDataSource) => void; + onSortChange: (sort: CardSortConfig[] | undefined) => void; +}) { + const [tableOpen, setTableOpen] = useState(false); + const ds = cfg.dataSource; + + const selectedDisplay = ds.tableName + ? tables.find((t) => t.tableName === ds.tableName)?.displayName || ds.tableName + : ""; + + const toggleColumn = (colName: string) => { + if (selectedColumns.includes(colName)) { + onColumnsChange(selectedColumns.filter((c) => c !== colName)); + } else { + onColumnsChange([...selectedColumns, colName]); + } + }; + + const sort = ds.sort?.[0]; + + return ( +
+ {/* 테이블 선택 */} +
+ + + + + + + + + + + 검색 결과가 없습니다 + + + { onTableChange(""); setTableOpen(false); }} + className="text-xs" + > + + 선택 안 함 + + {tables.map((t) => ( + { onTableChange(t.tableName); setTableOpen(false); }} + className="text-xs" + > + +
+ {t.displayName || t.tableName} + {t.displayName && t.displayName !== t.tableName && ( + {t.tableName} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 컬럼 선택 */} + {ds.tableName && columns.length > 0 && ( +
+ +
+ {columns.map((col) => ( + + ))} +
+
+ )} + + {/* 조인 설정 (접이식) */} + {ds.tableName && ( + + )} + + {/* 정렬 */} + {ds.tableName && columns.length > 0 && ( +
+ +
+ + {sort?.column && ( + + )} +
+
+ )} +
+ ); +} + +// ===== 조인 섹션 ===== + +function JoinSection({ + dataSource, + tables, + mainColumns, + onChange, +}: { + dataSource: CardListDataSource; + tables: TableInfo[]; + mainColumns: ColumnInfo[]; + onChange: (ds: CardListDataSource) => void; +}) { + const [expanded, setExpanded] = useState((dataSource.joins?.length || 0) > 0); + const joins = dataSource.joins || []; + + const addJoin = () => { + const newJoin: CardColumnJoin = { + targetTable: "", + joinType: "LEFT", + sourceColumn: "", + targetColumn: "", + }; + onChange({ ...dataSource, joins: [...joins, newJoin] }); + setExpanded(true); + }; + + const removeJoin = (index: number) => { + onChange({ ...dataSource, joins: joins.filter((_, i) => i !== index) }); + }; + + const updateJoin = (index: number, partial: Partial) => { + onChange({ + ...dataSource, + joins: joins.map((j, i) => (i === index ? { ...j, ...partial } : j)), + }); + }; + + return ( +
+ + {expanded && ( +
+

+ 다른 테이블의 데이터를 연결하여 함께 표시 (선택사항) +

+ {joins.map((join, i) => ( + updateJoin(i, partial)} + onRemove={() => removeJoin(i)} + /> + ))} + +
+ )} +
+ ); +} + +// ===== 조인 아이템 ===== + +function JoinItemV2({ + join, + index, + tables, + mainColumns, + mainTableName, + onUpdate, + onRemove, +}: { + join: CardColumnJoin; + index: number; + tables: TableInfo[]; + mainColumns: ColumnInfo[]; + mainTableName: string; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + const [targetColumns, setTargetColumns] = useState([]); + const [tableOpen, setTableOpen] = useState(false); + + useEffect(() => { + if (!join.targetTable) { setTargetColumns([]); return; } + fetchTableColumns(join.targetTable) + .then(setTargetColumns) + .catch(() => setTargetColumns([])); + }, [join.targetTable]); + + const autoMatches = mainColumns.filter((mc) => + targetColumns.some((tc) => tc.name === mc.name && tc.type === mc.type) + ); + + const selectableTables = tables.filter((t) => t.tableName !== mainTableName); + const hasJoinCondition = join.sourceColumn !== "" && join.targetColumn !== ""; + const selectedTargetCols = join.selectedTargetColumns || []; + const pickableTargetCols = targetColumns.filter((tc) => tc.name !== join.targetColumn); + + const toggleTargetCol = (colName: string) => { + const next = selectedTargetCols.includes(colName) + ? selectedTargetCols.filter((c) => c !== colName) + : [...selectedTargetCols, colName]; + onUpdate({ selectedTargetColumns: next }); + }; + + return ( +
+
+ 연결 #{index + 1} + +
+ + {/* 대상 테이블 */} + + + + + + + + + 없음 + + {selectableTables.map((t) => ( + { + onUpdate({ targetTable: t.tableName, sourceColumn: "", targetColumn: "", selectedTargetColumns: [] }); + setTableOpen(false); + }} + className="text-[10px]" + > + + {t.tableName} + + ))} + + + + + + + {/* 자동 매칭 */} + {join.targetTable && autoMatches.length > 0 && ( +
+ 연결 조건 + {autoMatches.map((mc) => { + const isSelected = join.sourceColumn === mc.name && join.targetColumn === mc.name; + return ( + + ); + })} +
+ )} + + {/* 수동 매칭 */} + {join.targetTable && autoMatches.length === 0 && ( +
+ + = + +
+ )} + + {/* 표시 방식 */} + {join.targetTable && ( +
+ {(["LEFT", "INNER"] as const).map((jt) => ( + + ))} +
+ )} + + {/* 가져올 컬럼 */} + {hasJoinCondition && pickableTargetCols.length > 0 && ( +
+ 가져올 컬럼 ({selectedTargetCols.length}개) +
+ {pickableTargetCols.map((tc) => { + const isChecked = selectedTargetCols.includes(tc.name); + return ( + + ); + })} +
+
+ )} +
+ ); +} + +// ===== 탭 2: 카드 디자인 ===== + +function TabCardDesign({ + cfg, + columns, + selectedColumns, + tables, + onGridChange, + onGridColumnsChange, + onCardGapChange, +}: { + cfg: PopCardListV2Config; + columns: ColumnInfo[]; + selectedColumns: string[]; + tables: TableInfo[]; + onGridChange: (g: CardGridConfigV2) => void; + onGridColumnsChange: (n: number) => void; + onCardGapChange: (n: number) => void; +}) { + const availableColumns = columns.filter((c) => selectedColumns.includes(c.name)); + const joinedColumns = (cfg.dataSource.joins || []).flatMap((j) => + (j.selectedTargetColumns || []).map((col) => ({ + name: `${j.targetTable}.${col}`, + displayName: col, + sourceTable: j.targetTable, + })) + ); + const allColumnOptions = [ + ...availableColumns.map((c) => ({ value: c.name, label: c.name })), + ...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), + ]; + + const [selectedCellId, setSelectedCellId] = useState(null); + const [mergeMode, setMergeMode] = useState(false); + const [mergeCellKeys, setMergeCellKeys] = useState>(new Set()); + const widthBarRef = useRef(null); + const gridRef = useRef(null); + const gridConfigRef = useRef(undefined); + const isDraggingRef = useRef(false); + const [gridLines, setGridLines] = useState<{ colLines: number[]; rowLines: number[] }>({ colLines: [], rowLines: [] }); + + // 그리드 정규화 + const rawGrid = cfg.cardGrid; + const migratedRowHeights = (rawGrid.rowHeights || Array(rawGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(migrateRowHeight); + const safeColWidths = rawGrid.colWidths || []; + const normalizedColWidths = safeColWidths.length >= rawGrid.cols + ? safeColWidths.slice(0, rawGrid.cols) + : [...safeColWidths, ...Array(rawGrid.cols - safeColWidths.length).fill("1fr")]; + const normalizedRowHeights = migratedRowHeights.length >= rawGrid.rows + ? migratedRowHeights.slice(0, rawGrid.rows) + : [...migratedRowHeights, ...Array(rawGrid.rows - migratedRowHeights.length).fill(`${DEFAULT_ROW_HEIGHT}px`)]; + + const grid: CardGridConfigV2 = { + ...rawGrid, + colWidths: normalizedColWidths, + rowHeights: normalizedRowHeights, + }; + gridConfigRef.current = grid; + + const updateGrid = (partial: Partial) => { + onGridChange({ ...grid, ...partial }); + }; + + // 점유 맵 + const buildOccupationMap = (): Record => { + const map: Record = {}; + grid.cells.forEach((cell) => { + const rs = Number(cell.rowSpan) || 1; + const cs = Number(cell.colSpan) || 1; + for (let r = cell.row; r < cell.row + rs; r++) { + for (let c = cell.col; c < cell.col + cs; c++) { + map[`${r}-${c}`] = cell.id; + } + } + }); + return map; + }; + const occupationMap = buildOccupationMap(); + const getCellByOrigin = (r: number, c: number) => grid.cells.find((cell) => cell.row === r && cell.col === c); + + // 셀 CRUD + const addCellAt = (row: number, col: number) => { + const newCell: CardCellDefinitionV2 = { + id: `cell-${Date.now()}`, + row, col, rowSpan: 1, colSpan: 1, + type: "text", + }; + updateGrid({ cells: [...grid.cells, newCell] }); + setSelectedCellId(newCell.id); + }; + + const removeCell = (id: string) => { + updateGrid({ cells: grid.cells.filter((c) => c.id !== id) }); + if (selectedCellId === id) setSelectedCellId(null); + }; + + const updateCell = (id: string, partial: Partial) => { + updateGrid({ cells: grid.cells.map((c) => (c.id === id ? { ...c, ...partial } : c)) }); + }; + + // 병합 + const toggleMergeMode = () => { + if (mergeMode) { setMergeMode(false); setMergeCellKeys(new Set()); } + else { setMergeMode(true); setMergeCellKeys(new Set()); setSelectedCellId(null); } + }; + + const toggleMergeCell = (row: number, col: number) => { + const key = `${row}-${col}`; + if (occupationMap[key]) return; + const next = new Set(mergeCellKeys); + if (next.has(key)) next.delete(key); else next.add(key); + setMergeCellKeys(next); + }; + + const validateMerge = (): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null => { + if (mergeCellKeys.size < 2) return null; + const positions = Array.from(mergeCellKeys).map((k) => { const [r, c] = k.split("-").map(Number); return { row: r, col: c }; }); + const minRow = Math.min(...positions.map((p) => p.row)); + const maxRow = Math.max(...positions.map((p) => p.row)); + const minCol = Math.min(...positions.map((p) => p.col)); + const maxCol = Math.max(...positions.map((p) => p.col)); + if (mergeCellKeys.size !== (maxRow - minRow + 1) * (maxCol - minCol + 1)) return null; + for (const key of mergeCellKeys) { if (occupationMap[key]) return null; } + return { minRow, maxRow, minCol, maxCol }; + }; + + const confirmMerge = () => { + const bbox = validateMerge(); + if (!bbox) return; + const newCell: CardCellDefinitionV2 = { + id: `cell-${Date.now()}`, + row: bbox.minRow, col: bbox.minCol, + rowSpan: bbox.maxRow - bbox.minRow + 1, + colSpan: bbox.maxCol - bbox.minCol + 1, + type: "text", + }; + updateGrid({ cells: [...grid.cells, newCell] }); + setSelectedCellId(newCell.id); + setMergeMode(false); + setMergeCellKeys(new Set()); + }; + + // 셀 분할 + const splitCellHorizontally = (cell: CardCellDefinitionV2) => { + const cs = Number(cell.colSpan) || 1; + const rs = Number(cell.rowSpan) || 1; + if (cs >= 2) { + const leftSpan = Math.ceil(cs / 2); + const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row, col: cell.col + leftSpan, rowSpan: rs, colSpan: cs - leftSpan, type: "text" }; + const updatedCells = grid.cells.map((c) => c.id === cell.id ? { ...c, colSpan: leftSpan } : c); + updateGrid({ cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } else { + if (grid.cols >= GRID_LIMITS.cols.max) return; + const insertPos = cell.col + 1; + const updatedCells = grid.cells.map((c) => { + if (c.id === cell.id) return c; + const cEnd = c.col + (Number(c.colSpan) || 1) - 1; + if (c.col >= insertPos) return { ...c, col: c.col + 1 }; + if (cEnd >= insertPos) return { ...c, colSpan: (Number(c.colSpan) || 1) + 1 }; + return c; + }); + const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row, col: insertPos, rowSpan: rs, colSpan: 1, type: "text" }; + const colIdx = cell.col - 1; + if (colIdx < 0 || colIdx >= grid.colWidths.length) return; + const currentFr = parseFr(grid.colWidths[colIdx]); + const halfFr = Math.max(GRID_LIMITS.minFr, currentFr / 2); + const frStr = `${Math.round(halfFr * 10) / 10}fr`; + const newWidths = [...grid.colWidths]; + newWidths[colIdx] = frStr; + newWidths.splice(colIdx + 1, 0, frStr); + updateGrid({ cols: grid.cols + 1, colWidths: newWidths, cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } + }; + + const splitCellVertically = (cell: CardCellDefinitionV2) => { + const rs = Number(cell.rowSpan) || 1; + const cs = Number(cell.colSpan) || 1; + const heights = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`); + if (rs >= 2) { + const topSpan = Math.ceil(rs / 2); + const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row + topSpan, col: cell.col, rowSpan: rs - topSpan, colSpan: cs, type: "text" }; + const updatedCells = grid.cells.map((c) => c.id === cell.id ? { ...c, rowSpan: topSpan } : c); + updateGrid({ cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } else { + if (grid.rows >= GRID_LIMITS.rows.max) return; + const insertPos = cell.row + 1; + const updatedCells = grid.cells.map((c) => { + if (c.id === cell.id) return c; + const cEnd = c.row + (Number(c.rowSpan) || 1) - 1; + if (c.row >= insertPos) return { ...c, row: c.row + 1 }; + if (cEnd >= insertPos) return { ...c, rowSpan: (Number(c.rowSpan) || 1) + 1 }; + return c; + }); + const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: insertPos, col: cell.col, rowSpan: 1, colSpan: cs, type: "text" }; + const newHeights = [...heights]; + newHeights.splice(cell.row - 1 + 1, 0, `${DEFAULT_ROW_HEIGHT}px`); + updateGrid({ rows: grid.rows + 1, rowHeights: newHeights, cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } + }; + + // 클릭 핸들러 + const handleEmptyCellClick = (row: number, col: number) => { + if (mergeMode) toggleMergeCell(row, col); + else addCellAt(row, col); + }; + const handleCellClick = (cell: CardCellDefinitionV2) => { + if (mergeMode) return; + setSelectedCellId(selectedCellId === cell.id ? null : cell.id); + }; + + // 열 너비 드래그 + const handleColDragStart = useCallback((e: React.MouseEvent, dividerIndex: number) => { + e.preventDefault(); + isDraggingRef.current = true; + const startX = e.clientX; + const bar = widthBarRef.current; + if (!bar) return; + const barWidth = bar.offsetWidth; + if (barWidth === 0) return; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const startFrs = (currentGrid.colWidths || []).map(parseFr); + const totalFr = startFrs.reduce((a, b) => a + b, 0); + + const onMove = (me: MouseEvent) => { + const delta = me.clientX - startX; + const frDelta = (delta / barWidth) * totalFr; + const newFrs = [...startFrs]; + newFrs[dividerIndex] = Math.max(GRID_LIMITS.minFr, startFrs[dividerIndex] + frDelta); + newFrs[dividerIndex + 1] = Math.max(GRID_LIMITS.minFr, startFrs[dividerIndex + 1] - frDelta); + onGridChange({ ...currentGrid, colWidths: newFrs.map((fr) => `${Math.round(fr * 10) / 10}fr`) }); + }; + const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [onGridChange]); + + // 행 높이 드래그 + const handleRowDragStart = useCallback((e: React.MouseEvent, dividerIndex: number) => { + e.preventDefault(); + isDraggingRef.current = true; + const startY = e.clientY; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const heights = (currentGrid.rowHeights || Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(parsePx); + if (dividerIndex < 0 || dividerIndex + 1 >= heights.length) return; + + const onMove = (me: MouseEvent) => { + const delta = me.clientY - startY; + const newH = [...heights]; + newH[dividerIndex] = Math.max(MIN_ROW_HEIGHT, heights[dividerIndex] + delta); + newH[dividerIndex + 1] = Math.max(MIN_ROW_HEIGHT, heights[dividerIndex + 1] - delta); + onGridChange({ ...currentGrid, rowHeights: newH.map((h) => `${Math.round(h)}px`) }); + }; + const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [onGridChange]); + + // 내부 셀 경계 드래그 + useEffect(() => { + const gridEl = gridRef.current; + if (!gridEl) return; + const measure = () => { + if (isDraggingRef.current) return; + const style = window.getComputedStyle(gridEl); + const colSizes = style.gridTemplateColumns.split(" ").map(parseFloat).filter((v) => !isNaN(v)); + const rowSizes = style.gridTemplateRows.split(" ").map(parseFloat).filter((v) => !isNaN(v)); + const gapSize = parseFloat(style.gap) || 0; + const colLines: number[] = []; + let x = 0; + for (let i = 0; i < colSizes.length - 1; i++) { x += colSizes[i] + gapSize; colLines.push(x - gapSize / 2); } + const rowLines: number[] = []; + let y = 0; + for (let i = 0; i < rowSizes.length - 1; i++) { y += rowSizes[i] + gapSize; rowLines.push(y - gapSize / 2); } + setGridLines({ colLines, rowLines }); + }; + const observer = new ResizeObserver(measure); + observer.observe(gridEl); + measure(); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [grid.colWidths.join(","), grid.rowHeights?.join(","), grid.gap, grid.cols, grid.rows]); + + const handleInternalColDrag = useCallback((e: React.MouseEvent, lineIdx: number) => { + e.preventDefault(); e.stopPropagation(); + isDraggingRef.current = true; + const startX = e.clientX; + const gridEl = gridRef.current; + if (!gridEl) return; + const gridWidth = gridEl.offsetWidth; + if (gridWidth === 0) return; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const startFrs = (currentGrid.colWidths || []).map(parseFr); + const totalFr = startFrs.reduce((a, b) => a + b, 0); + const onMove = (me: MouseEvent) => { + const delta = me.clientX - startX; + const frDelta = (delta / gridWidth) * totalFr; + const newFrs = [...startFrs]; + newFrs[lineIdx] = Math.max(GRID_LIMITS.minFr, startFrs[lineIdx] + frDelta); + newFrs[lineIdx + 1] = Math.max(GRID_LIMITS.minFr, startFrs[lineIdx + 1] - frDelta); + onGridChange({ ...currentGrid, colWidths: newFrs.map((fr) => `${Math.round(fr * 10) / 10}fr`) }); + }; + const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + }, [onGridChange]); + + const handleInternalRowDrag = useCallback((e: React.MouseEvent, lineIdx: number) => { + e.preventDefault(); e.stopPropagation(); + isDraggingRef.current = true; + const startY = e.clientY; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const heights = (currentGrid.rowHeights || Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(parsePx); + if (lineIdx < 0 || lineIdx + 1 >= heights.length) return; + const onMove = (me: MouseEvent) => { + const delta = me.clientY - startY; + const newH = [...heights]; + newH[lineIdx] = Math.max(MIN_ROW_HEIGHT, heights[lineIdx] + delta); + newH[lineIdx + 1] = Math.max(MIN_ROW_HEIGHT, heights[lineIdx + 1] - delta); + onGridChange({ ...currentGrid, rowHeights: newH.map((h) => `${Math.round(h)}px`) }); + }; + const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + }, [onGridChange]); + + // 경계선 가시성 + const isColLineVisible = (lineIdx: number): boolean => { + const leftCol = lineIdx + 1, rightCol = lineIdx + 2; + for (let r = 1; r <= grid.rows; r++) { + const left = occupationMap[`${r}-${leftCol}`], right = occupationMap[`${r}-${rightCol}`]; + if (left !== right || (!left && !right)) return true; + } + return false; + }; + const isRowLineVisible = (lineIdx: number): boolean => { + const topRow = lineIdx + 1, bottomRow = lineIdx + 2; + for (let c = 1; c <= grid.cols; c++) { + const top = occupationMap[`${topRow}-${c}`], bottom = occupationMap[`${bottomRow}-${c}`]; + if (top !== bottom || (!top && !bottom)) return true; + } + return false; + }; + + const selectedCell = selectedCellId ? grid.cells.find((c) => c.id === selectedCellId) : null; + useEffect(() => { + if (selectedCellId && !grid.cells.find((c) => c.id === selectedCellId)) setSelectedCellId(null); + }, [grid.cells, selectedCellId]); + + const mergeValid = validateMerge(); + + const gridPositions: { row: number; col: number }[] = []; + for (let r = 1; r <= grid.rows; r++) { + for (let c = 1; c <= grid.cols; c++) { + gridPositions.push({ row: r, col: c }); + } + } + const rowHeightsArr = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`); + + // 바 그룹핑 + type BarGroup = { startIdx: number; count: number; totalFr: number }; + const colGroups: BarGroup[] = (() => { + const groups: BarGroup[] = []; + if (grid.colWidths.length === 0) return groups; + let cur: BarGroup = { startIdx: 0, count: 1, totalFr: parseFr(grid.colWidths[0]) }; + for (let i = 0; i < grid.cols - 1; i++) { + if (isColLineVisible(i)) { groups.push(cur); cur = { startIdx: i + 1, count: 1, totalFr: parseFr(grid.colWidths[i + 1]) }; } + else { cur.count++; cur.totalFr += parseFr(grid.colWidths[i + 1]); } + } + groups.push(cur); + return groups; + })(); + + const rowGroups: BarGroup[] = (() => { + const groups: BarGroup[] = []; + if (rowHeightsArr.length === 0) return groups; + let cur: BarGroup = { startIdx: 0, count: 1, totalFr: parsePx(rowHeightsArr[0]) }; + for (let i = 0; i < grid.rows - 1; i++) { + if (isRowLineVisible(i)) { groups.push(cur); cur = { startIdx: i + 1, count: 1, totalFr: parsePx(rowHeightsArr[i + 1]) }; } + else { cur.count++; cur.totalFr += parsePx(rowHeightsArr[i + 1]); } + } + groups.push(cur); + return groups; + })(); + + return ( +
+ {/* 카드 배치 */} +
+
+ 열 수 + + {cfg.gridColumns || 3} + +
+
+ 카드 간격 + + {cfg.cardGap || 8}px + +
+
+ + {/* 인라인 툴바 */} +
+ +
+ 간격 + + {grid.gap}px + +
+ +
+ + +
+ + {/* 병합 모드 안내 */} + {mergeMode && ( +
+ + {mergeCellKeys.size > 0 ? `${mergeCellKeys.size}칸 선택됨${mergeValid ? " (병합 가능)" : " (직사각형으로 선택)"}` : "빈 셀을 클릭하여 선택"} + + + +
+ )} + + {/* 열 너비 드래그 바 */} +
+
+
+ {colGroups.map((group, gi) => ( + +
+ {group.count > 1 ? `${Math.round(group.totalFr * 10) / 10}fr` : grid.colWidths[group.startIdx]} +
+ {gi < colGroups.length - 1 && ( +
handleColDragStart(e, group.startIdx + group.count - 1)} /> + )} + + ))} +
+
+ + {/* 행 높이 바 + 그리드 */} +
+
+ {rowGroups.map((group, gi) => ( + +
{Math.round(group.totalFr)}
+ {gi < rowGroups.length - 1 && ( +
handleRowDragStart(e, group.startIdx + group.count - 1)} /> + )} + + ))} +
+
+
0 ? grid.colWidths.map((w) => `minmax(30px, ${w})`).join(" ") : "1fr", + gridTemplateRows: rowHeightsArr.join(" "), + gap: `${Number(grid.gap) || 0}px`, + }} + > + {gridPositions.map(({ row, col }) => { + const cellAtOrigin = getCellByOrigin(row, col); + const occupiedBy = occupationMap[`${row}-${col}`]; + const isMergeSelected = mergeCellKeys.has(`${row}-${col}`); + if (occupiedBy && !cellAtOrigin) return null; + if (cellAtOrigin) { + const isSelected = selectedCellId === cellAtOrigin.id; + return ( +
handleCellClick(cellAtOrigin)} + > +
+ {cellAtOrigin.columnName || cellAtOrigin.label || "미지정"} + {V2_CELL_TYPE_LABELS[cellAtOrigin.type]?.label || cellAtOrigin.type} +
+
+ ); + } + return ( +
handleEmptyCellClick(row, col)} + > + {isMergeSelected ? : } +
+ ); + })} +
+ {/* 내부 경계 드래그 오버레이 */} +
+ {gridLines.colLines.map((x, i) => { + if (!isColLineVisible(i)) return null; + return
handleInternalColDrag(e, i)} />; + })} + {gridLines.rowLines.map((y, i) => { + if (!isRowLineVisible(i)) return null; + return
handleInternalRowDrag(e, i)} />; + })} +
+
+
+ +

+ {grid.cols}열 x {grid.rows}행 (최대 {GRID_LIMITS.cols.max}x{GRID_LIMITS.rows.max}) +

+ + {/* 선택된 셀 설정 패널 */} + {selectedCell && !mergeMode && ( + updateCell(selectedCell.id, partial)} + onRemove={() => removeCell(selectedCell.id)} + /> + )} +
+ ); +} + +// ===== 셀 상세 에디터 (타입별 인라인) ===== + +function CellDetailEditor({ + cell, + allColumnOptions, + columns, + selectedColumns, + tables, + onUpdate, + onRemove, +}: { + cell: CardCellDefinitionV2; + allColumnOptions: { value: string; label: string }[]; + columns: ColumnInfo[]; + selectedColumns: string[]; + tables: TableInfo[]; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + return ( +
+
+ + 셀 (행{cell.row} 열{cell.col} + {((Number(cell.colSpan) || 1) > 1 || (Number(cell.rowSpan) || 1) > 1) && `, ${Number(cell.colSpan) || 1}x${Number(cell.rowSpan) || 1}`}) + + +
+ + {/* 컬럼 + 타입 */} +
+ + +
+ + {/* 라벨 + 위치 */} +
+ onUpdate({ label: e.target.value })} placeholder="라벨 (선택)" className="h-7 flex-1 text-[10px]" /> + +
+ + {/* 크기 + 정렬 */} +
+ + + +
+ + {/* 타입별 상세 설정 */} + {cell.type === "status-badge" && } + {cell.type === "timeline" && } + {cell.type === "action-buttons" && } + {cell.type === "footer-status" && } + {cell.type === "field" && } + {cell.type === "number-input" && ( +
+ 숫자 입력 설정 +
+ onUpdate({ inputUnit: e.target.value })} placeholder="단위 (EA)" className="h-7 flex-1 text-[10px]" /> + +
+
+ )} + {cell.type === "cart-button" && ( +
+ 담기 버튼 설정 +
+ onUpdate({ cartLabel: e.target.value })} placeholder="담기" className="h-7 flex-1 text-[10px]" /> + onUpdate({ cartCancelLabel: e.target.value })} placeholder="취소" className="h-7 flex-1 text-[10px]" /> +
+
+ )} +
+ ); +} + +// ===== 상태 배지 매핑 에디터 ===== + +function StatusMappingEditor({ + cell, + onUpdate, +}: { + cell: CardCellDefinitionV2; + onUpdate: (partial: Partial) => void; +}) { + const statusMap = cell.statusMap || []; + + const addMapping = () => { + onUpdate({ statusMap: [...statusMap, { value: "", label: "", color: "#6b7280" }] }); + }; + + const updateMapping = (index: number, partial: Partial<{ value: string; label: string; color: string }>) => { + onUpdate({ statusMap: statusMap.map((m, i) => (i === index ? { ...m, ...partial } : m)) }); + }; + + const removeMapping = (index: number) => { + onUpdate({ statusMap: statusMap.filter((_, i) => i !== index) }); + }; + + return ( +
+
+ 상태값-색상 매핑 + +
+ {statusMap.map((m, i) => ( +
+ updateMapping(i, { value: e.target.value })} placeholder="값" className="h-6 flex-1 text-[10px]" /> + updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> + updateMapping(i, { color: e.target.value })} className="h-6 w-8 cursor-pointer rounded border" /> + +
+ ))} +
+ ); +} + +// ===== 타임라인 설정 ===== + +function TimelineConfigEditor({ + cell, + allColumnOptions, + tables, + onUpdate, +}: { + cell: CardCellDefinitionV2; + allColumnOptions: { value: string; label: string }[]; + tables: TableInfo[]; + onUpdate: (partial: Partial) => void; +}) { + const src = cell.timelineSource || { processTable: "", foreignKey: "", seqColumn: "", nameColumn: "", statusColumn: "" }; + const [processColumns, setProcessColumns] = useState([]); + const [tableOpen, setTableOpen] = useState(false); + + useEffect(() => { + if (!src.processTable) { setProcessColumns([]); return; } + fetchTableColumns(src.processTable) + .then(setProcessColumns) + .catch(() => setProcessColumns([])); + }, [src.processTable]); + + const updateSource = (partial: Partial) => { + onUpdate({ timelineSource: { ...src, ...partial } }); + }; + + const colOptions = processColumns.map((c) => ({ value: c.name, label: c.name })); + + return ( +
+ 공정 데이터 소스 + + {/* 공정 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((t) => ( + { + updateSource({ processTable: t.tableName, foreignKey: "", seqColumn: "", nameColumn: "", statusColumn: "" }); + setTableOpen(false); + }} + className="text-[10px]" + > + + {t.displayName || t.tableName} + + ))} + + + + + +
+ + {/* 컬럼 매핑 (공정 테이블 선택 후) */} + {src.processTable && processColumns.length > 0 && ( +
+ +
+
+ 연결 FK + +
+
+ 순서 + +
+
+ 공정명 + +
+
+ 상태 + +
+
+
+ )} + + {/* 상태 값 매핑 */} + {src.processTable && src.statusColumn && ( +
+ +

DB에 저장된 실제 상태 값을 입력하세요. 비워두면 기본값 사용.

+
+ {([ + { key: "waiting", label: "대기", def: "waiting" }, + { key: "accepted", label: "접수", def: "accepted" }, + { key: "inProgress", label: "진행중", def: "in_progress" }, + { key: "completed", label: "완료", def: "completed" }, + ] as const).map((item) => ( +
+ {item.label} + updateSource({ statusValues: { ...src.statusValues, [item.key]: e.target.value } })} + className="h-6 text-[9px]" + /> +
+ ))} +
+
+ )} + + {/* 구분선 */} +
+ 표시 옵션 +
+ +
+ 최대 표시 수 + onUpdate({ visibleCount: parseInt(e.target.value) || 5 })} + className="h-7 w-16 text-[10px]" + /> + +
+
+ + onUpdate({ currentHighlight: v })} + /> +
+
+ + onUpdate({ showDetailModal: v })} + /> + 전체 공정 모달 +
+
+ ); +} + +// ===== 액션 버튼 에디터 ===== + +function ActionButtonsEditor({ + cell, + allColumnOptions, + onUpdate, +}: { + cell: CardCellDefinitionV2; + allColumnOptions: { value: string; label: string }[]; + onUpdate: (partial: Partial) => void; +}) { + const rules = cell.actionRules || []; + const [expandedBtn, setExpandedBtn] = useState(null); + + const cloneRules = () => rules.map((r) => ({ ...r, buttons: r.buttons.map((b) => ({ ...b })) })); + + const addRule = () => { + onUpdate({ + actionRules: [ + ...rules, + { whenStatus: "", buttons: [{ label: "", variant: "default" as ButtonVariant, taskPreset: "" }] }, + ], + }); + }; + + const updateRule = (index: number, partial: Partial<{ whenStatus: string }>) => { + onUpdate({ actionRules: rules.map((r, i) => (i === index ? { ...r, ...partial } : r)) }); + }; + + const removeRule = (index: number) => { + onUpdate({ actionRules: rules.filter((_, i) => i !== index) }); + setExpandedBtn(null); + }; + + const addButton = (ruleIndex: number) => { + const nr = cloneRules(); + nr[ruleIndex].buttons.push({ label: "", variant: "default" as ButtonVariant, taskPreset: "" }); + onUpdate({ actionRules: nr }); + }; + + const updateButton = (ruleIndex: number, btnIndex: number, partial: Record) => { + const nr = cloneRules(); + nr[ruleIndex].buttons[btnIndex] = { ...nr[ruleIndex].buttons[btnIndex], ...partial }; + onUpdate({ actionRules: nr }); + }; + + const removeButton = (ruleIndex: number, btnIndex: number) => { + const nr = cloneRules(); + nr[ruleIndex].buttons = nr[ruleIndex].buttons.filter((_, i) => i !== btnIndex); + onUpdate({ actionRules: nr }); + setExpandedBtn(null); + }; + + // updates 배열 관리 + const addUpdate = (ri: number, bi: number) => { + const nr = cloneRules(); + const btn = nr[ri].buttons[bi]; + btn.updates = [...(btn.updates || []), { column: "", value: "", valueType: "static" as const }]; + onUpdate({ actionRules: nr }); + }; + + const updateUpdateEntry = (ri: number, bi: number, ui: number, partial: Partial) => { + const nr = cloneRules(); + const btn = nr[ri].buttons[bi]; + btn.updates = (btn.updates || []).map((u, i) => (i === ui ? { ...u, ...partial } : u)); + onUpdate({ actionRules: nr }); + }; + + const removeUpdate = (ri: number, bi: number, ui: number) => { + const nr = cloneRules(); + const btn = nr[ri].buttons[bi]; + btn.updates = (btn.updates || []).filter((_, i) => i !== ui); + onUpdate({ actionRules: nr }); + }; + + return ( +
+
+ 상태별 버튼 규칙 + +
+ {rules.map((rule, ri) => ( +
+
+ 조건: + updateRule(ri, { whenStatus: e.target.value })} placeholder="상태값 (예: waiting)" className="h-6 flex-1 text-[10px]" /> + +
+ + {rule.buttons.map((btn, bi) => { + const btnKey = `${ri}-${bi}`; + const isExpanded = expandedBtn === btnKey; + + return ( +
+
+ updateButton(ri, bi, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> + + + +
+ + {isExpanded && ( +
+ 클릭 시 동작 + +
+ 대상 테이블 + updateButton(ri, bi, { targetTable: e.target.value })} + placeholder="work_order_process" + className="h-6 flex-1 text-[10px]" + /> +
+ +
+ 확인 메시지 + updateButton(ri, bi, { confirmMessage: e.target.value })} + placeholder="접수하시겠습니까?" + className="h-6 flex-1 text-[10px]" + /> +
+ +
+ 변경할 컬럼 + +
+ + {(btn.updates || []).map((u, ui) => ( +
+ + + {(u.valueType === "static" || u.valueType === "columnRef") && ( + updateUpdateEntry(ri, bi, ui, { value: e.target.value })} + placeholder={u.valueType === "static" ? "값" : "컬럼명"} + className="h-6 flex-1 text-[10px]" + /> + )} + +
+ ))} + + {(!btn.updates || btn.updates.length === 0) && ( +

변경 항목을 추가하면 버튼 클릭 시 DB가 변경됩니다.

+ )} +
+ )} +
+ ); + })} + + +
+ ))} +
+ ); +} + +// ===== 하단 상태 에디터 ===== + +function FooterStatusEditor({ + cell, + allColumnOptions, + onUpdate, +}: { + cell: CardCellDefinitionV2; + allColumnOptions: { value: string; label: string }[]; + onUpdate: (partial: Partial) => void; +}) { + const footerStatusMap = cell.footerStatusMap || []; + + const addMapping = () => { + onUpdate({ footerStatusMap: [...footerStatusMap, { value: "", label: "", color: "#6b7280" }] }); + }; + + const updateMapping = (index: number, partial: Partial<{ value: string; label: string; color: string }>) => { + onUpdate({ footerStatusMap: footerStatusMap.map((m, i) => (i === index ? { ...m, ...partial } : m)) }); + }; + + const removeMapping = (index: number) => { + onUpdate({ footerStatusMap: footerStatusMap.filter((_, i) => i !== index) }); + }; + + return ( +
+ 하단 상태 설정 +
+ onUpdate({ footerLabel: e.target.value })} + placeholder="라벨 (예: 검사의뢰)" + className="h-7 flex-1 text-[10px]" + /> +
+
+ +
+
+ + onUpdate({ showTopBorder: v })} + /> +
+
+ 상태값-색상 매핑 + +
+ {footerStatusMap.map((m, i) => ( +
+ updateMapping(i, { value: e.target.value })} placeholder="값" className="h-6 flex-1 text-[10px]" /> + updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> + updateMapping(i, { color: e.target.value })} className="h-6 w-8 cursor-pointer rounded border" /> + +
+ ))} +
+ ); +} + +// ===== 필드 설정 에디터 ===== + +function FieldConfigEditor({ + cell, + allColumnOptions, + onUpdate, +}: { + cell: CardCellDefinitionV2; + allColumnOptions: { value: string; label: string }[]; + onUpdate: (partial: Partial) => void; +}) { + const valueType = cell.valueType || "column"; + + return ( +
+ 필드 설정 +
+ + onUpdate({ unit: e.target.value })} placeholder="단위" className="h-7 w-16 text-[10px]" /> +
+ {valueType === "formula" && ( +
+ + + + {cell.formulaRightType === "column" && ( + + )} +
+ )} +
+ ); +} + +// ===== 탭 3: 동작 ===== + +function TabActions({ + cfg, + onUpdate, +}: { + cfg: PopCardListV2Config; + onUpdate: (partial: Partial) => void; +}) { + const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; + const clickAction = cfg.cardClickAction || "none"; + + return ( +
+ {/* 카드 선택 시 */} +
+ +
+ {(["none", "publish", "navigate"] as V2CardClickAction[]).map((action) => ( + + ))} +
+
+ + {/* 스크롤 방향 */} +
+ +
+ {(["vertical", "horizontal"] as const).map((dir) => ( + + ))} +
+
+ + {/* 오버플로우 */} +
+ +
+ {(["loadMore", "pagination"] as const).map((mode) => ( + + ))} +
+
+
+ + onUpdate({ overflow: { ...overflow, visibleCount: Number(e.target.value) || 6 } })} + className="mt-0.5 h-7 text-[10px]" + /> +
+ {overflow.mode === "loadMore" && ( +
+ + onUpdate({ overflow: { ...overflow, loadMoreCount: Number(e.target.value) || 6 } })} + className="mt-0.5 h-7 text-[10px]" + /> +
+ )} + {overflow.mode === "pagination" && ( +
+ + onUpdate({ overflow: { ...overflow, pageSize: Number(e.target.value) || 6 } })} + className="mt-0.5 h-7 text-[10px]" + /> +
+ )} +
+
+ + {/* 장바구니 */} +
+ + { + if (checked) { + onUpdate({ cartAction: { saveMode: "cart", label: "담기", cancelLabel: "취소" } }); + } else { + onUpdate({ cartAction: undefined }); + } + }} + /> +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx new file mode 100644 index 00000000..8ebaf913 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx @@ -0,0 +1,104 @@ +"use client"; + +/** + * pop-card-list-v2 디자인 모드 미리보기 + * + * 디자이너 캔버스에서 표시되는 미리보기. + * CSS Grid 기반 셀 배치를 시각적으로 보여준다. + */ + +import React from "react"; +import { LayoutGrid, Package } from "lucide-react"; +import type { PopCardListV2Config } from "../types"; +import { CARD_SCROLL_DIRECTION_LABELS, CARD_SIZE_LABELS } from "../types"; + +interface PopCardListV2PreviewProps { + config?: PopCardListV2Config; +} + +export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewProps) { + const scrollDirection = config?.scrollDirection || "vertical"; + const cardSize = config?.cardSize || "medium"; + const dataSource = config?.dataSource; + const cardGrid = config?.cardGrid; + const hasTable = !!dataSource?.tableName; + const cellCount = cardGrid?.cells?.length || 0; + + return ( +
+
+
+ + 카드 목록 V2 +
+
+ + {CARD_SCROLL_DIRECTION_LABELS[scrollDirection]} + + + {CARD_SIZE_LABELS[cardSize]} + +
+
+ + {!hasTable ? ( +
+
+ +

데이터 소스를 설정하세요

+
+
+ ) : ( + <> +
+ + {dataSource!.tableName} + + + ({cellCount}셀) + +
+ +
+ {[0, 1].map((cardIdx) => ( +
+ {cellCount === 0 ? ( +
+ 셀을 추가하세요 +
+ ) : ( +
w || "1fr").join(" ") + : `repeat(${cardGrid!.cols || 1}, 1fr)`, + gridTemplateRows: `repeat(${cardGrid!.rows || 1}, minmax(16px, auto))`, + gap: "2px", + }} + > + {cardGrid!.cells.map((cell) => ( +
+ + {cell.type} + {cell.columnName ? `: ${cell.columnName}` : ""} + +
+ ))} +
+ )} +
+ ))} +
+ + )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx new file mode 100644 index 00000000..259a6ac8 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -0,0 +1,732 @@ +"use client"; + +/** + * pop-card-list-v2 셀 타입별 렌더러 + * + * 각 셀 타입은 독립 함수로 구현되어 CardV2Grid에서 type별 dispatch로 호출된다. + * 기존 pop-card-list의 카드 내부 렌더링과 pop-string-list의 CardModeView 패턴을 결합. + */ + +import React, { useMemo, useState } from "react"; +import { + ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, + Loader2, Play, CheckCircle2, CircleDot, Clock, + type LucideIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep } from "../types"; +import { DEFAULT_CARD_IMAGE } from "../types"; +import type { ButtonVariant } from "../pop-button"; + +type RowData = Record; + +// ===== 공통 유틸 ===== + +const LUCIDE_ICON_MAP: Record = { + ShoppingCart, Package, Truck, Box, Archive, Heart, Star, +}; + +function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { + if (!name) return ; + const IconComp = LUCIDE_ICON_MAP[name]; + if (!IconComp) return ; + return ; +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined) return "-"; + if (typeof value === "number") return value.toLocaleString(); + if (typeof value === "boolean") return value ? "예" : "아니오"; + if (value instanceof Date) return value.toLocaleDateString(); + if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { + const date = new Date(value); + if (!isNaN(date.getTime())) { + return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + } + return String(value); +} + +const FONT_SIZE_MAP = { xs: "10px", sm: "11px", md: "12px", lg: "14px" } as const; +const FONT_WEIGHT_MAP = { normal: 400, medium: 500, bold: 700 } as const; + +// ===== 셀 렌더러 Props ===== + +export interface CellRendererProps { + cell: CardCellDefinitionV2; + row: RowData; + inputValue?: number; + isCarted?: boolean; + isButtonLoading?: boolean; + onInputClick?: (e: React.MouseEvent) => void; + onCartAdd?: () => void; + onCartCancel?: () => void; + onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void; + onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record) => void; + packageEntries?: PackageEntry[]; + inputUnit?: string; +} + +// ===== 메인 디스패치 ===== + +export function renderCellV2(props: CellRendererProps): React.ReactNode { + switch (props.cell.type) { + case "text": + return ; + case "field": + return ; + case "image": + return ; + case "badge": + return ; + case "button": + return ; + case "number-input": + return ; + case "cart-button": + return ; + case "package-summary": + return ; + case "status-badge": + return ; + case "timeline": + return ; + case "action-buttons": + return ; + case "footer-status": + return ; + default: + return 알 수 없는 셀 타입; + } +} + +// ===== 1. text ===== + +function TextCell({ cell, row }: CellRendererProps) { + const value = cell.columnName ? row[cell.columnName] : ""; + const fs = FONT_SIZE_MAP[cell.fontSize || "md"]; + const fw = FONT_WEIGHT_MAP[cell.fontWeight || "normal"]; + + return ( + + {formatValue(value)} + + ); +} + +// ===== 2. field (라벨+값) ===== + +function FieldCell({ cell, row, inputValue }: CellRendererProps) { + const valueType = cell.valueType || "column"; + const fs = FONT_SIZE_MAP[cell.fontSize || "md"]; + + const displayValue = useMemo(() => { + if (valueType !== "formula") { + const raw = cell.columnName ? row[cell.columnName] : undefined; + const formatted = formatValue(raw); + return cell.unit ? `${formatted} ${cell.unit}` : formatted; + } + + if (cell.formulaLeft && cell.formulaOperator) { + const rightVal = + (cell.formulaRightType || "input") === "input" + ? (inputValue ?? 0) + : Number(row[cell.formulaRight || ""] ?? 0); + const leftVal = Number(row[cell.formulaLeft] ?? 0); + + let result: number | null = null; + switch (cell.formulaOperator) { + case "+": result = leftVal + rightVal; break; + case "-": result = leftVal - rightVal; break; + case "*": result = leftVal * rightVal; break; + case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break; + } + + if (result !== null && isFinite(result)) { + const formatted = (Math.round(result * 100) / 100).toLocaleString(); + return cell.unit ? `${formatted} ${cell.unit}` : formatted; + } + return "-"; + } + return "-"; + }, [valueType, cell, row, inputValue]); + + const isFormula = valueType === "formula"; + const isLabelLeft = cell.labelPosition === "left"; + + return ( +
+ {cell.label && ( + + {cell.label}{isLabelLeft ? ":" : ""} + + )} + + {displayValue} + +
+ ); +} + +// ===== 3. image ===== + +function ImageCell({ cell, row }: CellRendererProps) { + const value = cell.columnName ? row[cell.columnName] : ""; + const imageUrl = value ? String(value) : (cell.defaultImage || DEFAULT_CARD_IMAGE); + + return ( +
+ {cell.label { + const target = e.target as HTMLImageElement; + if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE; + }} + /> +
+ ); +} + +// ===== 4. badge ===== + +function BadgeCell({ cell, row }: CellRendererProps) { + const value = cell.columnName ? row[cell.columnName] : ""; + return ( + + {formatValue(value)} + + ); +} + +// ===== 5. button ===== + +function ButtonCell({ cell, row, isButtonLoading, onButtonClick }: CellRendererProps) { + return ( + + ); +} + +// ===== 6. number-input ===== + +function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererProps) { + const unit = cell.inputUnit || "EA"; + return ( + + ); +} + +// ===== 7. cart-button ===== + +function CartButtonCell({ cell, row, isCarted, onCartAdd, onCartCancel }: CellRendererProps) { + const iconSize = 18; + const label = cell.cartLabel || "담기"; + const cancelLabel = cell.cartCancelLabel || "취소"; + + if (isCarted) { + return ( + + ); + } + + return ( + + ); +} + +// ===== 8. package-summary ===== + +function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) { + if (!packageEntries || packageEntries.length === 0) return null; + + return ( +
+ {packageEntries.map((entry, idx) => ( +
+
+ + 포장완료 + + + + {entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit} + +
+ + = {entry.totalQuantity.toLocaleString()}{inputUnit || "EA"} + +
+ ))} +
+ ); +} + +// ===== 9. status-badge ===== + +const STATUS_COLORS: Record = { + waiting: { bg: "#94a3b820", text: "#64748b" }, + accepted: { bg: "#3b82f620", text: "#2563eb" }, + in_progress: { bg: "#f59e0b20", text: "#d97706" }, + completed: { bg: "#10b98120", text: "#059669" }, +}; + +function StatusBadgeCell({ cell, row }: CellRendererProps) { + const value = cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : ""); + const strValue = String(value || ""); + const mapped = cell.statusMap?.find((m) => m.value === strValue); + + // 접수가능 자동 판별: work_order_process 기반 + // 직전 공정이 completed이고 현재 공정이 waiting이면 "접수가능" + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + const isAcceptable = useMemo(() => { + if (!processFlow || strValue !== "waiting") return false; + const currentIdx = processFlow.findIndex((s) => s.isCurrent); + if (currentIdx < 0) return false; + if (currentIdx === 0) return true; + return processFlow[currentIdx - 1]?.status === "completed"; + }, [processFlow, strValue]); + + if (isAcceptable) { + return ( + + + 접수가능 + + ); + } + + if (mapped) { + return ( + + {mapped.label} + + ); + } + + const defaultColors = STATUS_COLORS[strValue]; + if (defaultColors) { + const labelMap: Record = { + waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료", + }; + return ( + + {labelMap[strValue] || strValue} + + ); + } + + return ( + + {formatValue(value)} + + ); +} + +// ===== 10. timeline ===== + +const TIMELINE_STATUS_STYLES: Record = { + completed: { + chipBg: "#10b981", + chipText: "#ffffff", + icon: , + }, + in_progress: { + chipBg: "#f59e0b", + chipText: "#ffffff", + icon: , + }, + accepted: { + chipBg: "#3b82f6", + chipText: "#ffffff", + icon: , + }, + waiting: { + chipBg: "#e2e8f0", + chipText: "#64748b", + icon: , + }, +}; + +function TimelineCell({ cell, row }: CellRendererProps) { + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + + if (!processFlow || processFlow.length === 0) { + const fallback = cell.processColumn ? row[cell.processColumn] : ""; + return ( + + {formatValue(fallback)} + + ); + } + + const maxVisible = cell.visibleCount || 5; + const currentIdx = processFlow.findIndex((s) => s.isCurrent); + + type DisplayItem = + | { kind: "step"; step: TimelineProcessStep } + | { kind: "count"; count: number; side: "before" | "after" }; + + // 현재 공정 기준으로 앞뒤 배분하여 축약 + // 예: 10공정 중 4번이 현재, maxVisible=5 → [2]...[3공정]...[●4공정]...[5공정]...[5] + const displayItems = useMemo((): DisplayItem[] => { + if (processFlow.length <= maxVisible) { + return processFlow.map((s) => ({ kind: "step" as const, step: s })); + } + + const effectiveIdx = Math.max(0, currentIdx); + const priority = cell.timelinePriority || "before"; + // 숫자칩 2개를 제외한 나머지를 앞뒤로 배분 (priority에 따라 여분 슬롯 방향 결정) + const slotForSteps = maxVisible - 2; + const half = Math.floor(slotForSteps / 2); + const extra = slotForSteps - half - 1; // -1은 현재 공정 + const beforeSlots = priority === "before" ? Math.max(half, extra) : Math.min(half, extra); + const afterSlots = slotForSteps - beforeSlots - 1; + + let startIdx = effectiveIdx - beforeSlots; + let endIdx = effectiveIdx + afterSlots; + + // 경계 보정 + if (startIdx < 0) { + endIdx = Math.min(processFlow.length - 1, endIdx + Math.abs(startIdx)); + startIdx = 0; + } + if (endIdx >= processFlow.length) { + startIdx = Math.max(0, startIdx - (endIdx - processFlow.length + 1)); + endIdx = processFlow.length - 1; + } + + const items: DisplayItem[] = []; + const beforeCount = startIdx; + const afterCount = processFlow.length - 1 - endIdx; + + if (beforeCount > 0) { + items.push({ kind: "count", count: beforeCount, side: "before" }); + } + for (let i = startIdx; i <= endIdx; i++) { + items.push({ kind: "step", step: processFlow[i] }); + } + if (afterCount > 0) { + items.push({ kind: "count", count: afterCount, side: "after" }); + } + + return items; + }, [processFlow, maxVisible, currentIdx]); + + const [modalOpen, setModalOpen] = useState(false); + + const completedCount = processFlow.filter((s) => s.status === "completed").length; + const totalCount = processFlow.length; + + return ( + <> +
{ e.stopPropagation(); setModalOpen(true); } : undefined} + title={cell.showDetailModal !== false ? "클릭하여 전체 공정 현황 보기" : undefined} + > + {displayItems.map((item, idx) => { + const isLast = idx === displayItems.length - 1; + + if (item.kind === "count") { + return ( + +
+ {item.count} +
+ {!isLast &&
} + + ); + } + + const styles = TIMELINE_STATUS_STYLES[item.step.status] || TIMELINE_STATUS_STYLES.waiting; + + return ( + +
+ {styles.icon} + + {item.step.processName} + +
+ {!isLast &&
} + + ); + })} +
+ + + + + 전체 공정 현황 + + 총 {totalCount}개 공정 중 {completedCount}개 완료 + + + +
+ {processFlow.map((step, idx) => { + const styles = TIMELINE_STATUS_STYLES[step.status] || TIMELINE_STATUS_STYLES.waiting; + const statusLabel = + step.status === "completed" ? "완료" : + step.status === "in_progress" ? "진행중" : + step.status === "accepted" ? "접수" : + step.status === "hold" ? "보류" : "대기"; + + return ( +
+ {/* 세로 연결선 + 아이콘 */} +
+ {idx > 0 &&
} +
+ {styles.icon} +
+ {idx < processFlow.length - 1 &&
} +
+ + {/* 공정 정보 */} +
+
+ {step.seqNo} + + {step.processName} + + {step.isCurrent && ( + + )} +
+ + {statusLabel} + +
+
+ ); + })} +
+ + {/* 하단 진행률 바 */} +
+
+ 진행률 + {totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}% +
+
+
0 ? (completedCount / totalCount) * 100 : 0}%` }} + /> +
+
+ +
+ + ); +} + +// ===== 11. action-buttons ===== + +function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps) { + const statusValue = cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : ""); + const rules = cell.actionRules || []; + + // 접수가능 자동 판별 + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + const isAcceptable = useMemo(() => { + if (!processFlow || statusValue !== "waiting") return false; + const currentIdx = processFlow.findIndex((s) => s.isCurrent); + if (currentIdx < 0) return false; + if (currentIdx === 0) return true; + return processFlow[currentIdx - 1]?.status === "completed"; + }, [processFlow, statusValue]); + + const effectiveStatus = isAcceptable ? "acceptable" : statusValue; + const matchedRule = rules.find((r) => r.whenStatus === effectiveStatus) + || rules.find((r) => r.whenStatus === statusValue); + + // 매칭 규칙이 없을 때 기본 동작 + if (!matchedRule) { + if (isAcceptable) { + return ( +
+ +
+ ); + } + if (statusValue === "in_progress") { + return ( +
+ +
+ ); + } + return null; + } + + return ( +
+ {matchedRule.buttons.map((btn, idx) => ( + + ))} +
+ ); +} + +// ===== 12. footer-status ===== + +function FooterStatusCell({ cell, row }: CellRendererProps) { + const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : ""; + const strValue = String(value || ""); + const mapped = cell.footerStatusMap?.find((m) => m.value === strValue); + + if (!strValue && !cell.footerLabel) return null; + + return ( +
+ {cell.footerLabel && ( + {cell.footerLabel} + )} + {mapped ? ( + + {mapped.label} + + ) : strValue ? ( + + {strValue} + + ) : null} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx new file mode 100644 index 00000000..d3e80209 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx @@ -0,0 +1,60 @@ +"use client"; + +/** + * pop-card-list-v2 컴포넌트 레지스트리 등록 진입점 + * + * import 시 side-effect로 PopComponentRegistry에 자동 등록 + */ + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopCardListV2Component } from "./PopCardListV2Component"; +import { PopCardListV2ConfigPanel } from "./PopCardListV2Config"; +import { PopCardListV2PreviewComponent } from "./PopCardListV2Preview"; +import type { PopCardListV2Config } from "../types"; + +const defaultConfig: PopCardListV2Config = { + dataSource: { tableName: "" }, + cardGrid: { + rows: 1, + cols: 1, + colWidths: ["1fr"], + rowHeights: ["32px"], + gap: 4, + showCellBorder: true, + cells: [], + }, + gridColumns: 3, + cardGap: 8, + scrollDirection: "vertical", + overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 }, + cardClickAction: "none", +}; + +PopComponentRegistry.registerComponent({ + id: "pop-card-list-v2", + name: "카드 목록 V2", + description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)", + category: "display", + icon: "LayoutGrid", + component: PopCardListV2Component, + configPanel: PopCardListV2ConfigPanel, + preview: PopCardListV2PreviewComponent, + defaultProps: defaultConfig, + connectionMeta: { + sendable: [ + { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" }, + { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" }, + { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, + { key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, + { key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" }, + ], + receivable: [ + { key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, + { key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, + { key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" }, + { key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" }, + ], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts b/frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts new file mode 100644 index 00000000..e4bfed8f --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts @@ -0,0 +1,163 @@ +/** + * pop-card-list v1 -> v2 마이그레이션 함수 + * + * 기존 PopCardListConfig의 고정 레이아웃(헤더/이미지/필드/입력/담기/포장)을 + * CardGridConfigV2 셀 배열로 변환하여 PopCardListV2Config를 생성한다. + */ + +import type { + PopCardListConfig, + PopCardListV2Config, + CardCellDefinitionV2, +} from "../types"; + +export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Config { + const cells: CardCellDefinitionV2[] = []; + let nextRow = 1; + + // 1. 헤더 행 (코드 + 제목) + if (old.cardTemplate?.header?.codeField || old.cardTemplate?.header?.titleField) { + if (old.cardTemplate.header.codeField) { + cells.push({ + id: "h-code", + row: nextRow, + col: 1, + rowSpan: 1, + colSpan: 1, + type: "text", + columnName: old.cardTemplate.header.codeField, + fontSize: "sm", + textColor: "hsl(var(--muted-foreground))", + }); + } + if (old.cardTemplate.header.titleField) { + cells.push({ + id: "h-title", + row: nextRow, + col: 2, + rowSpan: 1, + colSpan: old.cardTemplate.header.codeField ? 2 : 3, + type: "text", + columnName: old.cardTemplate.header.titleField, + fontSize: "md", + fontWeight: "bold", + }); + } + nextRow++; + } + + // 2. 이미지 (왼쪽, 본문 높이만큼 rowSpan) + const bodyFieldCount = old.cardTemplate?.body?.fields?.length || 0; + const bodyRowSpan = Math.max(1, bodyFieldCount); + + if (old.cardTemplate?.image?.enabled) { + cells.push({ + id: "img", + row: nextRow, + col: 1, + rowSpan: bodyRowSpan, + colSpan: 1, + type: "image", + columnName: old.cardTemplate.image.imageColumn || "", + defaultImage: old.cardTemplate.image.defaultImage, + }); + } + + // 3. 본문 필드들 (이미지 오른쪽) + const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1; + const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3; + const hasRightActions = !!(old.inputField?.enabled || old.cartAction); + + (old.cardTemplate?.body?.fields || []).forEach((field, i) => { + cells.push({ + id: `f-${i}`, + row: nextRow + i, + col: fieldStartCol, + rowSpan: 1, + colSpan: hasRightActions ? fieldColSpan - 1 : fieldColSpan, + type: "field", + columnName: field.columnName, + label: field.label, + valueType: field.valueType, + formulaLeft: field.formulaLeft, + formulaOperator: field.formulaOperator as CardCellDefinitionV2["formulaOperator"], + formulaRight: field.formulaRight, + formulaRightType: field.formulaRightType as CardCellDefinitionV2["formulaRightType"], + unit: field.unit, + textColor: field.textColor, + }); + }); + + // 4. 수량 입력 + 담기 버튼 (오른쪽 열) + const rightCol = 3; + if (old.inputField?.enabled) { + cells.push({ + id: "input", + row: nextRow, + col: rightCol, + rowSpan: Math.ceil(bodyRowSpan / 2), + colSpan: 1, + type: "number-input", + inputUnit: old.inputField.unit, + limitColumn: old.inputField.limitColumn || old.inputField.maxColumn, + }); + } + if (old.cartAction) { + cells.push({ + id: "cart", + row: nextRow + Math.ceil(bodyRowSpan / 2), + col: rightCol, + rowSpan: Math.floor(bodyRowSpan / 2) || 1, + colSpan: 1, + type: "cart-button", + cartLabel: old.cartAction.label, + cartCancelLabel: old.cartAction.cancelLabel, + cartIconType: old.cartAction.iconType, + cartIconValue: old.cartAction.iconValue, + }); + } + + // 5. 포장 요약 (마지막 행, full-width) + if (old.packageConfig?.enabled) { + const summaryRow = nextRow + bodyRowSpan; + cells.push({ + id: "pkg", + row: summaryRow, + col: 1, + rowSpan: 1, + colSpan: 3, + type: "package-summary", + }); + } + + // 그리드 크기 계산 + const maxRow = cells.length > 0 ? Math.max(...cells.map((c) => c.row + c.rowSpan - 1)) : 1; + const maxCol = 3; + + return { + dataSource: old.dataSource, + cardGrid: { + rows: maxRow, + cols: maxCol, + colWidths: old.cardTemplate?.image?.enabled + ? ["1fr", "2fr", "1fr"] + : ["1fr", "2fr", "1fr"], + gap: 2, + showCellBorder: false, + cells, + }, + scrollDirection: old.scrollDirection, + cardSize: old.cardSize, + gridColumns: old.gridColumns, + gridRows: old.gridRows, + cardGap: 8, + overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 }, + cardClickAction: "none", + responsiveDisplay: old.responsiveDisplay, + inputField: old.inputField, + packageConfig: old.packageConfig, + cartAction: old.cartAction, + cartListMode: old.cartListMode, + saveMapping: old.saveMapping, + }; +} diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 16340c5d..1632821b 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -722,3 +722,177 @@ export interface PopCardListConfig { cartListMode?: CartListModeConfig; saveMapping?: CardListSaveMapping; } + +// ============================================= +// pop-card-list-v2 전용 타입 (슬롯 기반 카드) +// ============================================= + +import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "./pop-button"; + +export type CardCellType = + | "text" + | "field" + | "image" + | "badge" + | "button" + | "number-input" + | "cart-button" + | "package-summary" + | "status-badge" + | "timeline" + | "action-buttons" + | "footer-status"; + +// timeline 셀에서 사용하는 공정 단계 데이터 +export interface TimelineProcessStep { + seqNo: number; + processName: string; + status: string; + isCurrent: boolean; +} + +// timeline/status-badge/action-buttons가 참조하는 공정 테이블 설정 +export interface TimelineDataSource { + processTable: string; // 공정 데이터 테이블명 (예: work_order_process) + foreignKey: string; // 메인 테이블 id와 매칭되는 FK 컬럼 (예: wo_id) + seqColumn: string; // 순서 컬럼 (예: seq_no) + nameColumn: string; // 공정명 컬럼 (예: process_name) + statusColumn: string; // 상태 컬럼 (예: status) + statusValues?: { // 상태 값 매핑 (미설정 시 기본값 사용) + waiting?: string; // 대기 (기본: "waiting") + accepted?: string; // 접수 (기본: "accepted") + inProgress?: string; // 진행중 (기본: "in_progress") + completed?: string; // 완료 (기본: "completed") + }; +} + +export interface CardCellDefinitionV2 { + id: string; + row: number; + col: number; + rowSpan: number; + colSpan: number; + type: CardCellType; + + // 공통 + columnName?: string; + label?: string; + labelPosition?: "top" | "left"; + fontSize?: "xs" | "sm" | "md" | "lg"; + fontWeight?: "normal" | "medium" | "bold"; + textColor?: string; + align?: "left" | "center" | "right"; + verticalAlign?: "top" | "middle" | "bottom"; + + // field 타입 전용 (CardFieldBinding 흡수) + valueType?: "column" | "formula"; + formulaLeft?: string; + formulaOperator?: "+" | "-" | "*" | "/"; + formulaRight?: string; + formulaRightType?: "input" | "column"; + unit?: string; + + // image 타입 전용 + defaultImage?: string; + + // button 타입 전용 + buttonAction?: ButtonMainAction; + buttonVariant?: ButtonVariant; + buttonConfirm?: ConfirmConfig; + + // number-input 타입 전용 + inputUnit?: string; + limitColumn?: string; + autoInitMax?: boolean; + + // cart-button 타입 전용 + cartLabel?: string; + cartCancelLabel?: string; + cartIconType?: "lucide" | "emoji"; + cartIconValue?: string; + + // status-badge 타입 전용 (CARD-3에서 구현) + statusColumn?: string; + statusMap?: Array<{ value: string; label: string; color: string }>; + + // timeline 타입 전용: 공정 데이터 소스 설정 + timelineSource?: TimelineDataSource; + processColumn?: string; + processStatusColumn?: string; + currentHighlight?: boolean; + visibleCount?: number; + timelinePriority?: "before" | "after"; + showDetailModal?: boolean; + + // action-buttons 타입 전용 + actionRules?: Array<{ + whenStatus: string; + buttons: Array<{ + label: string; + variant: ButtonVariant; + taskPreset: string; + confirm?: ConfirmConfig; + targetTable?: string; + confirmMessage?: string; + allowMultiSelect?: boolean; + updates?: ActionButtonUpdate[]; + }>; + }>; + + // footer-status 타입 전용 + footerLabel?: string; + footerStatusColumn?: string; + footerStatusMap?: Array<{ value: string; label: string; color: string }>; + showTopBorder?: boolean; +} + +export interface ActionButtonUpdate { + column: string; + value?: string; + valueType: "static" | "currentUser" | "currentTime" | "columnRef"; +} + +export interface CardGridConfigV2 { + rows: number; + cols: number; + colWidths: string[]; + rowHeights?: string[]; + gap: number; + showCellBorder: boolean; + cells: CardCellDefinitionV2[]; +} + +// ----- V2 카드 선택 동작 ----- + +export type V2CardClickAction = "none" | "publish" | "navigate"; + +// ----- V2 오버플로우 설정 ----- + +export interface V2OverflowConfig { + mode: "loadMore" | "pagination"; + visibleCount: number; + loadMoreCount?: number; + pageSize?: number; +} + +// ----- pop-card-list-v2 전체 설정 ----- + +export interface PopCardListV2Config { + dataSource: CardListDataSource; + cardGrid: CardGridConfigV2; + selectedColumns?: string[]; + gridColumns?: number; + gridRows?: number; + scrollDirection?: CardScrollDirection; + /** @deprecated 열 수(gridColumns)로 카드 크기 결정. 하위 호환용 */ + cardSize?: CardSize; + cardGap?: number; + overflow?: V2OverflowConfig; + cardClickAction?: V2CardClickAction; + responsiveDisplay?: CardResponsiveConfig; + inputField?: CardInputFieldConfig; + packageConfig?: CardPackageConfig; + cartAction?: CardCartActionConfig; + cartListMode?: CartListModeConfig; + saveMapping?: CardListSaveMapping; +} From ed3707a681edf1b3971cad47da2964cd60cac5f9 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Mar 2026 17:33:25 +0900 Subject: [PATCH 11/16] =?UTF-8?q?refactor(pop):=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EB=9D=BC=EB=B2=A8=20=EB=B2=94=EC=9A=A9?= =?UTF-8?q?=ED=99=94=20+=20=EC=83=81=ED=83=9C=20=EA=B0=92=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EB=8F=99=EC=A0=81=20=EB=B0=B0=EC=97=B4=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=EC=9D=98=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=8A=B9=ED=99=94=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8("=EA=B3=B5=EC=A0=95")=EC=9D=84=20=EB=B2=94=EC=9A=A9?= =?UTF-8?q?=20=EB=9D=BC=EB=B2=A8("=ED=95=98=EC=9C=84=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0/=ED=91=9C=EC=8B=9C=EB=AA=85")=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=ED=95=98=EA=B3=A0,=20=EC=83=81=ED=83=9C=20=EA=B0=92?= =?UTF-8?q?=20=EB=A7=A4=ED=95=91=EC=9D=84=20=EA=B3=A0=EC=A0=95=204?= =?UTF-8?q?=ED=82=A4=20=EA=B0=9D=EC=B2=B4=EC=97=90=EC=84=9C=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EB=B0=B0=EC=97=B4(statusMappings)=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=ED=95=98=EC=97=AC=20=EC=9E=84=EC=9D=98=20?= =?UTF-8?q?=EA=B0=9C=EC=88=98=EC=9D=98=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=ED=95=9C=EB=8B=A4.=20[=EB=9D=BC=EB=B2=A8=20=EB=B2=94?= =?UTF-8?q?=EC=9A=A9=ED=99=94]=20-=20"=EA=B3=B5=EC=A0=95=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=86=8C=EC=8A=A4"=20=E2=86=92=20"?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=86=8C?= =?UTF-8?q?=EC=8A=A4"=20-=20"=EA=B3=B5=EC=A0=95=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94"=20=E2=86=92=20"=ED=95=98=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94"=20-=20"=EA=B3=B5=EC=A0=95=EB=AA=85"=20?= =?UTF-8?q?=E2=86=92=20"=ED=91=9C=EC=8B=9C=EB=AA=85"=20-=20"=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EA=B3=B5=EC=A0=95=20=EA=B0=95=EC=A1=B0"=20?= =?UTF-8?q?=E2=86=92=20"=ED=98=84=EC=9E=AC=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0"=20-=20"=EC=A0=84=EC=B2=B4=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EB=8B=AC"=20=E2=86=92=20"=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=AA=A9=EB=A1=9D=20=EB=AA=A8=EB=8B=AC"=20-=20cell?= =?UTF-8?q?-renderers=20=EB=82=B4=20"=EA=B3=B5=EC=A0=95"=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=84=EB=B6=80=20=EB=B2=94=EC=9A=A9=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20[=EC=83=81=ED=83=9C=20=EA=B0=92=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EB=8F=99=EC=A0=81=20=EB=B0=B0=EC=97=B4]?= =?UTF-8?q?=20-=20types.ts:=20statusValues(=EA=B3=A0=EC=A0=95=204=ED=82=A4?= =?UTF-8?q?)=20=E2=86=92=20statusMappings(StatusValueMapping[])=20=20=20Ti?= =?UTF-8?q?melineStatusSemantic("pending"|"active"|"done"),=20StatusValueM?= =?UTF-8?q?apping=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80=20=20=20Timeli?= =?UTF-8?q?neProcessStep=EC=97=90=20semantic=3F=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20PopCardListV2Config:=20StatusMappingsE?= =?UTF-8?q?ditor=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=20=20(=ED=96=89=20=EC=B6=94=EA=B0=80/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20+=20=EC=8B=9C=EB=A7=A8=ED=8B=B1=20Select=20+=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=A0=81=EC=9A=A9=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC)=20-=20PopCardListV2Component:=20resolveStatusMapping?= =?UTF-8?q?s()=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=ED=95=A8=EC=88=98=20=20=20injectProcessFl?= =?UTF-8?q?ow=20=EB=8F=99=EC=A0=81=20=EB=A7=B5=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=EB=A1=9C=20=EC=A0=84=ED=99=98=20-?= =?UTF-8?q?=20cell-renderers:=20TIMELINE=5FSTATUS=5FSTYLES=20=E2=86=92=20T?= =?UTF-8?q?IMELINE=5FSEMANTIC=5FSTYLES=20=20=20getTimelineStyle()=20+=20LE?= =?UTF-8?q?GACY=5FSTATUS=5FTO=5FSEMANTIC=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=20=20=20completedCount/statusLabel/isAccepta?= =?UTF-8?q?ble=20=EB=AA=A8=EB=91=90=20semantic=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PopCardListV2Component.tsx | 58 +++++--- .../pop-card-list-v2/PopCardListV2Config.tsx | 129 ++++++++++++++---- .../pop-card-list-v2/cell-renderers.tsx | 78 +++++------ frontend/lib/registry/pop-components/types.ts | 28 ++-- 4 files changed, 192 insertions(+), 101 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index e6d16fda..eacb0ca6 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -29,6 +29,7 @@ import type { TimelineProcessStep, TimelineDataSource, ActionButtonUpdate, + StatusValueMapping, } from "../types"; import { CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE } from "../types"; import { dataApi } from "@/lib/api/data"; @@ -63,6 +64,20 @@ function parseCartRow(dbRow: Record): Record { }; } +// 레거시 statusValues(고정 4키 객체) → statusMappings(동적 배열) 자동 변환 +function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] { + if (src.statusMappings && src.statusMappings.length > 0) return src.statusMappings; + + // 레거시 호환: 기존 statusValues 객체가 있으면 변환 + const sv = (src as Record).statusValues as Record | undefined; + return [ + { dbValue: sv?.waiting || "waiting", label: "대기", semantic: "pending" as const }, + { dbValue: sv?.accepted || "accepted", label: "접수", semantic: "active" as const }, + { dbValue: sv?.inProgress || "in_progress", label: "진행중", semantic: "active" as const }, + { dbValue: sv?.completed || "completed", label: "완료", semantic: "done" as const }, + ]; +} + interface PopCardListV2ComponentProps { config?: PopCardListV2Config; className?: string; @@ -309,7 +324,7 @@ export function PopCardListV2Component({ return undefined; }, [cardGrid?.cells]); - // 공정 데이터 조회 + __processFlow__ 가상 컬럼 주입 + // 하위 데이터 조회 + __processFlow__ 가상 컬럼 주입 const injectProcessFlow = useCallback(async ( fetchedRows: RowData[], src: TimelineDataSource, @@ -318,11 +333,15 @@ export function PopCardListV2Component({ const rowIds = fetchedRows.map((r) => String(r.id)).filter(Boolean); if (rowIds.length === 0) return fetchedRows; - const sv = src.statusValues || {}; - const waitingVal = sv.waiting || "waiting"; - const acceptedVal = sv.accepted || "accepted"; - const inProgressVal = sv.inProgress || "in_progress"; - const completedVal = sv.completed || "completed"; + // statusMappings 동적 배열 → dbValue-to-내부키 맵 구축 + // 레거시 statusValues 객체도 자동 변환 + const mappings = resolveStatusMappings(src); + const dbToInternal = new Map(); + const dbToSemantic = new Map(); + for (const m of mappings) { + dbToInternal.set(m.dbValue, m.dbValue); + dbToSemantic.set(m.dbValue, m.semantic); + } const processResult = await dataApi.getTableData(src.processTable, { page: 1, @@ -338,30 +357,31 @@ export function PopCardListV2Component({ if (!fkValue || !rowIds.includes(fkValue)) continue; if (!processMap.has(fkValue)) processMap.set(fkValue, []); - const rawStatus = String(p[src.statusColumn] || waitingVal); - let normalizedStatus = rawStatus; - if (rawStatus === waitingVal) normalizedStatus = "waiting"; - else if (rawStatus === acceptedVal) normalizedStatus = "accepted"; - else if (rawStatus === inProgressVal) normalizedStatus = "in_progress"; - else if (rawStatus === completedVal) normalizedStatus = "completed"; + const rawStatus = String(p[src.statusColumn] || ""); + const normalizedStatus = dbToInternal.get(rawStatus) || rawStatus; + const semantic = dbToSemantic.get(rawStatus) || "pending"; processMap.get(fkValue)!.push({ seqNo: parseInt(String(p[src.seqColumn] || "0"), 10), processName: String(p[src.nameColumn] || ""), status: normalizedStatus, - isCurrent: normalizedStatus === "in_progress" || normalizedStatus === "accepted", + semantic: semantic as "pending" | "active" | "done", + isCurrent: semantic === "active", }); } - // isCurrent 보정: in_progress가 없으면 첫 waiting을 current로 + // isCurrent 보정: active가 없으면 첫 pending을 current로 for (const [, steps] of processMap) { steps.sort((a, b) => a.seqNo - b.seqNo); - const hasInProgress = steps.some((s) => s.status === "in_progress"); - if (!hasInProgress) { - const firstWaiting = steps.find((s) => s.status === "waiting"); - if (firstWaiting) { + const hasActive = steps.some((s) => s.isCurrent); + if (!hasActive) { + const firstPending = steps.find((s) => { + const sem = dbToSemantic.get(s.status) || "pending"; + return sem === "pending"; + }); + if (firstPending) { steps.forEach((s) => { s.isCurrent = false; }); - firstWaiting.isCurrent = true; + firstPending.isCurrent = true; } } } diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx index a24d6402..6b37b569 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -47,6 +47,8 @@ import type { V2CardClickAction, ActionButtonUpdate, TimelineDataSource, + StatusValueMapping, + TimelineStatusSemantic, } from "../types"; import type { ButtonVariant } from "../pop-button"; import { @@ -1487,11 +1489,11 @@ function TimelineConfigEditor({ return (
- 공정 데이터 소스 + 하위 데이터 소스 - {/* 공정 테이블 선택 */} + {/* 하위 테이블 선택 */}
- +
- {/* 컬럼 매핑 (공정 테이블 선택 후) */} + {/* 컬럼 매핑 (하위 테이블 선택 후) */} {src.processTable && processColumns.length > 0 && (
@@ -1552,7 +1554,7 @@ function TimelineConfigEditor({
- 공정명 + 표시명 updateSource({ statusValues: { ...src.statusValues, [item.key]: e.target.value } })} - className="h-6 text-[9px]" - /> -
- ))} -
-
+ updateSource({ statusMappings: mappings })} + /> )} {/* 구분선 */} @@ -1630,7 +1614,7 @@ function TimelineConfigEditor({
- + onUpdate({ currentHighlight: v })} @@ -1642,12 +1626,97 @@ function TimelineConfigEditor({ checked={cell.showDetailModal !== false} onCheckedChange={(v) => onUpdate({ showDetailModal: v })} /> - 전체 공정 모달 + 전체 목록 모달
); } +// ===== 상태 값 매핑 에디터 (동적 배열) ===== + +const SEMANTIC_OPTIONS: { value: TimelineStatusSemantic; label: string }[] = [ + { value: "pending", label: "대기" }, + { value: "active", label: "진행" }, + { value: "done", label: "완료" }, +]; + +const DEFAULT_STATUS_MAPPINGS: StatusValueMapping[] = [ + { dbValue: "waiting", label: "대기", semantic: "pending" }, + { dbValue: "accepted", label: "접수", semantic: "active" }, + { dbValue: "in_progress", label: "진행중", semantic: "active" }, + { dbValue: "completed", label: "완료", semantic: "done" }, +]; + +function StatusMappingsEditor({ + mappings, + onChange, +}: { + mappings: StatusValueMapping[]; + onChange: (mappings: StatusValueMapping[]) => void; +}) { + const addMapping = () => { + onChange([...mappings, { dbValue: "", label: "", semantic: "pending" }]); + }; + + const updateMapping = (index: number, partial: Partial) => { + onChange(mappings.map((m, i) => (i === index ? { ...m, ...partial } : m))); + }; + + const removeMapping = (index: number) => { + onChange(mappings.filter((_, i) => i !== index)); + }; + + const applyDefaults = () => { + onChange([...DEFAULT_STATUS_MAPPINGS]); + }; + + return ( +
+
+ +
+ {mappings.length === 0 && ( + + )} + +
+
+

DB 값, 화면 라벨, 의미(대기/진행/완료)를 매핑합니다.

+ {mappings.map((m, i) => ( +
+ updateMapping(i, { dbValue: e.target.value })} + placeholder="DB 값" + className="h-6 flex-1 text-[10px]" + /> + updateMapping(i, { label: e.target.value })} + placeholder="라벨" + className="h-6 flex-1 text-[10px]" + /> + + +
+ ))} +
+ ); +} + // ===== 액션 버튼 에디터 ===== function ActionButtonsEditor({ diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index 259a6ac8..500af96e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -333,15 +333,17 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) { const strValue = String(value || ""); const mapped = cell.statusMap?.find((m) => m.value === strValue); - // 접수가능 자동 판별: work_order_process 기반 - // 직전 공정이 completed이고 현재 공정이 waiting이면 "접수가능" + // 접수가능 자동 판별: 하위 데이터 기반 + // 직전 항목이 done이고 현재 항목이 pending이면 "접수가능" const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; const isAcceptable = useMemo(() => { if (!processFlow || strValue !== "waiting") return false; const currentIdx = processFlow.findIndex((s) => s.isCurrent); if (currentIdx < 0) return false; if (currentIdx === 0) return true; - return processFlow[currentIdx - 1]?.status === "completed"; + const prevStep = processFlow[currentIdx - 1]; + const prevSem = prevStep?.semantic || LEGACY_STATUS_TO_SEMANTIC[prevStep?.status || ""] || "pending"; + return prevSem === "done"; }, [processFlow, strValue]); if (isAcceptable) { @@ -391,29 +393,25 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) { // ===== 10. timeline ===== -const TIMELINE_STATUS_STYLES: Record = { - completed: { - chipBg: "#10b981", - chipText: "#ffffff", - icon: , - }, - in_progress: { - chipBg: "#f59e0b", - chipText: "#ffffff", - icon: , - }, - accepted: { - chipBg: "#3b82f6", - chipText: "#ffffff", - icon: , - }, - waiting: { - chipBg: "#e2e8f0", - chipText: "#64748b", - icon: , - }, +type TimelineStyle = { chipBg: string; chipText: string; icon: React.ReactNode }; + +const TIMELINE_SEMANTIC_STYLES: Record = { + done: { chipBg: "#10b981", chipText: "#ffffff", icon: }, + active: { chipBg: "#3b82f6", chipText: "#ffffff", icon: }, + pending: { chipBg: "#e2e8f0", chipText: "#64748b", icon: }, }; +// 레거시 status 값 → semantic 매핑 (기존 데이터 호환) +const LEGACY_STATUS_TO_SEMANTIC: Record = { + completed: "done", in_progress: "active", accepted: "active", waiting: "pending", +}; + +function getTimelineStyle(step: TimelineProcessStep): TimelineStyle { + if (step.semantic) return TIMELINE_SEMANTIC_STYLES[step.semantic] || TIMELINE_SEMANTIC_STYLES.pending; + const fallback = LEGACY_STATUS_TO_SEMANTIC[step.status]; + return TIMELINE_SEMANTIC_STYLES[fallback || "pending"]; +} + function TimelineCell({ cell, row }: CellRendererProps) { const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; @@ -433,8 +431,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { | { kind: "step"; step: TimelineProcessStep } | { kind: "count"; count: number; side: "before" | "after" }; - // 현재 공정 기준으로 앞뒤 배분하여 축약 - // 예: 10공정 중 4번이 현재, maxVisible=5 → [2]...[3공정]...[●4공정]...[5공정]...[5] + // 현재 항목 기준으로 앞뒤 배분하여 축약 const displayItems = useMemo((): DisplayItem[] => { if (processFlow.length <= maxVisible) { return processFlow.map((s) => ({ kind: "step" as const, step: s })); @@ -445,7 +442,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { // 숫자칩 2개를 제외한 나머지를 앞뒤로 배분 (priority에 따라 여분 슬롯 방향 결정) const slotForSteps = maxVisible - 2; const half = Math.floor(slotForSteps / 2); - const extra = slotForSteps - half - 1; // -1은 현재 공정 + const extra = slotForSteps - half - 1; const beforeSlots = priority === "before" ? Math.max(half, extra) : Math.min(half, extra); const afterSlots = slotForSteps - beforeSlots - 1; @@ -481,7 +478,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { const [modalOpen, setModalOpen] = useState(false); - const completedCount = processFlow.filter((s) => s.status === "completed").length; + const completedCount = processFlow.filter((s) => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length; const totalCount = processFlow.length; return ( @@ -493,7 +490,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { cell.align === "center" ? "justify-center" : cell.align === "right" ? "justify-end" : "justify-start", )} onClick={cell.showDetailModal !== false ? (e) => { e.stopPropagation(); setModalOpen(true); } : undefined} - title={cell.showDetailModal !== false ? "클릭하여 전체 공정 현황 보기" : undefined} + title={cell.showDetailModal !== false ? "클릭하여 전체 현황 보기" : undefined} > {displayItems.map((item, idx) => { const isLast = idx === displayItems.length - 1; @@ -503,7 +500,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
{item.count}
@@ -512,7 +509,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { ); } - const styles = TIMELINE_STATUS_STYLES[item.step.status] || TIMELINE_STATUS_STYLES.waiting; + const styles = getTimelineStyle(item.step); return ( @@ -540,20 +537,17 @@ function TimelineCell({ cell, row }: CellRendererProps) { - 전체 공정 현황 + 전체 현황 - 총 {totalCount}개 공정 중 {completedCount}개 완료 + 총 {totalCount}개 중 {completedCount}개 완료
{processFlow.map((step, idx) => { - const styles = TIMELINE_STATUS_STYLES[step.status] || TIMELINE_STATUS_STYLES.waiting; - const statusLabel = - step.status === "completed" ? "완료" : - step.status === "in_progress" ? "진행중" : - step.status === "accepted" ? "접수" : - step.status === "hold" ? "보류" : "대기"; + const styles = getTimelineStyle(step); + const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending"; + const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기"; return (
@@ -569,7 +563,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { {idx < processFlow.length - 1 &&
}
- {/* 공정 정보 */} + {/* 항목 정보 */}
s.isCurrent); if (currentIdx < 0) return false; if (currentIdx === 0) return true; - return processFlow[currentIdx - 1]?.status === "completed"; + const prevStep = processFlow[currentIdx - 1]; + const prevSem = prevStep?.semantic || LEGACY_STATUS_TO_SEMANTIC[prevStep?.status || ""] || "pending"; + return prevSem === "done"; }, [processFlow, statusValue]); const effectiveStatus = isAcceptable ? "acceptable" : statusValue; diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 1632821b..8d478ff3 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -743,27 +743,33 @@ export type CardCellType = | "action-buttons" | "footer-status"; -// timeline 셀에서 사용하는 공정 단계 데이터 +// timeline 셀에서 사용하는 하위 단계 데이터 export interface TimelineProcessStep { seqNo: number; processName: string; - status: string; + status: string; // DB 원본 값 + semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정) isCurrent: boolean; } -// timeline/status-badge/action-buttons가 참조하는 공정 테이블 설정 +// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정 export interface TimelineDataSource { - processTable: string; // 공정 데이터 테이블명 (예: work_order_process) + processTable: string; // 하위 데이터 테이블명 (예: work_order_process) foreignKey: string; // 메인 테이블 id와 매칭되는 FK 컬럼 (예: wo_id) seqColumn: string; // 순서 컬럼 (예: seq_no) - nameColumn: string; // 공정명 컬럼 (예: process_name) + nameColumn: string; // 표시명 컬럼 (예: process_name) statusColumn: string; // 상태 컬럼 (예: status) - statusValues?: { // 상태 값 매핑 (미설정 시 기본값 사용) - waiting?: string; // 대기 (기본: "waiting") - accepted?: string; // 접수 (기본: "accepted") - inProgress?: string; // 진행중 (기본: "in_progress") - completed?: string; // 완료 (기본: "completed") - }; + // 상태 값 매핑: DB값 → 시맨틱 (동적 배열, 순서대로 표시) + // 레거시 호환: 기존 { waiting, accepted, inProgress, completed } 객체도 런타임에서 자동 변환 + statusMappings?: StatusValueMapping[]; +} + +export type TimelineStatusSemantic = "pending" | "active" | "done"; + +export interface StatusValueMapping { + dbValue: string; // DB에 저장된 실제 값 + label: string; // 화면에 보이는 이름 + semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록) } export interface CardCellDefinitionV2 { From c17dd8685972ed120e6cb617e0eb64bcaf7ee9c7 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Mar 2026 18:51:22 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feat(pop):=20pop-search=20status-chip=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?+=20all=5Frows=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20pop-search=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20status-chip=20=EC=9E=85=EB=A0=A5=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EB=90=9C=20=EC=B9=B4=EB=93=9C=EC=9D=98=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=ED=95=98=EA=B3=A0=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=B3=84=20=EA=B1=B4=EC=88=98=EB=A5=BC=20=EC=A7=91=EA=B3=84/?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=ED=95=9C=EB=8B=A4.=20=EC=B9=A9=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=8B=9C=20filter=5Fvalue=EB=A5=BC=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=ED=95=98=EC=97=AC=20=EC=B9=B4=EB=93=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=ED=95=84=ED=84=B0=EB=A7=81=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20[status-chip=20=EC=9E=85=EB=A0=A5=20=ED=83=80?= =?UTF-8?q?=EC=9E=85]=20-=20types.ts:=20StatusChipStyle,=20StatusChipConfi?= =?UTF-8?q?g,=20STATUS=5FCHIP=5FSTYLE=5FLABELS=20-=20PopSearchComponent:?= =?UTF-8?q?=20StatusChipInput=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20(al?= =?UTF-8?q?lRows=20=EA=B5=AC=EB=8F=85=20+=20=EA=B1=B4=EC=88=98=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84)=20-=20PopSearchConfig:=20StatusChipDetailSettings=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20(=EC=B9=A9=20?= =?UTF-8?q?=EC=98=B5=EC=85=98/=EC=8A=A4=ED=83=80=EC=9D=BC)=20-=20index.tsx?= =?UTF-8?q?:=20receivable=EC=97=90=20all=5Frows=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=93=B1=EB=A1=9D=20[all=5Frows=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8]=20-=20pop-card-list-v2:=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20all=5Frow?= =?UTF-8?q?s=20publish=20+=20sendable=20=EB=93=B1=EB=A1=9D=20-=20pop-card-?= =?UTF-8?q?list:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=20all=5Frows=20publish=20+=20sendable=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20-=20useConnectionResolver:=20all=5Frows=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=EC=B9=AD=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20[pop-card-list-v2=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0]=20-=20=ED=95=98=EC=9C=84=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=ED=95=84=ED=84=B0=20=EC=A0=81=EC=9A=A9=20=EC=8B=9C?= =?UTF-8?q?=20=5F=5FsubStatus=5F=5F=20=EA=B0=80=EC=83=81=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=A3=BC=EC=9E=85=20-=20externalFilters=EC=97=90?= =?UTF-8?q?=20=ED=95=98=EC=9C=84=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EB=B6=84=EB=A6=AC=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/hooks/pop/useConnectionResolver.ts | 15 +- .../PopCardListV2Component.tsx | 97 +++++++++--- .../pop-components/pop-card-list-v2/index.tsx | 1 + .../pop-card-list/PopCardListComponent.tsx | 6 + .../pop-components/pop-card-list/index.tsx | 1 + .../pop-search/PopSearchComponent.tsx | 141 +++++++++++++++++- .../pop-search/PopSearchConfig.tsx | 130 ++++++++++++++++ .../pop-components/pop-search/index.tsx | 1 + .../pop-components/pop-search/types.ts | 27 +++- 9 files changed, 390 insertions(+), 29 deletions(-) diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts index a778f35f..4aa03be3 100644 --- a/frontend/hooks/pop/useConnectionResolver.ts +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -60,6 +60,9 @@ function getAutoMatchPairs( if (s.type === "filter_value" && r.type === "filter_value") { pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true }); } + if (s.type === "all_rows" && r.type === "all_rows") { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false }); + } } } @@ -105,11 +108,17 @@ export function useConnectionResolver({ const fieldName = data?.fieldName as string | undefined; const filterColumns = data?.filterColumns as string[] | undefined; const filterMode = (data?.filterMode as string) || "contains"; + // conn.filterConfig에 targetColumn이 명시되어 있으면 우선 사용 + const effectiveColumn = conn.filterConfig?.targetColumn || fieldName; + const effectiveMode = conn.filterConfig?.filterMode || filterMode; + const baseFilterConfig = effectiveColumn + ? { targetColumn: effectiveColumn, targetColumns: conn.filterConfig?.targetColumns || (filterColumns?.length ? filterColumns : [effectiveColumn]), filterMode: effectiveMode } + : conn.filterConfig; publish(targetEvent, { value: payload, - filterConfig: fieldName - ? { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode } - : conn.filterConfig, + filterConfig: conn.filterConfig?.isSubTable + ? { ...baseFilterConfig, isSubTable: true } + : baseFilterConfig, _connectionId: conn.id, }); } else { diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index eacb0ca6..303d9a25 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -132,7 +132,7 @@ export function PopCardListV2Component({ Map >(new Map()); @@ -143,7 +143,7 @@ export function PopCardListV2Component({ (payload: unknown) => { const data = payload as { value?: { fieldName?: string; value?: unknown }; - filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string }; + filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean }; _connectionId?: string; }; const connId = data?._connectionId || "default"; @@ -165,6 +165,12 @@ export function PopCardListV2Component({ return unsub; }, [componentId, subscribe]); + // 전체 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용) + useEffect(() => { + if (!componentId || loading) return; + publish(`__comp_output__${componentId}__all_rows`, rows); + }, [componentId, rows, loading, publish]); + const cartRef = useRef(cart); cartRef.current = cart; @@ -235,31 +241,75 @@ export function PopCardListV2Component({ const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns)); const gridRows = configGridRows; - // 외부 필터 + // 외부 필터 (메인 테이블 + 하위 테이블 분기) const filteredRows = useMemo(() => { if (externalFilters.size === 0) return rows; + const allFilters = [...externalFilters.values()]; - return rows.filter((row) => - allFilters.every((filter) => { - const searchValue = String(filter.value).toLowerCase(); - if (!searchValue) return true; - const fc = filter.filterConfig; - const columns: string[] = - fc?.targetColumns?.length ? fc.targetColumns - : fc?.targetColumn ? [fc.targetColumn] - : filter.fieldName ? [filter.fieldName] : []; - if (columns.length === 0) return true; - const mode = fc?.filterMode || "contains"; - return columns.some((col) => { - const cellValue = String(row[col] ?? "").toLowerCase(); - switch (mode) { - case "equals": return cellValue === searchValue; - case "starts_with": return cellValue.startsWith(searchValue); - default: return cellValue.includes(searchValue); - } + const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); + const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); + + return rows + .map((row) => { + // 1) 메인 테이블 필터 + const passMain = mainFilters.every((filter) => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + const fc = filter.filterConfig; + const columns: string[] = + fc?.targetColumns?.length ? fc.targetColumns + : fc?.targetColumn ? [fc.targetColumn] + : filter.fieldName ? [filter.fieldName] : []; + if (columns.length === 0) return true; + const mode = fc?.filterMode || "contains"; + return columns.some((col) => { + const cellValue = String(row[col] ?? "").toLowerCase(); + switch (mode) { + case "equals": return cellValue === searchValue; + case "starts_with": return cellValue.startsWith(searchValue); + default: return cellValue.includes(searchValue); + } + }); }); - }), - ); + if (!passMain) return null; + + // 2) 하위 테이블 필터 없으면 그대로 반환 + if (subFilters.length === 0) return row; + + // 3) __processFlow__에서 모든 하위 필터 조건을 만족하는 step 탐색 + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + if (!processFlow || processFlow.length === 0) return null; + + const matchingSteps = processFlow.filter((step) => + subFilters.every((filter) => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + const fc = filter.filterConfig; + const col = fc?.targetColumn || filter.fieldName || ""; + if (!col) return true; + const cellValue = String(step.rawData?.[col] ?? "").toLowerCase(); + const mode = fc?.filterMode || "contains"; + switch (mode) { + case "equals": return cellValue === searchValue; + case "starts_with": return cellValue.startsWith(searchValue); + default: return cellValue.includes(searchValue); + } + }), + ); + + if (matchingSteps.length === 0) return null; + + // 매칭된 step 중 첫 번째의 상태를 __subStatus__/__subSemantic__으로 주입 + const matched = matchingSteps[0]; + return { + ...row, + __subStatus__: matched.status, + __subSemantic__: matched.semantic || "pending", + __subProcessName__: matched.processName, + __subSeqNo__: matched.seqNo, + }; + }) + .filter((row): row is RowData => row !== null); }, [rows, externalFilters]); const overflowCfg = effectiveConfig?.overflow; @@ -367,6 +417,7 @@ export function PopCardListV2Component({ status: normalizedStatus, semantic: semantic as "pending" | "active" | "done", isCurrent: semantic === "active", + rawData: p as Record, }); } diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx index d3e80209..138ab941 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx @@ -43,6 +43,7 @@ PopComponentRegistry.registerComponent({ connectionMeta: { sendable: [ { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" }, + { key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" }, { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" }, { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, { key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index f6a1c5c3..c4a2e162 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -256,6 +256,12 @@ export function PopCardListComponent({ return unsub; }, [componentId, subscribe]); + // 전체 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용) + useEffect(() => { + if (!componentId || loading) return; + publish(`__comp_output__${componentId}__all_rows`, rows); + }, [componentId, rows, loading, publish]); + // cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용 const cartRef = useRef(cart); cartRef.current = cart; diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index b9b769af..fe6a43df 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -61,6 +61,7 @@ PopComponentRegistry.registerComponent({ connectionMeta: { sendable: [ { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" }, + { key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" }, { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" }, { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, { key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index 7c5f426d..7db10988 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -37,6 +37,8 @@ import type { ModalSelectConfig, ModalSearchMode, ModalFilterTab, + SelectOption, + StatusChipConfig, } from "./types"; import { DATE_PRESET_LABELS, @@ -147,6 +149,24 @@ export function PopSearchComponent({ return unsub; }, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]); + // status-chip: 연결된 카드 컴포넌트의 전체 rows 수신 + const [allRows, setAllRows] = useState[]>([]); + + useEffect(() => { + if (!componentId || normalizedType !== "status-chip") return; + const unsub = subscribe( + `__comp_input__${componentId}__all_rows`, + (payload: unknown) => { + const data = payload as { value?: unknown } | unknown; + const rows = (typeof data === "object" && data && "value" in data) + ? (data as { value: unknown }).value + : data; + if (Array.isArray(rows)) setAllRows(rows); + } + ); + return unsub; + }, [componentId, subscribe, normalizedType]); + const handleModalOpen = useCallback(() => { if (!config.modalConfig) return; setSimpleModalOpen(true); @@ -189,6 +209,7 @@ export function PopSearchComponent({ modalDisplayText={modalDisplayText} onModalOpen={handleModalOpen} onModalClear={handleModalClear} + allRows={allRows} />
@@ -218,7 +239,11 @@ interface InputRendererProps { onModalClear?: () => void; } -function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) { +interface InputRendererPropsExt extends InputRendererProps { + allRows?: Record[]; +} + +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear, allRows }: InputRendererPropsExt) { const normalized = normalizeInputType(config.inputType as string); switch (normalized) { case "text": @@ -238,6 +263,8 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa return ; case "modal": return ; + case "status-chip": + return ; default: return ; } @@ -651,6 +678,118 @@ function ModalSearchInput({ config, displayText, onClick, onClear }: { config: P ); } +// ======================================== +// status-chip 서브타입 +// ======================================== + +function StatusChipInput({ + config, + value, + onChange, + allRows, +}: { + config: PopSearchConfig; + value: string; + onChange: (v: unknown) => void; + allRows: Record[]; +}) { + const chipCfg: StatusChipConfig = config.statusChipConfig || {}; + const chipStyle = chipCfg.chipStyle || "tab"; + const showCount = chipCfg.showCount !== false; + const countColumn = chipCfg.countColumn || config.fieldName || ""; + const allowAll = chipCfg.allowAll !== false; + const allLabel = chipCfg.allLabel || "전체"; + + const options: SelectOption[] = config.options || []; + + const counts = useMemo(() => { + if (!showCount || !countColumn || allRows.length === 0) return new Map(); + const map = new Map(); + for (const row of allRows) { + const v = String(row[countColumn] ?? ""); + map.set(v, (map.get(v) || 0) + 1); + } + return map; + }, [allRows, countColumn, showCount]); + + const totalCount = allRows.length; + + const chipItems: { value: string; label: string; count: number }[] = useMemo(() => { + const items: { value: string; label: string; count: number }[] = []; + if (allowAll) { + items.push({ value: "", label: allLabel, count: totalCount }); + } + for (const opt of options) { + items.push({ + value: opt.value, + label: opt.label, + count: counts.get(opt.value) || 0, + }); + } + return items; + }, [options, counts, totalCount, allowAll, allLabel]); + + if (chipStyle === "pill") { + return ( +
+ {chipItems.map((item) => { + const isActive = value === item.value; + return ( + + ); + })} +
+ ); + } + + // tab 스타일 (기본) + return ( +
+ {chipItems.map((item) => { + const isActive = value === item.value; + return ( + + ); + })} +
+ ); +} + // ======================================== // 미구현 서브타입 플레이스홀더 // ======================================== diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 4c52961b..eac031c3 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -38,6 +38,8 @@ import type { ModalDisplayStyle, ModalSearchMode, ModalFilterTab, + StatusChipStyle, + StatusChipConfig, } from "./types"; import { SEARCH_INPUT_TYPE_LABELS, @@ -46,6 +48,7 @@ import { MODAL_DISPLAY_STYLE_LABELS, MODAL_SEARCH_MODE_LABELS, MODAL_FILTER_TAB_LABELS, + STATUS_CHIP_STYLE_LABELS, normalizeInputType, } from "./types"; import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; @@ -231,6 +234,8 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component return ; case "modal": return ; + case "status-chip": + return ; case "toggle": return (
@@ -1066,3 +1071,128 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
); } + +// ======================================== +// status-chip 상세 설정 +// ======================================== + +function StatusChipDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { + const chipCfg: StatusChipConfig = cfg.statusChipConfig || {}; + const options = cfg.options || []; + + const updateChip = (partial: Partial) => { + update({ statusChipConfig: { ...chipCfg, ...partial } }); + }; + + const addOption = () => { + update({ + options: [...options, { value: `status_${options.length + 1}`, label: `상태 ${options.length + 1}` }], + }); + }; + + const removeOption = (index: number) => { + update({ options: options.filter((_, i) => i !== index) }); + }; + + const updateOption = (index: number, field: "value" | "label", val: string) => { + update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) }); + }; + + return ( +
+ {/* 칩 옵션 목록 */} +
+ + {options.length === 0 && ( +

옵션이 없습니다. 아래 버튼으로 추가하세요.

+ )} + {options.map((opt, i) => ( +
+ updateOption(i, "value", e.target.value)} placeholder="DB 값" className="h-7 flex-1 text-[10px]" /> + updateOption(i, "label", e.target.value)} placeholder="표시 라벨" className="h-7 flex-1 text-[10px]" /> + +
+ ))} + +
+ + {/* 전체 칩 자동 추가 */} +
+ updateChip({ allowAll: Boolean(checked) })} + /> + +
+ + {chipCfg.allowAll !== false && ( +
+ + updateChip({ allLabel: e.target.value })} + placeholder="전체" + className="h-8 text-xs" + /> +
+ )} + + {/* 건수 표시 */} +
+ updateChip({ showCount: Boolean(checked) })} + /> + +
+ + {chipCfg.showCount !== false && ( +
+ + updateChip({ countColumn: e.target.value })} + placeholder="예: status" + className="h-8 text-xs" + /> +

+ 연결된 카드의 이 컬럼 값으로 상태별 건수를 집계합니다 +

+
+ )} + + {/* 칩 스타일 */} +
+ + +

+ 탭: 큰 숫자 + 라벨 / 알약: 작은 뱃지 형태 +

+
+ + {/* 필터 연결 */} + +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-search/index.tsx b/frontend/lib/registry/pop-components/pop-search/index.tsx index e78dd11c..fadf0bd7 100644 --- a/frontend/lib/registry/pop-components/pop-search/index.tsx +++ b/frontend/lib/registry/pop-components/pop-search/index.tsx @@ -40,6 +40,7 @@ PopComponentRegistry.registerComponent({ ], receivable: [ { key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" }, + { key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "연결된 카드의 전체 데이터를 받아 상태 칩 건수 표시" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 6da0ae32..9157e024 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -1,7 +1,7 @@ // ===== pop-search 전용 타입 ===== // 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나. -/** 검색 필드 입력 타입 (9종) */ +/** 검색 필드 입력 타입 (10종) */ export type SearchInputType = | "text" | "number" @@ -11,7 +11,8 @@ export type SearchInputType = | "multi-select" | "combo" | "modal" - | "toggle"; + | "toggle" + | "status-chip"; /** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */ export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid"; @@ -78,6 +79,18 @@ export interface ModalSelectConfig { distinct?: boolean; } +/** 상태 칩 표시 스타일 */ +export type StatusChipStyle = "tab" | "pill"; + +/** status-chip 전용 설정 */ +export interface StatusChipConfig { + showCount?: boolean; + countColumn?: string; + allowAll?: boolean; + allLabel?: string; + chipStyle?: StatusChipStyle; +} + /** pop-search 전체 설정 */ export interface PopSearchConfig { inputType: SearchInputType | LegacySearchInputType; @@ -103,6 +116,9 @@ export interface PopSearchConfig { // modal 전용 modalConfig?: ModalSelectConfig; + // status-chip 전용 + statusChipConfig?: StatusChipConfig; + // 라벨 labelText?: string; labelVisible?: boolean; @@ -144,6 +160,13 @@ export const SEARCH_INPUT_TYPE_LABELS: Record = { combo: "자동완성", modal: "모달", toggle: "토글", + "status-chip": "상태 칩 (대시보드)", +}; + +/** 상태 칩 스타일 라벨 (설정 패널용) */ +export const STATUS_CHIP_STYLE_LABELS: Record = { + tab: "탭 (큰 숫자)", + pill: "알약 (작은 뱃지)", }; /** 모달 보여주기 방식 라벨 */ From 12ccb85308ee29819340ce6b70dba92697900ac2 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 11 Mar 2026 12:07:11 +0900 Subject: [PATCH 13/16] =?UTF-8?q?feat(pop):=20=EA=B3=B5=EC=A0=95=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9E=90=EB=8F=99=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?+=20=ED=95=98=EC=9C=84=20=ED=95=84=ED=84=B0=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20+=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=83=81=ED=83=9C=EB=B0=B0=EC=A7=80=20?= =?UTF-8?q?=EA=B3=B5=EC=A0=95=20=ED=95=84=ED=84=B0=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=8B=9C=20=EC=83=81=ED=83=9C=20=EB=B1=83=EC=A7=80/=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8/=EB=B2=84=ED=8A=BC=EC=9D=B4=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=EC=83=81=ED=83=9C=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=99=EC=9E=91=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=ED=8C=8C=EC=83=9D=20=EC=83=81=ED=83=9C=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0,=20=ED=95=98=EC=9C=84=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=5F=5FsubStatus=5F=5F=20=EC=A3=BC=EC=9E=85,=20=EC=A0=91?= =?UTF-8?q?=EC=88=98=20=EB=B2=84=ED=8A=BC=20=EA=B3=B5=EC=A0=95=20=ED=96=89?= =?UTF-8?q?=20=ED=8A=B9=EC=A0=95=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=ED=95=9C=EB=8B=A4.=20[=ED=8C=8C=EC=83=9D=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=9E=90=EB=8F=99=20=EA=B3=84=EC=82=B0]=20-=20type?= =?UTF-8?q?s.ts:=20StatusValueMapping.isDerived=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=20=20isDerived=3Dtrue=EB=A9=B4=20DB?= =?UTF-8?q?=EC=97=90=20=EC=97=86=EB=8A=94=20=EC=83=81=ED=83=9C=EB=A1=9C,?= =?UTF-8?q?=20=EC=9D=B4=EC=A0=84=20=EA=B3=B5=EC=A0=95=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EB=B3=80=ED=99=98=20-=20Po?= =?UTF-8?q?pCardListV2Component:=20injectProcessFlow=EC=97=90=20derivedRul?= =?UTF-8?q?es=20=EA=B8=B0=EB=B0=98=20=EB=B3=80=ED=99=98=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=20=20=EA=B0=99=EC=9D=80=20semantic=EC=9D=98=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B6=94=EB=A1=A0=20(waiting=20=E2=86=92=20accepta?= =?UTF-8?q?ble)=20-=20TimelineProcessStep=EC=97=90=20processId,=20rawData?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20[=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=ED=95=84=ED=84=B0=20=5F=5FsubStatus=5F=5F=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85]=20-=20PopCardListV2Component:=20filteredRow?= =?UTF-8?q?s=EB=A5=BC=202=EB=8B=A8=EA=B3=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20=20=201=EB=8B=A8=EA=B3=84:=20=ED=95=98=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94(work=5Forder=5Fprocess)=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=E2=86=92=20=EB=A7=A4=EC=B9=AD=20=EA=B3=B5=EC=A0=95=EC=9D=98?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=EB=A5=BC=20=20=20VIRTUAL=5FSUB=5FSTATUS/S?= =?UTF-8?q?EMANTIC/PROCESS/SEQ=20=EA=B0=80=EC=83=81=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A3=BC=EC=9E=85=20=20=202=EB=8B=A8?= =?UTF-8?q?=EA=B3=84:=20=EB=A9=94=EC=9D=B8=20=ED=95=84=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20status=20=EC=BB=AC=EB=9F=BC=EC=9D=84=20=5F=5FsubSta?= =?UTF-8?q?tus=5F=5F=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EB=8C=80=EC=B2=B4=20?= =?UTF-8?q?-=20cell-renderers:=20StatusBadgeCell/ActionButtonsCell?= =?UTF-8?q?=EC=9D=B4=20=5F=5FsubStatus=5F=5F=20=EC=9A=B0=EC=84=A0=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=20=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9?= =?UTF-8?q?=EB=90=9C=20=EC=A0=91=EC=88=98=EA=B0=80=EB=8A=A5=20=ED=8C=90?= =?UTF-8?q?=EB=B3=84=20=EB=A1=9C=EC=A7=81(isAcceptable)=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=E2=86=92=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20-=20all=5Frows=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89:=20{=20rows,=20subStatusColumn=20}=20envelop?= =?UTF-8?q?e=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=A9=94=ED=83=80=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=20[=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0(isCurrent)=20=EA=B0=9C=EC=84=A0]=20-=20"?= =?UTF-8?q?=EA=B8=B0=EC=A4=80"=20=EC=83=81=ED=83=9C(isDerived)=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EA=B0=95=EC=A1=B0=20+=20=EA=B3=B5=EC=A0=95=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=8B=9C=20=EB=A7=A4=EC=B9=AD=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=EA=B0=95=EC=A1=B0=20-=20=ED=8F=B4=EB=B0=B1:=20acti?= =?UTF-8?q?ve=20=E2=86=92=20pending=20=EC=88=9C=EC=84=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B2=B0=EC=A0=95=20[=EC=A0=91=EC=88=98?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EA=B3=B5=EC=A0=95=20=ED=96=89=20?= =?UTF-8?q?=ED=8A=B9=EC=A0=95]=20-=20cell-renderers:=20ActionButtonsCell?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=98=84=EC=9E=AC=20=EA=B3=B5=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20processId=EB=A5=BC=20=5F=5FprocessId=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20-=20PopCardListV2Component:=20onActionButt?= =?UTF-8?q?onClick=EC=97=90=EC=84=9C=20=5F=5FprocessId=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=B5=EC=A0=95=20=ED=96=89=20UPDATE=20[=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99]=20-=20PopCardListV2Config:=20StatusMappingE?= =?UTF-8?q?ditor=EC=97=90=20"=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99"=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=20=20=EA=B0=99=EC=9D=80=20=EC=B9=B4=EB=93=9C=EC=9D=98=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20statusMappings=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B0=92/=EB=9D=BC=EB=B2=A8/=EC=83=89=EC=83=81/?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=9E=90=EB=8F=99=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=B4=20[=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20UI]=20-=20PopCardListV2Config:=20StatusMappingsEdit?= =?UTF-8?q?or=EC=97=90=20"=EA=B8=B0=EC=A4=80"=20=EB=9D=BC=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80=20=20=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A7=8C=20=EC=84=A0=ED=83=9D=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5,=20=EC=9E=AC=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20[=EC=97=B0=EA=B2=B0=20=ED=83=AD=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=84=A4=EC=A0=95]=20-=20ConnectionEditor:=20isSub?= =?UTF-8?q?Table=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20+=20targetColumn?= =?UTF-8?q?/filterMode=20=EC=84=A4=EC=A0=95=20UI=20-=20pop-layout.ts:=20fi?= =?UTF-8?q?lterConfig.isSubTable=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20[status-chip=20=ED=95=98=EC=9C=84=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A0=84=ED=99=98]=20-=20PopSearchCompone?= =?UTF-8?q?nt:=20=EC=B9=B4=EB=93=9C=EA=B0=80=20=EC=A0=84=EB=8B=AC=ED=95=9C?= =?UTF-8?q?=20subStatusColumn=20=EC=9E=90=EB=8F=99=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?=20=20useSubCount=20=ED=99=9C=EC=84=B1=20=EC=8B=9C=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84/=ED=95=84=ED=84=B0=20=EC=BB=AC=EB=9F=BC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A0=84=ED=99=98=20-=20PopSearchConfig:=20useSubC?= =?UTF-8?q?ount=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20UI=20-=20types.ts:=20StatusChipConfig.useSubCount?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20[=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=20=EB=9D=BC=EB=B2=A8]=20-=20Compone?= =?UTF-8?q?ntEditorPanel:=20comp.label=20||=20comp.id=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../designer/panels/ComponentEditorPanel.tsx | 4 +- .../pop/designer/panels/ConnectionEditor.tsx | 159 ++++++++++- .../pop/designer/types/pop-layout.ts | 1 + .../PopCardListV2Component.tsx | 249 +++++++++++------- .../pop-card-list-v2/PopCardListV2Config.tsx | 61 ++++- .../pop-card-list-v2/cell-renderers.tsx | 107 ++------ .../pop-search/PopSearchComponent.tsx | 50 +++- .../pop-search/PopSearchConfig.tsx | 20 ++ .../pop-components/pop-search/types.ts | 2 + frontend/lib/registry/pop-components/types.ts | 15 +- 10 files changed, 463 insertions(+), 205 deletions(-) diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 77fbe950..a58a6d31 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -170,9 +170,7 @@ export default function ComponentEditorPanel({
{allComponents.map((comp) => { - const label = comp.label - || COMPONENT_TYPE_LABELS[comp.type] - || comp.type; + const label = comp.label || comp.id; const isActive = comp.id === selectedComponentId; return ( )} +
+ {conn.filterConfig?.targetColumn && ( +
+ + {conn.filterConfig.targetColumn} + + + {conn.filterConfig.filterMode} + + {conn.filterConfig.isSubTable && ( + + 하위 테이블 + + )} +
+ )}
)}
@@ -186,6 +205,19 @@ interface SimpleConnectionFormProps { submitLabel: string; } +function extractSubTableName(comp: PopComponentDefinitionV5): string | null { + const cfg = comp.config as Record | undefined; + if (!cfg) return null; + + const grid = cfg.cardGrid as { cells?: Array<{ timelineSource?: { processTable?: string } }> } | undefined; + if (grid?.cells) { + for (const cell of grid.cells) { + if (cell.timelineSource?.processTable) return cell.timelineSource.processTable; + } + } + return null; +} + function SimpleConnectionForm({ component, allComponents, @@ -197,6 +229,18 @@ function SimpleConnectionForm({ const [selectedTargetId, setSelectedTargetId] = React.useState( initial?.targetComponent || "" ); + const [isSubTable, setIsSubTable] = React.useState( + initial?.filterConfig?.isSubTable || false + ); + const [targetColumn, setTargetColumn] = React.useState( + initial?.filterConfig?.targetColumn || "" + ); + const [filterMode, setFilterMode] = React.useState( + initial?.filterConfig?.filterMode || "equals" + ); + + const [subColumns, setSubColumns] = React.useState([]); + const [loadingColumns, setLoadingColumns] = React.useState(false); const targetCandidates = allComponents.filter((c) => { if (c.id === component.id) return false; @@ -204,14 +248,39 @@ function SimpleConnectionForm({ return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; }); + const sourceReg = PopComponentRegistry.getComponent(component.type); + const targetComp = allComponents.find((c) => c.id === selectedTargetId); + const targetReg = targetComp ? PopComponentRegistry.getComponent(targetComp.type) : null; + const isFilterConnection = sourceReg?.connectionMeta?.sendable?.some((s) => s.type === "filter_value") + && targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value"); + + const subTableName = targetComp ? extractSubTableName(targetComp) : null; + + React.useEffect(() => { + if (!isSubTable || !subTableName) { + setSubColumns([]); + return; + } + setLoadingColumns(true); + getTableColumns(subTableName) + .then((res) => { + const cols = res.success && res.data?.columns; + if (Array.isArray(cols)) { + setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean)); + } + }) + .catch(() => setSubColumns([])) + .finally(() => setLoadingColumns(false)); + }, [isSubTable, subTableName]); + const handleSubmit = () => { if (!selectedTargetId) return; - const targetComp = allComponents.find((c) => c.id === selectedTargetId); + const tComp = allComponents.find((c) => c.id === selectedTargetId); const srcLabel = component.label || component.id; - const tgtLabel = targetComp?.label || targetComp?.id || "?"; + const tgtLabel = tComp?.label || tComp?.id || "?"; - onSubmit({ + const conn: Omit = { sourceComponent: component.id, sourceField: "", sourceOutput: "_auto", @@ -219,10 +288,23 @@ function SimpleConnectionForm({ targetField: "", targetInput: "_auto", label: `${srcLabel} → ${tgtLabel}`, - }); + }; + + if (isFilterConnection && isSubTable && targetColumn) { + conn.filterConfig = { + targetColumn, + filterMode: filterMode as "equals" | "contains" | "starts_with" | "range", + isSubTable: true, + }; + } + + onSubmit(conn); if (!initial) { setSelectedTargetId(""); + setIsSubTable(false); + setTargetColumn(""); + setFilterMode("equals"); } }; @@ -244,7 +326,11 @@ function SimpleConnectionForm({ 어디로?
+ {isFilterConnection && selectedTargetId && subTableName && ( +
+
+ { + setIsSubTable(v === true); + if (!v) setTargetColumn(""); + }} + /> + +
+ + {isSubTable && ( +
+
+ 대상 컬럼 + {loadingColumns ? ( +
+ + 컬럼 로딩 중... +
+ ) : ( + + )} +
+ +
+ 비교 방식 + +
+
+ )} +
+ )} + +
+ {hasTimeline && ( + + )} + +
{statusMap.map((m, i) => (
@@ -1708,6 +1745,22 @@ function StatusMappingsEditor({ ))} + diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index 500af96e..5cc9afb3 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -10,7 +10,7 @@ import React, { useMemo, useState } from "react"; import { ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, - Loader2, Play, CheckCircle2, CircleDot, Clock, + Loader2, CheckCircle2, CircleDot, Clock, type LucideIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -19,7 +19,7 @@ import { } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep } from "../types"; -import { DEFAULT_CARD_IMAGE } from "../types"; +import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types"; import type { ButtonVariant } from "../pop-button"; type RowData = Record; @@ -329,35 +329,13 @@ const STATUS_COLORS: Record = { }; function StatusBadgeCell({ cell, row }: CellRendererProps) { - const value = cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : ""); - const strValue = String(value || ""); + const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined; + const effectiveValue = hasSubStatus + ? row[VIRTUAL_SUB_STATUS] + : (cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : "")); + const strValue = String(effectiveValue || ""); const mapped = cell.statusMap?.find((m) => m.value === strValue); - // 접수가능 자동 판별: 하위 데이터 기반 - // 직전 항목이 done이고 현재 항목이 pending이면 "접수가능" - const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; - const isAcceptable = useMemo(() => { - if (!processFlow || strValue !== "waiting") return false; - const currentIdx = processFlow.findIndex((s) => s.isCurrent); - if (currentIdx < 0) return false; - if (currentIdx === 0) return true; - const prevStep = processFlow[currentIdx - 1]; - const prevSem = prevStep?.semantic || LEGACY_STATUS_TO_SEMANTIC[prevStep?.status || ""] || "pending"; - return prevSem === "done"; - }, [processFlow, strValue]); - - if (isAcceptable) { - return ( - - - 접수가능 - - ); - } - if (mapped) { return ( - {formatValue(value)} + {formatValue(effectiveValue)} ); } @@ -614,66 +592,23 @@ function TimelineCell({ cell, row }: CellRendererProps) { // ===== 11. action-buttons ===== function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps) { - const statusValue = cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : ""); + const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined; + const statusValue = hasSubStatus + ? String(row[VIRTUAL_SUB_STATUS] || "") + : (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : "")); const rules = cell.actionRules || []; - // 접수가능 자동 판별 - const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; - const isAcceptable = useMemo(() => { - if (!processFlow || statusValue !== "waiting") return false; - const currentIdx = processFlow.findIndex((s) => s.isCurrent); - if (currentIdx < 0) return false; - if (currentIdx === 0) return true; - const prevStep = processFlow[currentIdx - 1]; - const prevSem = prevStep?.semantic || LEGACY_STATUS_TO_SEMANTIC[prevStep?.status || ""] || "pending"; - return prevSem === "done"; - }, [processFlow, statusValue]); + const matchedRule = rules.find((r) => r.whenStatus === statusValue); - const effectiveStatus = isAcceptable ? "acceptable" : statusValue; - const matchedRule = rules.find((r) => r.whenStatus === effectiveStatus) - || rules.find((r) => r.whenStatus === statusValue); - - // 매칭 규칙이 없을 때 기본 동작 if (!matchedRule) { - if (isAcceptable) { - return ( -
- -
- ); - } - if (statusValue === "in_progress") { - return ( -
- -
- ); - } return null; } + // __processFlow__에서 isCurrent 공정의 processId 추출 + const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; + const currentProcess = processFlow?.find((s) => s.isCurrent); + const currentProcessId = currentProcess?.processId; + return (
{matchedRule.buttons.map((btn, idx) => ( @@ -684,7 +619,11 @@ function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps className="h-7 text-[10px]" onClick={(e) => { e.stopPropagation(); - onActionButtonClick?.(btn.taskPreset, row, btn as Record); + const config = { ...(btn as Record) }; + if (currentProcessId !== undefined) { + config.__processId = currentProcessId; + } + onActionButtonClick?.(btn.taskPreset, row, config); }} > {btn.label} diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index 7db10988..a878bb2b 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -89,13 +89,23 @@ export function PopSearchComponent({ return "contains"; }, [config.filterMode, config.dateSelectionMode, normalizedType]); + // status-chip: 연결된 카드 컴포넌트의 전체 rows + 메타 수신 + const [allRows, setAllRows] = useState[]>([]); + const [autoSubStatusColumn, setAutoSubStatusColumn] = useState(null); + const emitFilterChanged = useCallback( (newValue: unknown) => { setValue(newValue); setSharedData(`search_${fieldKey}`, newValue); if (componentId) { - const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; + const baseColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; + const chipCfg = config.statusChipConfig; + // 카드가 전달한 subStatusColumn이 있으면 자동으로 하위 필터 컬럼 추가 + const subActive = chipCfg?.useSubCount && !!autoSubStatusColumn; + const filterColumns = subActive + ? [...new Set([...baseColumns, autoSubStatusColumn!])] + : baseColumns; publish(`__comp_output__${componentId}__filter_value`, { fieldName: fieldKey, filterColumns, @@ -106,7 +116,7 @@ export function PopSearchComponent({ publish("filter_changed", { [fieldKey]: newValue }); }, - [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns] + [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns, config.statusChipConfig, autoSubStatusColumn] ); useEffect(() => { @@ -149,19 +159,25 @@ export function PopSearchComponent({ return unsub; }, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]); - // status-chip: 연결된 카드 컴포넌트의 전체 rows 수신 - const [allRows, setAllRows] = useState[]>([]); - useEffect(() => { if (!componentId || normalizedType !== "status-chip") return; const unsub = subscribe( `__comp_input__${componentId}__all_rows`, (payload: unknown) => { const data = payload as { value?: unknown } | unknown; - const rows = (typeof data === "object" && data && "value" in data) + const inner = (typeof data === "object" && data && "value" in data) ? (data as { value: unknown }).value : data; - if (Array.isArray(rows)) setAllRows(rows); + + // 카드가 { rows, subStatusColumn } 형태로 발행하는 경우 메타 추출 + if (typeof inner === "object" && inner && !Array.isArray(inner) && "rows" in inner) { + const envelope = inner as { rows?: unknown; subStatusColumn?: string | null }; + if (Array.isArray(envelope.rows)) setAllRows(envelope.rows as Record[]); + setAutoSubStatusColumn(envelope.subStatusColumn ?? null); + } else if (Array.isArray(inner)) { + setAllRows(inner as Record[]); + setAutoSubStatusColumn(null); + } } ); return unsub; @@ -210,6 +226,7 @@ export function PopSearchComponent({ onModalOpen={handleModalOpen} onModalClear={handleModalClear} allRows={allRows} + autoSubStatusColumn={autoSubStatusColumn} />
@@ -241,9 +258,10 @@ interface InputRendererProps { interface InputRendererPropsExt extends InputRendererProps { allRows?: Record[]; + autoSubStatusColumn?: string | null; } -function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear, allRows }: InputRendererPropsExt) { +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear, allRows, autoSubStatusColumn }: InputRendererPropsExt) { const normalized = normalizeInputType(config.inputType as string); switch (normalized) { case "text": @@ -264,7 +282,7 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa case "modal": return ; case "status-chip": - return ; + return ; default: return ; } @@ -687,30 +705,36 @@ function StatusChipInput({ value, onChange, allRows, + autoSubStatusColumn, }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void; allRows: Record[]; + autoSubStatusColumn: string | null; }) { const chipCfg: StatusChipConfig = config.statusChipConfig || {}; const chipStyle = chipCfg.chipStyle || "tab"; const showCount = chipCfg.showCount !== false; - const countColumn = chipCfg.countColumn || config.fieldName || ""; + const baseCountColumn = chipCfg.countColumn || config.fieldName || ""; + const useSubCount = chipCfg.useSubCount || false; const allowAll = chipCfg.allowAll !== false; const allLabel = chipCfg.allLabel || "전체"; const options: SelectOption[] = config.options || []; + // 카드가 전달한 가상 컬럼명이 있으면 자동 사용 + const effectiveCountColumn = (useSubCount && autoSubStatusColumn) ? autoSubStatusColumn : baseCountColumn; + const counts = useMemo(() => { - if (!showCount || !countColumn || allRows.length === 0) return new Map(); + if (!showCount || !effectiveCountColumn || allRows.length === 0) return new Map(); const map = new Map(); for (const row of allRows) { - const v = String(row[countColumn] ?? ""); + const v = String(row[effectiveCountColumn] ?? ""); map.set(v, (map.get(v) || 0) + 1); } return map; - }, [allRows, countColumn, showCount]); + }, [allRows, effectiveCountColumn, showCount]); const totalCount = allRows.length; diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index eac031c3..7c6b98c2 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -1168,6 +1168,26 @@ function StatusChipDetailSettings({ cfg, update, allComponents, connections, com
)} + {chipCfg.showCount !== false && ( +
+
+ updateChip({ useSubCount: Boolean(checked) })} + /> + +
+ {chipCfg.useSubCount && ( +

+ 연결된 카드의 하위 테이블 필터가 적용되면 집계 컬럼이 자동 전환됩니다 +

+ )} +
+ )} + {/* 칩 스타일 */}
diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 9157e024..d8a15fc2 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -89,6 +89,8 @@ export interface StatusChipConfig { allowAll?: boolean; allLabel?: string; chipStyle?: StatusChipStyle; + /** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */ + useSubCount?: boolean; } /** pop-search 전체 설정 */ diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 8d478ff3..e883202f 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -747,9 +747,11 @@ export type CardCellType = export interface TimelineProcessStep { seqNo: number; processName: string; - status: string; // DB 원본 값 + status: string; // DB 원본 값 (또는 derivedFrom에 의해 변환된 값) semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정) isCurrent: boolean; + processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용) + rawData?: Record; // 하위 테이블 원본 행 (하위 필터 매칭용) } // timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정 @@ -767,9 +769,10 @@ export interface TimelineDataSource { export type TimelineStatusSemantic = "pending" | "active" | "done"; export interface StatusValueMapping { - dbValue: string; // DB에 저장된 실제 값 + dbValue: string; // DB에 저장된 실제 값 (또는 파생 상태의 식별값) label: string; // 화면에 보이는 이름 semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록) + isDerived?: boolean; // true면 DB에 없는 자동 판별 상태 (이전 공정 완료 시 변환) } export interface CardCellDefinitionV2 { @@ -817,7 +820,7 @@ export interface CardCellDefinitionV2 { cartIconType?: "lucide" | "emoji"; cartIconValue?: string; - // status-badge 타입 전용 (CARD-3에서 구현) + // status-badge 타입 전용 statusColumn?: string; statusMap?: Array<{ value: string; label: string; color: string }>; @@ -902,3 +905,9 @@ export interface PopCardListV2Config { cartListMode?: CartListModeConfig; saveMapping?: CardListSaveMapping; } + +/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */ +export const VIRTUAL_SUB_STATUS = "__subStatus__" as const; +export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const; +export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const; +export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const; From c7b8acbac3d92b4a314cd69fdaef6df05ff60e80 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 11 Mar 2026 16:35:49 +0900 Subject: [PATCH 14/16] =?UTF-8?q?refactor(pop):=20status-chip=EC=9D=84=20p?= =?UTF-8?q?op-status-bar=20=EB=8F=85=EB=A6=BD=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20+=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=88=9C=ED=99=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95=20pop-search=EC=97=90=20?= =?UTF-8?q?=EB=82=B4=EC=9E=A5=EB=90=98=EC=96=B4=20=EC=9E=88=EB=8D=98=20sta?= =?UTF-8?q?tus-chip=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20pop-status-bar?= =?UTF-8?q?=EB=9D=BC=EB=8A=94=20=EB=8F=85=EB=A6=BD=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=9E=AC=EC=82=AC=EC=9A=A9=EC=84=B1=EA=B3=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=9C=A0=EC=97=B0=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EB=86=92=EC=9D=B8=EB=8B=A4.=20=EC=83=81=ED=83=9C=20=EC=B9=A9?= =?UTF-8?q?=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EC=B9=B4=EC=9A=B4=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EC=99=9C=EA=B3=A1=EB=90=98=EB=8D=98=20=EC=88=9C?= =?UTF-8?q?=ED=99=98=20=EC=9D=98=EC=A1=B4=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=ED=95=9C=EB=8B=A4.=20[pop-status-bar=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8]=20-?= =?UTF-8?q?=20types.ts:=20StatusBarConfig,=20StatusChipOption,=20hiddenMes?= =?UTF-8?q?sage=20=EB=93=B1=20=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98=20-?= =?UTF-8?q?=20PopStatusBarComponent:=20all=5Frows=20=EA=B5=AC=EB=8F=85=20+?= =?UTF-8?q?=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=A7=91=EA=B3=84=20+=20filte?= =?UTF-8?q?r=5Fvalue=20=EB=B0=9C=ED=96=89=20=20=20=5Fsource:=20"status-bar?= =?UTF-8?q?"=20=EB=A7=88=EC=BB=A4=EB=A1=9C=20=EC=9E=90=EC=8B=A0=EC=9D=98?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=8B=9D=EB=B3=84=20=20=20hid?= =?UTF-8?q?eUntilSubFilter:=20=ED=95=98=EC=9C=84=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=A0=84=20=EC=B9=A9=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20+=20=EC=84=A4=EC=A0=95=20=EA=B0=80=EB=8A=A5=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20-=20PopStatusBarConfig:=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20(DB=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B1=84=EC=9A=B0=EA=B8=B0,=20=EA=B3=A0=EC=9C=A0?= =?UTF-8?q?=EA=B0=92=20=ED=81=B4=EB=A6=AD=20=EC=B6=94=EA=B0=80,=20=20=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=EB=AC=B8=EA=B5=AC=20=EC=84=A4=EC=A0=95,?= =?UTF-8?q?=20=ED=95=98=EC=9C=84=20=ED=95=84=ED=84=B0=20=EA=B0=80=EC=83=81?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EC=95=88=EB=82=B4)=20-=20index.tsx:=20?= =?UTF-8?q?=EB=A0=88=EC=A7=80=EC=8A=A4=ED=8A=B8=EB=A6=AC=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D,=20connectionMeta(filter=5Fvalue/all=5Frows/set=5Fval?= =?UTF-8?q?ue)=20[=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=88=9C=ED=99=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95]=20-=20PopCardListV2Com?= =?UTF-8?q?ponent:=20externalFilters=EC=97=90=20=5Fsource=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=80=EC=9E=A5=20=20=20all=5Frows=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EC=8B=9C=20status-bar=20=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=A0=9C=EC=99=B8=ED=95=9C=20row?= =?UTF-8?q?sForStatusCount=20=EA=B3=84=EC=82=B0=20=20=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EC=B9=A9=20=ED=81=B4=EB=A6=AD=ED=95=B4=EB=8F=84=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=EA=B0=80=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=EB=90=A8=20[pop-search=EC=97=90=EC=84=9C=20status-chi?= =?UTF-8?q?p=20=EC=A0=9C=EA=B1=B0]=20-=20PopSearchComponent:=20StatusChipI?= =?UTF-8?q?nput,=20allRows=20=EA=B5=AC=EB=8F=85,=20autoSubStatusColumn=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20-=20PopSearchConfig:=20StatusChipDetailSet?= =?UTF-8?q?tings=20=EC=A0=9C=EA=B1=B0,=20=EB=B6=84=EB=A6=AC=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A1=9C=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4=20-=20index.tsx:=20receivable=EC=97=90=EC=84=9C=20all?= =?UTF-8?q?=5Frows=20=EC=A0=9C=EA=B1=B0=20-=20types.ts:=20StatusChipStyle,?= =?UTF-8?q?=20StatusChipConfig=EC=97=90=20@deprecated=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20[=EC=84=A4=EC=A0=95=20UX=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0]=20-=20"=EC=A0=84=EC=B2=B4=20=EC=B9=A9=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B6=94=EA=B0=80"=20=E2=86=92=20"=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=B3=B4=EA=B8=B0=20=EC=B9=A9=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?"=20+=20=EC=84=A4=EB=AA=85=20=EB=AC=B8=EA=B5=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20hiddenMessage:=20=EC=88=A8=EA=B9=80=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B0=80=EB=8A=A5=20(=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9=20=EC=A0=9C=EA=B1=B0)=20-=20useSubCount=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EA=B0=80=EC=83=81=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=20=EC=95=88=EB=82=B4=20=EA=B2=BD=EA=B3=A0=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/registry/pop-components/index.ts | 1 + .../PopCardListV2Component.tsx | 400 +++++++++++++- .../pop-search/PopSearchComponent.tsx | 173 +------ .../pop-search/PopSearchConfig.tsx | 156 +----- .../pop-components/pop-search/index.tsx | 1 - .../pop-components/pop-search/types.ts | 5 +- .../pop-status-bar/PopStatusBarComponent.tsx | 243 +++++++++ .../pop-status-bar/PopStatusBarConfig.tsx | 489 ++++++++++++++++++ .../pop-components/pop-status-bar/index.tsx | 87 ++++ .../pop-components/pop-status-bar/types.ts | 48 ++ 10 files changed, 1273 insertions(+), 330 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx create mode 100644 frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx create mode 100644 frontend/lib/registry/pop-components/pop-status-bar/index.tsx create mode 100644 frontend/lib/registry/pop-components/pop-status-bar/types.ts diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index 26436d86..351d6700 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -21,6 +21,7 @@ import "./pop-card-list-v2"; import "./pop-button"; import "./pop-string-list"; import "./pop-search"; +import "./pop-status-bar"; import "./pop-field"; import "./pop-scanner"; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index a14e3635..5a424d4e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -10,10 +10,14 @@ import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; import { useRouter } from "next/navigation"; import { - Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, + Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, Check, X, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; import type { PopCardListV2Config, CardGridConfigV2, @@ -30,6 +34,8 @@ import type { TimelineDataSource, ActionButtonUpdate, StatusValueMapping, + SelectModeConfig, + SelectModeButtonConfig, } from "../types"; import { CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE, @@ -42,6 +48,10 @@ import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; import { NumberInputModal } from "../pop-card-list/NumberInputModal"; import { renderCellV2 } from "./cell-renderers"; +import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout"; +import { isV5Layout, detectGridMode } from "@/components/pop/designer/types/pop-layout"; +import dynamic from "next/dynamic"; +const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false }); type RowData = Record; @@ -136,6 +146,7 @@ export function PopCardListV2Component({ fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean }; + _source?: string; }> >(new Map()); @@ -145,7 +156,7 @@ export function PopCardListV2Component({ `__comp_input__${componentId}__filter_condition`, (payload: unknown) => { const data = payload as { - value?: { fieldName?: string; value?: unknown }; + value?: { fieldName?: string; value?: unknown; _source?: string }; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean }; _connectionId?: string; }; @@ -157,6 +168,7 @@ export function PopCardListV2Component({ fieldName: data.value.fieldName || "", value: data.value.value, filterConfig: data.filterConfig, + _source: data.value._source, }); } else { next.delete(connId); @@ -199,6 +211,73 @@ export function PopCardListV2Component({ publish(`__comp_output__${componentId}__selected_row`, row); }, [componentId, publish]); + // ===== 선택 모드 ===== + const [selectMode, setSelectMode] = useState(false); + const [selectModeStatus, setSelectModeStatus] = useState(""); + const [selectModeConfig, setSelectModeConfig] = useState(null); + const [selectedRowIds, setSelectedRowIds] = useState>(new Set()); + const [selectProcessing, setSelectProcessing] = useState(false); + + // ===== 모달 열기 (POP 화면) ===== + const [popModalOpen, setPopModalOpen] = useState(false); + const [popModalLayout, setPopModalLayout] = useState(null); + const [popModalScreenId, setPopModalScreenId] = useState(""); + const [popModalRow, setPopModalRow] = useState(null); + + const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => { + try { + const sid = parseInt(screenIdStr, 10); + if (isNaN(sid)) { + toast.error("올바른 화면 ID가 아닙니다."); + return; + } + const popLayout = await screenApi.getLayoutPop(sid); + if (popLayout && isV5Layout(popLayout)) { + setPopModalLayout(popLayout); + setPopModalScreenId(String(sid)); + setPopModalRow(row); + setPopModalOpen(true); + } else { + toast.error("해당 POP 화면을 찾을 수 없습니다."); + } + } catch { + toast.error("POP 화면을 불러오는데 실패했습니다."); + } + }, []); + + const enterSelectMode = useCallback((whenStatus: string, buttonConfig: Record) => { + const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined; + if (!smConfig) return; + setSelectMode(true); + setSelectModeStatus(smConfig.filterStatus || whenStatus); + setSelectModeConfig(smConfig); + setSelectedRowIds(new Set()); + }, []); + + const exitSelectMode = useCallback(() => { + setSelectMode(false); + setSelectModeStatus(""); + setSelectModeConfig(null); + setSelectedRowIds(new Set()); + }, []); + + const toggleRowSelection = useCallback((row: RowData) => { + const rowId = String(row.id ?? row.pk ?? ""); + if (!rowId) return; + setSelectedRowIds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); else next.add(rowId); + return next; + }); + }, []); + + const isRowSelectable = useCallback((row: RowData) => { + if (!selectMode) return false; + const subStatus = row[VIRTUAL_SUB_STATUS]; + if (subStatus !== undefined) return String(subStatus) === selectModeStatus; + return true; + }, [selectMode, selectModeStatus]); + // 확장/페이지네이션 const [isExpanded, setIsExpanded] = useState(false); const [currentPage, setCurrentPage] = useState(1); @@ -341,14 +420,176 @@ export function PopCardListV2Component({ return [...externalFilters.values()].some((f) => f.filterConfig?.isSubTable); }, [externalFilters]); - // 필터 적용된 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용) + // 선택 모드 일괄 처리 + const handleSelectModeAction = useCallback(async (btnConfig: SelectModeButtonConfig) => { + if (btnConfig.clickMode === "cancel-select") { + exitSelectMode(); + return; + } + + if (btnConfig.clickMode === "status-change" && btnConfig.updates && btnConfig.targetTable) { + if (selectedRowIds.size === 0) { + toast.error("선택된 항목이 없습니다."); + return; + } + if (btnConfig.confirmMessage && !window.confirm(btnConfig.confirmMessage)) return; + + setSelectProcessing(true); + try { + const selectedRows = filteredRows.filter((r) => { + const rowId = String(r.id ?? r.pk ?? ""); + return selectedRowIds.has(rowId); + }); + + let successCount = 0; + for (const row of selectedRows) { + const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; + const currentProcess = processFlow?.find((s) => s.isCurrent); + const targetId = currentProcess?.processId ?? row.id ?? row.pk; + if (!targetId) continue; + + const tasks = btnConfig.updates.map((u, idx) => ({ + id: `sel-update-${idx}`, + type: "data-update" as const, + targetTable: btnConfig.targetTable!, + targetColumn: u.column, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: u.valueType === "static" ? (u.value ?? "") : + u.valueType === "currentUser" ? "__CURRENT_USER__" : + u.valueType === "currentTime" ? "__CURRENT_TIME__" : + (u.value ?? ""), + lookupMode: "manual" as const, + manualItemField: "id", + manualPkColumn: "id", + })); + + const result = await apiClient.post("/pop/execute-action", { + tasks, + data: { items: [{ ...row, id: targetId }], fieldValues: {} }, + mappings: {}, + }); + if (result.data?.success) successCount++; + } + + if (successCount > 0) { + toast.success(`${successCount}건 처리 완료`); + exitSelectMode(); + fetchDataRef.current(); + } else { + toast.error("처리에 실패했습니다."); + } + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } finally { + setSelectProcessing(false); + } + return; + } + + if (btnConfig.clickMode === "modal-open" && btnConfig.modalScreenId) { + const selectedRows = filteredRows.filter((r) => { + const rowId = String(r.id ?? r.pk ?? ""); + return selectedRowIds.has(rowId); + }); + openPopModal(btnConfig.modalScreenId, selectedRows[0] || {}); + return; + } + }, [selectedRowIds, filteredRows, exitSelectMode]); + + // status-bar 필터를 제외한 rows (카운트 집계용) + // status-bar에서 "접수가능" 등 선택해도 전체 카운트가 유지되어야 함 + const rowsForStatusCount = useMemo(() => { + const hasStatusBarFilter = [...externalFilters.values()].some((f) => f._source === "status-bar"); + if (!hasStatusBarFilter) return filteredRows; + + // status-bar 필터를 제외한 필터만 적용 + const nonStatusFilters = new Map( + [...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar") + ); + if (nonStatusFilters.size === 0) return rows; + + const allFilters = [...nonStatusFilters.values()]; + const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); + const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); + + const afterSubFilter = subFilters.length === 0 + ? rows + : rows + .map((row) => { + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + if (!processFlow || processFlow.length === 0) return null; + const matchingSteps = processFlow.filter((step) => + subFilters.every((filter) => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + const fc = filter.filterConfig; + const col = fc?.targetColumn || filter.fieldName || ""; + if (!col) return true; + const cellValue = String(step.rawData?.[col] ?? "").toLowerCase(); + const mode = fc?.filterMode || "contains"; + switch (mode) { + case "equals": return cellValue === searchValue; + case "starts_with": return cellValue.startsWith(searchValue); + default: return cellValue.includes(searchValue); + } + }), + ); + if (matchingSteps.length === 0) return null; + const matched = matchingSteps[0]; + const updatedFlow = processFlow.map((s) => ({ + ...s, + isCurrent: s.seqNo === matched.seqNo, + })); + return { + ...row, + __processFlow__: updatedFlow, + [VIRTUAL_SUB_STATUS]: matched.status, + [VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending", + [VIRTUAL_SUB_PROCESS]: matched.processName, + [VIRTUAL_SUB_SEQ]: matched.seqNo, + }; + }) + .filter((row): row is RowData => row !== null); + + if (mainFilters.length === 0) return afterSubFilter; + + return afterSubFilter.filter((row) => + mainFilters.every((filter) => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + const fc = filter.filterConfig; + const columns: string[] = + fc?.targetColumns?.length ? fc.targetColumns + : fc?.targetColumn ? [fc.targetColumn] + : filter.fieldName ? [filter.fieldName] : []; + if (columns.length === 0) return true; + const mode = fc?.filterMode || "contains"; + const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null; + const statusCol = timelineSource?.statusColumn || "status"; + const effectiveColumns = subCol + ? columns.map((col) => col === statusCol || col === "status" ? subCol : col) + : columns; + return effectiveColumns.some((col) => { + const cellValue = String(row[col] ?? "").toLowerCase(); + switch (mode) { + case "equals": return cellValue === searchValue; + case "starts_with": return cellValue.startsWith(searchValue); + default: return cellValue.includes(searchValue); + } + }); + }), + ); + }, [rows, filteredRows, externalFilters, timelineSource]); + + // 카운트 집계용 rows 발행 (status-bar 필터 제외) useEffect(() => { if (!componentId || loading) return; publish(`__comp_output__${componentId}__all_rows`, { - rows: filteredRows, + rows: rowsForStatusCount, subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null, }); - }, [componentId, filteredRows, loading, publish, hasActiveSubFilter]); + }, [componentId, rowsForStatusCount, loading, publish, hasActiveSubFilter]); const overflowCfg = effectiveConfig?.overflow; const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows; @@ -571,6 +812,9 @@ export function PopCardListV2Component({ } finally { setLoading(false); } }, [dataSource, timelineSource, injectProcessFlow]); + const fetchDataRef = useRef(fetchData); + fetchDataRef.current = fetchData; + useEffect(() => { if (isCartListMode) { const cartListMode = config!.cartListMode!; @@ -701,7 +945,31 @@ export function PopCardListV2Component({
) : ( <> - {isCartListMode && ( + {/* 선택 모드 상단 바 */} + {selectMode && ( +
+
+
+ {selectedRowIds.size} +
+ + {selectedRowIds.size > 0 ? `${selectedRowIds.size}개 선택됨` : "카드를 선택하세요"} + +
+ +
+ )} + + {/* 장바구니 모드 상단 바 */} + {!selectMode && isCartListMode && (
toggleRowSelection(row)} + onEnterSelectMode={enterSelectMode} + onOpenPopModal={openPopModal} /> ))}
- {hasMoreCards && ( + {/* 선택 모드 하단 액션 바 */} + {selectMode && selectModeConfig && ( +
+
+ {selectModeConfig.buttons.map((btn, idx) => ( + + ))} +
+
+ )} + + {/* 더보기/페이지네이션 */} + {!selectMode && hasMoreCards && (
@@ -778,6 +1076,31 @@ export function PopCardListV2Component({ )} )} + + {/* POP 화면 모달 */} + { + setPopModalOpen(open); + if (!open) { + setPopModalLayout(null); + setPopModalRow(null); + } + }}> + + + 상세 작업 + +
+ {popModalLayout && ( + + )} +
+
+
); } @@ -799,12 +1122,20 @@ interface CardV2Props { onDeleteItem?: (cartId: string) => void; onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void; onRefresh?: () => void; + selectMode?: boolean; + isSelectModeSelected?: boolean; + isSelectable?: boolean; + onToggleRowSelect?: () => void; + onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; + onOpenPopModal?: (screenId: string, row: RowData) => void; } function CardV2({ row, cardGrid, spec, config, onSelect, cart, publish, parentComponentId, isCartListMode, isSelected, onToggleSelect, onDeleteItem, onUpdateQuantity, onRefresh, + selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode, + onOpenPopModal, }: CardV2Props) { const inputField = config?.inputField; const cartAction = config?.cartAction; @@ -882,9 +1213,15 @@ function CardV2({ } catch { toast.error("삭제에 실패했습니다."); } }; - const borderClass = isCartListMode - ? isSelected ? "border-primary border-2 hover:border-primary/80" : "hover:border-2 hover:border-blue-500" - : isCarted ? "border-emerald-500 border-2 hover:border-emerald-600" : "hover:border-2 hover:border-blue-500"; + const borderClass = selectMode + ? isSelectModeSelected + ? "border-primary border-2 bg-primary/5" + : isSelectable + ? "hover:border-2 hover:border-primary/50" + : "opacity-40 pointer-events-none" + : isCartListMode + ? isSelected ? "border-primary border-2 hover:border-primary/80" : "hover:border-2 hover:border-blue-500" + : isCarted ? "border-emerald-500 border-2 hover:border-emerald-600" : "hover:border-2 hover:border-blue-500"; if (!cardGrid || cardGrid.cells.length === 0) { return ( @@ -917,13 +1254,38 @@ function CardV2({
onSelect?.(row)} + onClick={() => { + if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } + if (!selectMode) onSelect?.(row); + }} role="button" tabIndex={0} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect?.(row); }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } + if (!selectMode) onSelect?.(row); + } + }} > + {/* 선택 모드: 체크 인디케이터 */} + {selectMode && isSelectable && ( +
+
{ e.stopPropagation(); onToggleRowSelect?.(); }} + > + {isSelectModeSelected && } +
+
+ )} + {/* 장바구니 목록 모드: 체크박스 + 삭제 */} - {isCartListMode && ( + {!selectMode && isCartListMode && (
{ const cfg = buttonConfig as { updates?: ActionButtonUpdate[]; @@ -993,6 +1356,9 @@ function CardV2({ u.valueType === "currentTime" ? "__CURRENT_TIME__" : u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") : (u.value ?? ""), + lookupMode: "manual" as const, + manualItemField: "id", + manualPkColumn: "id", })); const targetRow = cfg.__processId ? { ...actionRow, id: cfg.__processId } @@ -1013,6 +1379,13 @@ function CardV2({ } return; } + + const actionCfg = buttonConfig as { type?: string; modalScreenId?: string } | undefined; + if (actionCfg?.type === "modal-open" && actionCfg.modalScreenId) { + onOpenPopModal?.(actionCfg.modalScreenId, actionRow); + return; + } + if (parentComponentId) { publish(`__comp_output__${parentComponentId}__action`, { taskPreset, @@ -1040,6 +1413,7 @@ function CardV2({ onConfirm={handleInputConfirm} /> )} +
); } diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index a878bb2b..f019eb41 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -33,12 +33,9 @@ import type { PopSearchConfig, DatePresetOption, DateSelectionMode, - CalendarDisplayMode, ModalSelectConfig, ModalSearchMode, ModalFilterTab, - SelectOption, - StatusChipConfig, } from "./types"; import { DATE_PRESET_LABELS, @@ -89,9 +86,6 @@ export function PopSearchComponent({ return "contains"; }, [config.filterMode, config.dateSelectionMode, normalizedType]); - // status-chip: 연결된 카드 컴포넌트의 전체 rows + 메타 수신 - const [allRows, setAllRows] = useState[]>([]); - const [autoSubStatusColumn, setAutoSubStatusColumn] = useState(null); const emitFilterChanged = useCallback( (newValue: unknown) => { @@ -99,13 +93,7 @@ export function PopSearchComponent({ setSharedData(`search_${fieldKey}`, newValue); if (componentId) { - const baseColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; - const chipCfg = config.statusChipConfig; - // 카드가 전달한 subStatusColumn이 있으면 자동으로 하위 필터 컬럼 추가 - const subActive = chipCfg?.useSubCount && !!autoSubStatusColumn; - const filterColumns = subActive - ? [...new Set([...baseColumns, autoSubStatusColumn!])] - : baseColumns; + const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; publish(`__comp_output__${componentId}__filter_value`, { fieldName: fieldKey, filterColumns, @@ -116,7 +104,7 @@ export function PopSearchComponent({ publish("filter_changed", { [fieldKey]: newValue }); }, - [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns, config.statusChipConfig, autoSubStatusColumn] + [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns] ); useEffect(() => { @@ -159,30 +147,6 @@ export function PopSearchComponent({ return unsub; }, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]); - useEffect(() => { - if (!componentId || normalizedType !== "status-chip") return; - const unsub = subscribe( - `__comp_input__${componentId}__all_rows`, - (payload: unknown) => { - const data = payload as { value?: unknown } | unknown; - const inner = (typeof data === "object" && data && "value" in data) - ? (data as { value: unknown }).value - : data; - - // 카드가 { rows, subStatusColumn } 형태로 발행하는 경우 메타 추출 - if (typeof inner === "object" && inner && !Array.isArray(inner) && "rows" in inner) { - const envelope = inner as { rows?: unknown; subStatusColumn?: string | null }; - if (Array.isArray(envelope.rows)) setAllRows(envelope.rows as Record[]); - setAutoSubStatusColumn(envelope.subStatusColumn ?? null); - } else if (Array.isArray(inner)) { - setAllRows(inner as Record[]); - setAutoSubStatusColumn(null); - } - } - ); - return unsub; - }, [componentId, subscribe, normalizedType]); - const handleModalOpen = useCallback(() => { if (!config.modalConfig) return; setSimpleModalOpen(true); @@ -225,8 +189,6 @@ export function PopSearchComponent({ modalDisplayText={modalDisplayText} onModalOpen={handleModalOpen} onModalClear={handleModalClear} - allRows={allRows} - autoSubStatusColumn={autoSubStatusColumn} />
@@ -256,12 +218,7 @@ interface InputRendererProps { onModalClear?: () => void; } -interface InputRendererPropsExt extends InputRendererProps { - allRows?: Record[]; - autoSubStatusColumn?: string | null; -} - -function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear, allRows, autoSubStatusColumn }: InputRendererPropsExt) { +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) { const normalized = normalizeInputType(config.inputType as string); switch (normalized) { case "text": @@ -282,7 +239,11 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa case "modal": return ; case "status-chip": - return ; + return ( +
+ pop-status-bar 컴포넌트를 사용하세요 +
+ ); default: return ; } @@ -696,124 +657,6 @@ function ModalSearchInput({ config, displayText, onClick, onClear }: { config: P ); } -// ======================================== -// status-chip 서브타입 -// ======================================== - -function StatusChipInput({ - config, - value, - onChange, - allRows, - autoSubStatusColumn, -}: { - config: PopSearchConfig; - value: string; - onChange: (v: unknown) => void; - allRows: Record[]; - autoSubStatusColumn: string | null; -}) { - const chipCfg: StatusChipConfig = config.statusChipConfig || {}; - const chipStyle = chipCfg.chipStyle || "tab"; - const showCount = chipCfg.showCount !== false; - const baseCountColumn = chipCfg.countColumn || config.fieldName || ""; - const useSubCount = chipCfg.useSubCount || false; - const allowAll = chipCfg.allowAll !== false; - const allLabel = chipCfg.allLabel || "전체"; - - const options: SelectOption[] = config.options || []; - - // 카드가 전달한 가상 컬럼명이 있으면 자동 사용 - const effectiveCountColumn = (useSubCount && autoSubStatusColumn) ? autoSubStatusColumn : baseCountColumn; - - const counts = useMemo(() => { - if (!showCount || !effectiveCountColumn || allRows.length === 0) return new Map(); - const map = new Map(); - for (const row of allRows) { - const v = String(row[effectiveCountColumn] ?? ""); - map.set(v, (map.get(v) || 0) + 1); - } - return map; - }, [allRows, effectiveCountColumn, showCount]); - - const totalCount = allRows.length; - - const chipItems: { value: string; label: string; count: number }[] = useMemo(() => { - const items: { value: string; label: string; count: number }[] = []; - if (allowAll) { - items.push({ value: "", label: allLabel, count: totalCount }); - } - for (const opt of options) { - items.push({ - value: opt.value, - label: opt.label, - count: counts.get(opt.value) || 0, - }); - } - return items; - }, [options, counts, totalCount, allowAll, allLabel]); - - if (chipStyle === "pill") { - return ( -
- {chipItems.map((item) => { - const isActive = value === item.value; - return ( - - ); - })} -
- ); - } - - // tab 스타일 (기본) - return ( -
- {chipItems.map((item) => { - const isActive = value === item.value; - return ( - - ); - })} -
- ); -} - // ======================================== // 미구현 서브타입 플레이스홀더 // ======================================== diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 7c6b98c2..8c619429 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -38,8 +38,6 @@ import type { ModalDisplayStyle, ModalSearchMode, ModalFilterTab, - StatusChipStyle, - StatusChipConfig, } from "./types"; import { SEARCH_INPUT_TYPE_LABELS, @@ -48,7 +46,6 @@ import { MODAL_DISPLAY_STYLE_LABELS, MODAL_SEARCH_MODE_LABELS, MODAL_FILTER_TAB_LABELS, - STATUS_CHIP_STYLE_LABELS, normalizeInputType, } from "./types"; import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; @@ -235,7 +232,14 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component case "modal": return ; case "status-chip": - return ; + return ( +
+

+ 상태 칩은 pop-status-bar 컴포넌트로 분리되었습니다. + 새로운 "상태 바" 컴포넌트를 사용해주세요. +

+
+ ); case "toggle": return (
@@ -1072,147 +1076,3 @@ function ModalDetailSettings({ cfg, update }: StepProps) { ); } -// ======================================== -// status-chip 상세 설정 -// ======================================== - -function StatusChipDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { - const chipCfg: StatusChipConfig = cfg.statusChipConfig || {}; - const options = cfg.options || []; - - const updateChip = (partial: Partial) => { - update({ statusChipConfig: { ...chipCfg, ...partial } }); - }; - - const addOption = () => { - update({ - options: [...options, { value: `status_${options.length + 1}`, label: `상태 ${options.length + 1}` }], - }); - }; - - const removeOption = (index: number) => { - update({ options: options.filter((_, i) => i !== index) }); - }; - - const updateOption = (index: number, field: "value" | "label", val: string) => { - update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) }); - }; - - return ( -
- {/* 칩 옵션 목록 */} -
- - {options.length === 0 && ( -

옵션이 없습니다. 아래 버튼으로 추가하세요.

- )} - {options.map((opt, i) => ( -
- updateOption(i, "value", e.target.value)} placeholder="DB 값" className="h-7 flex-1 text-[10px]" /> - updateOption(i, "label", e.target.value)} placeholder="표시 라벨" className="h-7 flex-1 text-[10px]" /> - -
- ))} - -
- - {/* 전체 칩 자동 추가 */} -
- updateChip({ allowAll: Boolean(checked) })} - /> - -
- - {chipCfg.allowAll !== false && ( -
- - updateChip({ allLabel: e.target.value })} - placeholder="전체" - className="h-8 text-xs" - /> -
- )} - - {/* 건수 표시 */} -
- updateChip({ showCount: Boolean(checked) })} - /> - -
- - {chipCfg.showCount !== false && ( -
- - updateChip({ countColumn: e.target.value })} - placeholder="예: status" - className="h-8 text-xs" - /> -

- 연결된 카드의 이 컬럼 값으로 상태별 건수를 집계합니다 -

-
- )} - - {chipCfg.showCount !== false && ( -
-
- updateChip({ useSubCount: Boolean(checked) })} - /> - -
- {chipCfg.useSubCount && ( -

- 연결된 카드의 하위 테이블 필터가 적용되면 집계 컬럼이 자동 전환됩니다 -

- )} -
- )} - - {/* 칩 스타일 */} -
- - -

- 탭: 큰 숫자 + 라벨 / 알약: 작은 뱃지 형태 -

-
- - {/* 필터 연결 */} - -
- ); -} diff --git a/frontend/lib/registry/pop-components/pop-search/index.tsx b/frontend/lib/registry/pop-components/pop-search/index.tsx index fadf0bd7..e78dd11c 100644 --- a/frontend/lib/registry/pop-components/pop-search/index.tsx +++ b/frontend/lib/registry/pop-components/pop-search/index.tsx @@ -40,7 +40,6 @@ PopComponentRegistry.registerComponent({ ], receivable: [ { key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" }, - { key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "연결된 카드의 전체 데이터를 받아 상태 칩 건수 표시" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index d8a15fc2..6b284b60 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -79,17 +79,16 @@ export interface ModalSelectConfig { distinct?: boolean; } -/** 상태 칩 표시 스타일 */ +/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */ export type StatusChipStyle = "tab" | "pill"; -/** status-chip 전용 설정 */ +/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */ export interface StatusChipConfig { showCount?: boolean; countColumn?: string; allowAll?: boolean; allLabel?: string; chipStyle?: StatusChipStyle; - /** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */ useSubCount?: boolean; } diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx new file mode 100644 index 00000000..805fadcd --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { usePopEvent } from "@/hooks/pop"; +import type { StatusBarConfig, StatusChipOption } from "./types"; +import { DEFAULT_STATUS_BAR_CONFIG } from "./types"; + +interface PopStatusBarComponentProps { + config: StatusBarConfig; + label?: string; + screenId?: string; + componentId?: string; +} + +export function PopStatusBarComponent({ + config: rawConfig, + label, + screenId, + componentId, +}: PopStatusBarComponentProps) { + const config = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) }; + const { publish, subscribe } = usePopEvent(screenId || ""); + + const [selectedValue, setSelectedValue] = useState(""); + const [allRows, setAllRows] = useState[]>([]); + const [autoSubStatusColumn, setAutoSubStatusColumn] = useState(null); + + // all_rows 이벤트 구독 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__all_rows`, + (payload: unknown) => { + const data = payload as { value?: unknown } | unknown; + const inner = + typeof data === "object" && data && "value" in data + ? (data as { value: unknown }).value + : data; + + if ( + typeof inner === "object" && + inner && + !Array.isArray(inner) && + "rows" in inner + ) { + const envelope = inner as { + rows?: unknown; + subStatusColumn?: string | null; + }; + if (Array.isArray(envelope.rows)) + setAllRows(envelope.rows as Record[]); + setAutoSubStatusColumn(envelope.subStatusColumn ?? null); + } else if (Array.isArray(inner)) { + setAllRows(inner as Record[]); + setAutoSubStatusColumn(null); + } + } + ); + return unsub; + }, [componentId, subscribe]); + + // 외부에서 값 설정 이벤트 구독 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__set_value`, + (payload: unknown) => { + const data = payload as { value?: unknown } | unknown; + const incoming = + typeof data === "object" && data && "value" in data + ? (data as { value: unknown }).value + : data; + setSelectedValue(String(incoming ?? "")); + } + ); + return unsub; + }, [componentId, subscribe]); + + const emitFilter = useCallback( + (newValue: string) => { + setSelectedValue(newValue); + if (!componentId) return; + + const baseColumn = config.filterColumn || config.countColumn || ""; + const subActive = config.useSubCount && !!autoSubStatusColumn; + const filterColumns = subActive + ? [...new Set([baseColumn, autoSubStatusColumn!].filter(Boolean))] + : [baseColumn].filter(Boolean); + + publish(`__comp_output__${componentId}__filter_value`, { + fieldName: baseColumn, + filterColumns, + value: newValue, + filterMode: "equals", + _source: "status-bar", + }); + }, + [componentId, publish, config.filterColumn, config.countColumn, config.useSubCount, autoSubStatusColumn] + ); + + const chipCfg = config; + const showCount = chipCfg.showCount !== false; + const baseCountColumn = chipCfg.countColumn || ""; + const useSubCount = chipCfg.useSubCount || false; + const hideUntilSubFilter = chipCfg.hideUntilSubFilter || false; + const allowAll = chipCfg.allowAll !== false; + const allLabel = chipCfg.allLabel || "전체"; + const chipStyle = chipCfg.chipStyle || "tab"; + const options: StatusChipOption[] = chipCfg.options || []; + + // 하위 필터(공정) 활성 여부 + const subFilterActive = useSubCount && !!autoSubStatusColumn; + + // hideUntilSubFilter가 켜져있으면서 아직 공정 선택이 안 된 경우 숨김 + const shouldHide = hideUntilSubFilter && !subFilterActive; + + const effectiveCountColumn = + subFilterActive ? autoSubStatusColumn : baseCountColumn; + + const counts = useMemo(() => { + if (!showCount || !effectiveCountColumn || allRows.length === 0) + return new Map(); + const map = new Map(); + for (const row of allRows) { + if (row == null || typeof row !== "object") continue; + const v = String(row[effectiveCountColumn] ?? ""); + map.set(v, (map.get(v) || 0) + 1); + } + return map; + }, [allRows, effectiveCountColumn, showCount]); + + const totalCount = allRows.length; + + const chipItems = useMemo(() => { + const items: { value: string; label: string; count: number }[] = []; + if (allowAll) { + items.push({ value: "", label: allLabel, count: totalCount }); + } + for (const opt of options) { + items.push({ + value: opt.value, + label: opt.label, + count: counts.get(opt.value) || 0, + }); + } + return items; + }, [options, counts, totalCount, allowAll, allLabel]); + + const showLabel = !!label; + + if (shouldHide) { + return ( +
+ + {chipCfg.hiddenMessage || "조건을 선택하면 상태별 현황이 표시됩니다"} + +
+ ); + } + + if (chipStyle === "pill") { + return ( +
+ {showLabel && ( + + {label} + + )} +
+ {chipItems.map((item) => { + const isActive = selectedValue === item.value; + return ( + + ); + })} +
+
+ ); + } + + // tab 스타일 (기본) + return ( +
+ {showLabel && ( + + {label} + + )} +
+ {chipItems.map((item) => { + const isActive = selectedValue === item.value; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx new file mode 100644 index 00000000..3b0ce864 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx @@ -0,0 +1,489 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Plus, Trash2, Loader2, AlertTriangle, RefreshCw } from "lucide-react"; +import { getTableColumns } from "@/lib/api/tableManagement"; +import { dataApi } from "@/lib/api/data"; +import type { ColumnTypeInfo } from "@/lib/api/tableManagement"; +import type { StatusBarConfig, StatusChipStyle, StatusChipOption } from "./types"; +import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types"; + +interface ConfigPanelProps { + config: StatusBarConfig | undefined; + onUpdate: (config: StatusBarConfig) => void; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; + componentId?: string; +} + +export function PopStatusBarConfigPanel({ + config: rawConfig, + onUpdate, + allComponents, + connections, + componentId, +}: ConfigPanelProps) { + const cfg = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) }; + + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + const options = cfg.options || []; + + const removeOption = (index: number) => { + update({ options: options.filter((_, i) => i !== index) }); + }; + + const updateOption = ( + index: number, + field: keyof StatusChipOption, + val: string + ) => { + update({ + options: options.map((opt, i) => + i === index ? { ...opt, [field]: val } : opt + ), + }); + }; + + // 연결된 카드 컴포넌트의 테이블 컬럼 가져오기 + const connectedTableName = useMemo(() => { + if (!componentId || !connections || !allComponents) return null; + const targetIds = connections + .filter((c) => c.sourceComponent === componentId) + .map((c) => c.targetComponent); + const sourceIds = connections + .filter((c) => c.targetComponent === componentId) + .map((c) => c.sourceComponent); + const peerIds = [...new Set([...targetIds, ...sourceIds])]; + + for (const pid of peerIds) { + const comp = allComponents.find((c) => c.id === pid); + if (!comp?.config) continue; + const compCfg = comp.config as Record; + const ds = compCfg.dataSource as { tableName?: string } | undefined; + if (ds?.tableName) return ds.tableName; + } + return null; + }, [componentId, connections, allComponents]); + + const [targetColumns, setTargetColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + // 집계 컬럼의 고유값 (옵션 선택용) + const [distinctValues, setDistinctValues] = useState([]); + const [distinctLoading, setDistinctLoading] = useState(false); + + useEffect(() => { + if (!connectedTableName) { + setTargetColumns([]); + return; + } + let cancelled = false; + setColumnsLoading(true); + getTableColumns(connectedTableName) + .then((res) => { + if (cancelled) return; + if (res.success && res.data?.columns) { + setTargetColumns(res.data.columns); + } + }) + .finally(() => { + if (!cancelled) setColumnsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [connectedTableName]); + + const fetchDistinctValues = useCallback(async (tableName: string, column: string) => { + setDistinctLoading(true); + try { + const res = await dataApi.getTableData(tableName, { page: 1, size: 9999 }); + const vals = new Set(); + for (const row of res.data) { + const v = row[column]; + if (v != null && String(v).trim() !== "") { + vals.add(String(v)); + } + } + const sorted = [...vals].sort(); + setDistinctValues(sorted); + return sorted; + } catch { + setDistinctValues([]); + return []; + } finally { + setDistinctLoading(false); + } + }, []); + + // 집계 컬럼 변경 시 고유값 새로 가져오기 + useEffect(() => { + const col = cfg.countColumn; + if (!connectedTableName || !col) { + setDistinctValues([]); + return; + } + fetchDistinctValues(connectedTableName, col); + }, [connectedTableName, cfg.countColumn, fetchDistinctValues]); + + const handleAutoFill = useCallback(async () => { + if (!connectedTableName || !cfg.countColumn) return; + const vals = await fetchDistinctValues(connectedTableName, cfg.countColumn); + if (vals.length === 0) return; + const newOptions: StatusChipOption[] = vals.map((v) => { + const existing = options.find((o) => o.value === v); + return { value: v, label: existing?.label || v }; + }); + update({ options: newOptions }); + }, [connectedTableName, cfg.countColumn, options, fetchDistinctValues]); + + const addOptionFromValue = (value: string) => { + if (options.some((o) => o.value === value)) return; + update({ + options: [...options, { value, label: value }], + }); + }; + + return ( +
+ {/* --- 칩 옵션 목록 --- */} +
+
+ + {connectedTableName && cfg.countColumn && ( + + )} +
+ {cfg.useSubCount && ( +
+ +

+ 하위 필터 자동 전환이 켜져 있으면 런타임에 가상 컬럼으로 + 집계됩니다. DB 값과 다를 수 있으니 직접 입력을 권장합니다. +

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

+ {connectedTableName && cfg.countColumn + ? "\"DB에서 자동 채우기\"를 클릭하거나 아래에서 추가하세요." + : "옵션이 없습니다. 먼저 집계 컬럼을 선택한 후 추가하세요."} +

+ )} + {options.map((opt, i) => ( +
+ updateOption(i, "value", e.target.value)} + placeholder="DB 값" + className="h-7 flex-1 text-[10px]" + /> + updateOption(i, "label", e.target.value)} + placeholder="표시 라벨" + className="h-7 flex-1 text-[10px]" + /> + +
+ ))} + + {/* 고유값에서 추가 */} + {distinctValues.length > 0 && ( +
+ +
+ {distinctValues + .filter((dv) => !options.some((o) => o.value === dv)) + .map((dv) => ( + + ))} + {distinctValues.every((dv) => options.some((o) => o.value === dv)) && ( +

모든 값이 추가되었습니다

+ )} +
+
+ )} + + {/* 수동 추가 */} + +
+ + {/* --- 전체 보기 칩 --- */} +
+
+ update({ allowAll: Boolean(checked) })} + /> + +
+

+ 필터 해제용 칩을 옵션 목록 맨 앞에 자동 추가합니다 +

+ + {cfg.allowAll !== false && ( +
+ + update({ allLabel: e.target.value })} + placeholder="전체" + className="h-7 text-[10px]" + /> +
+ )} +
+ + {/* --- 건수 표시 --- */} +
+ update({ showCount: Boolean(checked) })} + /> + +
+ + {cfg.showCount !== false && ( +
+ + {columnsLoading ? ( +
+ + 컬럼 로딩... +
+ ) : targetColumns.length > 0 ? ( + + ) : ( + update({ countColumn: e.target.value })} + placeholder="예: status" + className="h-8 text-xs" + /> + )} +

+ 연결된 카드의 이 컬럼 값으로 상태별 건수를 집계합니다 +

+
+ )} + + {cfg.showCount !== false && ( +
+
+ + update({ useSubCount: Boolean(checked) }) + } + /> + +
+ {cfg.useSubCount && ( + <> +

+ 연결된 카드의 하위 테이블 필터가 적용되면 집계 컬럼이 자동 + 전환됩니다 +

+
+ + update({ hideUntilSubFilter: Boolean(checked) }) + } + /> + +
+ {cfg.hideUntilSubFilter && ( +
+ + update({ hiddenMessage: e.target.value })} + placeholder="조건을 선택하면 상태별 현황이 표시됩니다" + className="h-7 text-[10px]" + /> +
+ )} + + )} +
+ )} + + {/* --- 칩 스타일 --- */} +
+ + +

+ 탭: 큰 숫자 + 라벨 / 알약: 작은 뱃지 형태 +

+
+ + {/* --- 필터 컬럼 --- */} +
+ + {!connectedTableName && ( +
+ +

+ 연결 탭에서 대상 카드 컴포넌트를 먼저 연결해주세요. +

+
+ )} + {connectedTableName && ( + <> + {columnsLoading ? ( +
+ + 컬럼 로딩... +
+ ) : targetColumns.length > 0 ? ( + + ) : ( + update({ filterColumn: e.target.value })} + placeholder="예: status" + className="h-8 text-xs" + /> + )} +

+ 선택한 상태 칩 값으로 카드를 필터링할 컬럼 (비어있으면 집계 + 컬럼과 동일) +

+ + )} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-status-bar/index.tsx b/frontend/lib/registry/pop-components/pop-status-bar/index.tsx new file mode 100644 index 00000000..e94f321a --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/index.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopStatusBarComponent } from "./PopStatusBarComponent"; +import { PopStatusBarConfigPanel } from "./PopStatusBarConfig"; +import type { StatusBarConfig } from "./types"; +import { DEFAULT_STATUS_BAR_CONFIG } from "./types"; + +function PopStatusBarPreviewComponent({ + config, + label, +}: { + config?: StatusBarConfig; + label?: string; +}) { + const cfg = config || DEFAULT_STATUS_BAR_CONFIG; + const options = cfg.options || []; + const displayLabel = label || "상태 바"; + + return ( +
+ + {displayLabel} + +
+ {options.length === 0 ? ( + + 옵션 없음 + + ) : ( + options.slice(0, 4).map((opt) => ( +
+ 0 + + {opt.label} + +
+ )) + )} +
+
+ ); +} + +PopComponentRegistry.registerComponent({ + id: "pop-status-bar", + name: "상태 바", + description: "상태별 건수 대시보드 + 필터", + category: "display", + icon: "BarChart3", + component: PopStatusBarComponent, + configPanel: PopStatusBarConfigPanel, + preview: PopStatusBarPreviewComponent, + defaultProps: DEFAULT_STATUS_BAR_CONFIG, + connectionMeta: { + sendable: [ + { + key: "filter_value", + label: "필터 값", + type: "filter_value", + category: "filter", + description: "선택한 상태 칩 값을 카드에 필터로 전달", + }, + ], + receivable: [ + { + key: "all_rows", + label: "전체 데이터", + type: "all_rows", + category: "data", + description: "연결된 카드의 전체 데이터를 받아 상태별 건수 집계", + }, + { + key: "set_value", + label: "값 설정", + type: "filter_value", + category: "filter", + description: "외부에서 선택 값 설정", + }, + ], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-status-bar/types.ts b/frontend/lib/registry/pop-components/pop-status-bar/types.ts new file mode 100644 index 00000000..91a37c40 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/types.ts @@ -0,0 +1,48 @@ +// ===== pop-status-bar 전용 타입 ===== +// 상태 칩 대시보드 컴포넌트. 카드 데이터를 집계하여 상태별 건수 표시 + 필터 발행. + +/** 상태 칩 표시 스타일 */ +export type StatusChipStyle = "tab" | "pill"; + +/** 개별 옵션 */ +export interface StatusChipOption { + value: string; + label: string; +} + +/** status-bar 전용 설정 */ +export interface StatusBarConfig { + showCount?: boolean; + countColumn?: string; + allowAll?: boolean; + allLabel?: string; + chipStyle?: StatusChipStyle; + /** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */ + useSubCount?: boolean; + /** 하위 필터(공정 선택 등)가 활성화되기 전까지 칩을 숨김 */ + hideUntilSubFilter?: boolean; + /** 칩 숨김 상태일 때 표시할 안내 문구 */ + hiddenMessage?: string; + + options?: StatusChipOption[]; + + /** 필터 대상 컬럼명 (기본: countColumn) */ + filterColumn?: string; + /** 추가 필터 대상 컬럼 (하위 테이블 등) */ + filterColumns?: string[]; +} + +/** 기본 설정값 */ +export const DEFAULT_STATUS_BAR_CONFIG: StatusBarConfig = { + showCount: true, + allowAll: true, + allLabel: "전체", + chipStyle: "tab", + options: [], +}; + +/** 칩 스타일 라벨 (설정 패널용) */ +export const STATUS_CHIP_STYLE_LABELS: Record = { + tab: "탭 (큰 숫자)", + pill: "알약 (작은 뱃지)", +}; From cae1622ac2ce284007ab0a4e86b5ffa606aff73e Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 11 Mar 2026 16:41:18 +0900 Subject: [PATCH 15/16] =?UTF-8?q?fix(pop):=20pop-status-bar=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=20=ED=8C=94=EB=A0=88=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EB=93=B1=EB=A1=9D=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=88=98=EC=A0=95=20pop-status-bar=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EA=B0=80=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=97=90=20=ED=91=9C=EC=8B=9C=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4.=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=88=EC=98=A8,=20=ED=8C=94=EB=A0=88=ED=8A=B8,=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8,=20=EA=B8=B0=EB=B3=B8=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=ED=81=AC=EA=B8=B0=204=EA=B3=B3=EC=97=90=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20-=20pop-layout.ts:=20PopComponentType=EC=97=90=20"?= =?UTF-8?q?pop-status-bar"=20=EC=B6=94=EA=B0=80,=20=20=20DEFAULT=5FCOMPONE?= =?UTF-8?q?NT=5FGRID=5FSIZE=EC=97=90=206=EC=B9=B8x1=ED=96=89=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=ED=81=AC=EA=B8=B0=20=EC=B6=94=EA=B0=80=20-=20Compo?= =?UTF-8?q?nentPalette.tsx:=20PALETTE=5FITEMS=EC=97=90=20"=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B0=94"=20=ED=95=AD=EB=AA=A9=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20PopRenderer.tsx:=20COMPONENT=5FTYPE=5FLABELS?= =?UTF-8?q?=EC=97=90=20"=EC=83=81=ED=83=9C=20=EB=B0=94"=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20ComponentEditorPanel.tsx:=20COMPONENT=5FTYPE=5F?= =?UTF-8?q?LABELS=EC=97=90=20"=EC=83=81=ED=83=9C=20=EB=B0=94"=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop/designer/panels/ComponentEditorPanel.tsx | 1 + .../components/pop/designer/panels/ComponentPalette.tsx | 8 +++++++- .../components/pop/designer/renderers/PopRenderer.tsx | 1 + frontend/components/pop/designer/types/pop-layout.ts | 3 ++- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index a58a6d31..c51f3db2 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -74,6 +74,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-button": "버튼", "pop-string-list": "리스트 목록", "pop-search": "검색", + "pop-status-bar": "상태 바", "pop-list": "리스트", "pop-indicator": "인디케이터", "pop-scanner": "스캐너", diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 9744075f..4e048692 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -69,6 +69,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: Search, description: "조건 입력 (텍스트/날짜/선택/모달)", }, + { + type: "pop-status-bar", + label: "상태 바", + icon: BarChart2, + description: "상태별 건수 대시보드 + 필터", + }, { type: "pop-field", label: "입력 필드", diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 9ff1aeee..241374d7 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -76,6 +76,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-button": "버튼", "pop-string-list": "리스트 목록", "pop-search": "검색", + "pop-status-bar": "상태 바", "pop-field": "입력", "pop-scanner": "스캐너", "pop-profile": "프로필", diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 95c4011b..9fb9a847 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field" | "pop-scanner" | "pop-profile"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile"; /** * 데이터 흐름 정의 @@ -363,6 +363,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record Date: Wed, 11 Mar 2026 18:20:02 +0900 Subject: [PATCH 16/16] =?UTF-8?q?feat(pop):=20=EB=8B=A4=EC=A4=91=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=EC=B2=B4=EC=9D=B4=EB=8B=9D=20+=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20+=20=EC=B9=B4=EB=93=9C=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20+=20=ED=95=84=ED=84=B0=20=EC=A0=84=20?= =?UTF-8?q?=EB=B9=84=ED=91=9C=EC=8B=9C=20=EB=B2=84=ED=8A=BC=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=ED=95=98=EB=82=98=EC=97=90=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=EC=9D=84=20=EC=88=9C=EC=B0=A8=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=ED=95=98=EB=8A=94=20=EB=8B=A4=EC=A4=91=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EC=B2=B4=EC=9D=B4=EB=8B=9D,=20DB=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EC=84=A0=ED=83=9D=EC=9C=BC=EB=A1=9C=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EB=B3=80=EA=B2=BD=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5,=20=EC=B9=B4=EB=93=9C=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=8B=9C=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=97=B4=EA=B8=B0,=20=ED=95=84=ED=84=B0=20=EC=A0=84=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B9=84=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20[=EB=8B=A4=EC=A4=91=20=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EC=B2=B4=EC=9D=B4=EB=8B=9D]=20-=20types.ts:=20ActionButtonDef.?= =?UTF-8?q?clickActions=20=EB=B0=B0=EC=97=B4=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?=ED=95=98=EC=9C=84=ED=98=B8=ED=99=98=20=EC=9C=A0=EC=A7=80)=20-?= =?UTF-8?q?=20PopCardListV2Config:=20=EC=95=A1=EC=85=98=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20UI=20(=EC=B6=94=EA=B0=80/=EC=82=AD=EC=A0=9C/?= =?UTF-8?q?=EC=88=9C=EC=84=9C)=20-=20cell-renderers:=20=5F=5FallActions=20?= =?UTF-8?q?=EB=B0=B0=EC=97=B4=EB=A1=9C=20config=20=EC=A0=84=EB=8B=AC=20-?= =?UTF-8?q?=20PopCardListV2Component:=20actionsToRun=20=EC=88=9C=EC=B0=A8?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89,=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=82=B5=20[=EC=99=B8=EB=B6=80=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=84=A0=ED=83=9D]=20-=20ActionButtonClickAction.j?= =?UTF-8?q?oinConfig=20(sourceColumn,=20targetColumn)=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20-=20ImmediateActionEditor:=20"DB=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=84=A0=ED=83=9D..."=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20+=20=EC=A1=B0=EC=9D=B8=ED=82=A4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20UI=20-=20DbTableCombobox:=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EB=AA=85(=EC=98=81=EC=96=B4)+=EC=84=A4=EB=AA=85(?= =?UTF-8?q?=ED=95=9C=EA=B8=80)=20=EA=B2=80=EC=83=89=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=20-=20Component:=20joinConfig=20=EA=B8=B0=EB=B0=98=20lookupVal?= =?UTF-8?q?ue/lookupColumn=20=EC=B2=98=EB=A6=AC=20[=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EB=AA=A8=EB=8B=AC]=20-=20types.ts:=20V2Ca?= =?UTF-8?q?rdClickAction=EC=97=90=20"modal-open",=20V2CardClickModalConfig?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20-=20PopCardListV2Config:=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=ED=83=AD=EC=97=90=20=EB=AA=A8=EB=8B=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(=ED=99=94=EB=A9=B4=20ID,=20=EC=A1=B0=EA=B1=B4,=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9)=20-=20PopCardListV2Component:=20handleCardS?= =?UTF-8?q?elect=20=EC=A1=B0=EA=B1=B4=20=EC=B2=B4=ED=81=AC=20=ED=9B=84=20o?= =?UTF-8?q?penPopModal=20[=ED=95=84=ED=84=B0=20=EC=A0=84=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B9=84=ED=91=9C=EC=8B=9C]=20-=20PopCard?= =?UTF-8?q?ListV2Config.hideUntilFiltered=20Switch=20-=20Component:=20exte?= =?UTF-8?q?rnalFilters=20=EC=97=86=EC=9D=84=20=EB=95=8C=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=A9=94=EC=8B=9C=EC=A7=80=20[=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95]=20-=20availableTableOptions:=20dataSourc?= =?UTF-8?q?e.table=20->=20dataSource.tableName=20=EC=88=98=EC=A0=95=20-=20?= =?UTF-8?q?popActionRoutes:=20INSERT=20=EC=8B=9C=20created=5Fdate/updated?= =?UTF-8?q?=5Fdate/writer=20=EC=9E=90=EB=8F=99=20=EC=B6=94=EA=B0=80,=20=20?= =?UTF-8?q?=20UPDATE=20=EC=8B=9C=20updated=5Fdate=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20[=EC=95=A1=EC=85=98=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0]=20-=20evaluateShowC?= =?UTF-8?q?ondition:=20=EB=B2=84=ED=8A=BC=EB=B3=84=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=ED=8F=89=EA=B0=80=20(visible/disabled/hidden)=20-=20ActionButt?= =?UTF-8?q?onsEditor:=20=EC=95=84=EC=BD=94=EB=94=94=EC=96=B8=20UI=20+=20se?= =?UTF-8?q?ssionStorage=20=EC=83=81=ED=83=9C=20=EC=9C=A0=EC=A7=80=20-=201?= =?UTF-8?q?=EC=85=80=201=EB=B2=84=ED=8A=BC=20=EB=A0=8C=EB=8D=94=EB=A7=81:?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=EB=A7=9E=EB=8A=94=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=201=EA=B0=9C=EB=A7=8C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/popActionRoutes.ts | 54 +- .../PopCardListV2Component.tsx | 167 +-- .../pop-card-list-v2/PopCardListV2Config.tsx | 1044 ++++++++++++++--- .../pop-card-list-v2/cell-renderers.tsx | 94 +- frontend/lib/registry/pop-components/types.ts | 96 +- 5 files changed, 1188 insertions(+), 267 deletions(-) diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 4ff425e3..d25c6bdc 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -273,6 +273,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); await client.query( @@ -320,8 +333,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp for (let i = 0; i < lookupValues.length; i++) { const item = items[i] ?? {}; const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); + const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`, [resolved, companyCode, lookupValues[i]], ); processedCount++; @@ -339,9 +353,10 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`; + const autoUpdatedDb = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", "); await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql}${autoUpdatedDb} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, [thenVal, elseVal, companyCode, ...lookupValues], ); processedCount += lookupValues.length; @@ -376,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp setSql = `"${task.targetColumn}" = $1`; } + const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`, [value, companyCode, lookupValues[i]], ); processedCount++; @@ -578,6 +594,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -611,6 +640,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(fieldValues[sourceField] ?? null); } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -662,16 +704,18 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } if (valueType === "fixed") { + const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", "); - const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`; + const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd} WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`; await client.query(sql, [fixedValue, companyCode, ...lookupValues]); processedCount += lookupValues.length; } else { for (let i = 0; i < lookupValues.length; i++) { const item = items[i] ?? {}; const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item); + const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd2} WHERE company_code = $2 AND "${pkColumn}" = $3`, [resolvedValue, companyCode, lookupValues[i]] ); processedCount++; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 5a424d4e..55829efb 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -33,6 +33,7 @@ import type { TimelineProcessStep, TimelineDataSource, ActionButtonUpdate, + ActionButtonClickAction, StatusValueMapping, SelectModeConfig, SelectModeButtonConfig, @@ -206,11 +207,6 @@ export function PopCardListV2Component({ }); }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]); - const handleCardSelect = useCallback((row: RowData) => { - if (!componentId) return; - publish(`__comp_output__${componentId}__selected_row`, row); - }, [componentId, publish]); - // ===== 선택 모드 ===== const [selectMode, setSelectMode] = useState(false); const [selectModeStatus, setSelectModeStatus] = useState(""); @@ -245,6 +241,26 @@ export function PopCardListV2Component({ } }, []); + const handleCardSelect = useCallback((row: RowData) => { + + if (effectiveConfig?.cardClickAction === "modal-open" && effectiveConfig?.cardClickModalConfig?.screenId) { + const mc = effectiveConfig.cardClickModalConfig; + if (mc.condition && mc.condition.type !== "always") { + const processFlow = row.__processFlow__ as { isCurrent: boolean; status?: string }[] | undefined; + const currentProcess = processFlow?.find((s) => s.isCurrent); + if (mc.condition.type === "timeline-status") { + if (currentProcess?.status !== mc.condition.value) return; + } else if (mc.condition.type === "column-value") { + if (String(row[mc.condition.column || ""] ?? "") !== mc.condition.value) return; + } + } + openPopModal(mc.screenId, row); + return; + } + if (!componentId) return; + publish(`__comp_output__${componentId}__selected_row`, row); + }, [componentId, publish, effectiveConfig, openPopModal]); + const enterSelectMode = useCallback((whenStatus: string, buttonConfig: Record) => { const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined; if (!smConfig) return; @@ -931,6 +947,10 @@ export function PopCardListV2Component({

데이터 소스를 설정해주세요.

+ ) : effectiveConfig?.hideUntilFiltered && externalFilters.size === 0 ? ( +
+

필터를 선택하면 데이터가 표시됩니다.

+
) : loading ? (
@@ -1077,7 +1097,7 @@ export function PopCardListV2Component({ )} - {/* POP 화면 모달 */} + {/* POP 화면 모달 (풀스크린) */} { setPopModalOpen(open); if (!open) { @@ -1085,17 +1105,17 @@ export function PopCardListV2Component({ setPopModalRow(null); } }}> - - - 상세 작업 + + + {effectiveConfig?.cardClickModalConfig?.modalTitle || "상세 작업"} -
+
{popModalLayout && ( )}
@@ -1326,71 +1346,74 @@ function CardV2({ onCartCancel: handleCartCancel, onEnterSelectMode, onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => { - const cfg = buttonConfig as { - updates?: ActionButtonUpdate[]; - targetTable?: string; - confirmMessage?: string; - __processId?: string | number; - } | undefined; + const cfg = buttonConfig as Record | undefined; + const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || []; + const processId = cfg?.__processId as string | number | undefined; - if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) { - if (cfg.confirmMessage) { - if (!window.confirm(cfg.confirmMessage)) return; + // 단일 액션 폴백 (기존 구조 호환) + const actionsToRun = allActions.length > 0 + ? allActions + : cfg?.type + ? [cfg as unknown as ActionButtonClickAction] + : []; + + if (actionsToRun.length === 0) { + if (parentComponentId) { + publish(`__comp_output__${parentComponentId}__action`, { taskPreset, row: actionRow }); } - try { - // 공정 테이블 대상이면 processId 우선 사용 - const rowId = cfg.__processId ?? actionRow.id ?? actionRow.pk; - if (!rowId) { - toast.error("대상 레코드의 ID를 찾을 수 없습니다."); + return; + } + + for (const action of actionsToRun) { + if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { + if (action.confirmMessage) { + if (!window.confirm(action.confirmMessage)) return; + } + try { + const rowId = processId ?? actionRow.id ?? actionRow.pk; + if (!rowId) { toast.error("대상 레코드의 ID를 찾을 수 없습니다."); return; } + const lookupValue = action.joinConfig + ? String(actionRow[action.joinConfig.sourceColumn] ?? rowId) + : rowId; + const lookupColumn = action.joinConfig?.targetColumn || "id"; + const tasks = action.updates.map((u, idx) => ({ + id: `btn-update-${idx}`, + type: "data-update" as const, + targetTable: action.targetTable!, + targetColumn: u.column, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: u.valueType === "static" ? (u.value ?? "") : + u.valueType === "currentUser" ? "__CURRENT_USER__" : + u.valueType === "currentTime" ? "__CURRENT_TIME__" : + u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") : + (u.value ?? ""), + lookupMode: "manual" as const, + manualItemField: lookupColumn, + manualPkColumn: lookupColumn, + })); + const targetRow = action.joinConfig + ? { ...actionRow, [lookupColumn]: lookupValue } + : processId ? { ...actionRow, id: processId } : actionRow; + const result = await apiClient.post("/pop/execute-action", { + tasks, + data: { items: [targetRow], fieldValues: {} }, + mappings: {}, + }); + if (result.data?.success) { + toast.success(result.data.message || "처리 완료"); + onRefresh?.(); + } else { + toast.error(result.data?.message || "처리 실패"); + return; + } + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); return; } - const tasks = cfg.updates.map((u, idx) => ({ - id: `btn-update-${idx}`, - type: "data-update" as const, - targetTable: cfg.targetTable!, - targetColumn: u.column, - operationType: "assign" as const, - valueSource: "fixed" as const, - fixedValue: u.valueType === "static" ? (u.value ?? "") : - u.valueType === "currentUser" ? "__CURRENT_USER__" : - u.valueType === "currentTime" ? "__CURRENT_TIME__" : - u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") : - (u.value ?? ""), - lookupMode: "manual" as const, - manualItemField: "id", - manualPkColumn: "id", - })); - const targetRow = cfg.__processId - ? { ...actionRow, id: cfg.__processId } - : actionRow; - const result = await apiClient.post("/pop/execute-action", { - tasks, - data: { items: [targetRow], fieldValues: {} }, - mappings: {}, - }); - if (result.data?.success) { - toast.success(result.data.message || "처리 완료"); - onRefresh?.(); - } else { - toast.error(result.data?.message || "처리 실패"); - } - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } else if (action.type === "modal-open" && action.modalScreenId) { + onOpenPopModal?.(action.modalScreenId, actionRow); } - return; - } - - const actionCfg = buttonConfig as { type?: string; modalScreenId?: string } | undefined; - if (actionCfg?.type === "modal-open" && actionCfg.modalScreenId) { - onOpenPopModal?.(actionCfg.modalScreenId, actionRow); - return; - } - - if (parentComponentId) { - publish(`__comp_output__${parentComponentId}__action`, { - taskPreset, - row: actionRow, - }); } }, packageEntries, diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx index 4fee23ba..79d8a31e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -8,7 +8,7 @@ * 탭 3: 동작 — 카드 선택 동작, 오버플로우, 카트 */ -import { useState, useEffect, useRef, useCallback, Fragment } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -16,11 +16,13 @@ import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Check, ChevronsUpDown, Plus, Minus, Trash2 } from "lucide-react"; +import { Check, ChevronsUpDown, Plus, Minus, Trash2, ChevronDown, ChevronRight } from "lucide-react"; import { Popover, PopoverContent, @@ -45,10 +47,15 @@ import type { CardSortConfig, V2OverflowConfig, V2CardClickAction, + V2CardClickModalConfig, ActionButtonUpdate, TimelineDataSource, StatusValueMapping, TimelineStatusSemantic, + SelectModeButtonConfig, + ActionButtonDef, + ActionButtonShowCondition, + ActionButtonClickAction, } from "../types"; import type { ButtonVariant } from "../pop-button"; import { @@ -1269,6 +1276,7 @@ function TabCardDesign({ columns={columns} selectedColumns={selectedColumns} tables={tables} + dataSource={cfg.dataSource} onUpdate={(partial) => updateCell(selectedCell.id, partial)} onRemove={() => removeCell(selectedCell.id)} /> @@ -1286,6 +1294,7 @@ function CellDetailEditor({ columns, selectedColumns, tables, + dataSource, onUpdate, onRemove, }: { @@ -1295,9 +1304,31 @@ function CellDetailEditor({ columns: ColumnInfo[]; selectedColumns: string[]; tables: TableInfo[]; + dataSource: CardListDataSource; onUpdate: (partial: Partial) => void; onRemove: () => void; }) { + const availableTableOptions = useMemo(() => { + const opts: { value: string; label: string }[] = []; + if (dataSource.tableName) { + opts.push({ value: dataSource.tableName, label: `${dataSource.tableName} (메인)` }); + } + for (const j of dataSource.joins || []) { + if (j.targetTable) { + opts.push({ value: j.targetTable, label: `${j.targetTable} (조인)` }); + } + } + const added = new Set(opts.map((o) => o.value)); + for (const c of allCells) { + const pt = c.timelineSource?.processTable; + if (pt && !added.has(pt)) { + opts.push({ value: pt, label: `${pt} (타임라인)` }); + added.add(pt); + } + } + return opts; + }, [dataSource, allCells]); + return (
@@ -1312,17 +1343,19 @@ function CellDetailEditor({ {/* 컬럼 + 타입 */}
- + {cell.type !== "action-buttons" && ( + + )} updateRule(ri, { whenStatus: e.target.value })} placeholder="상태값 (예: waiting)" className="h-6 flex-1 text-[10px]" /> - -
- {rule.buttons.map((btn, bi) => { - const btnKey = `${ri}-${bi}`; - const isExpanded = expandedBtn === btnKey; + {buttons.length === 0 && ( +

버튼 규칙을 추가하세요. 상태별로 다른 버튼을 설정할 수 있습니다.

+ )} - return ( -
-
- updateButton(ri, bi, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> - updateBtn(bi, { label: e.target.value })} + onClick={(e) => e.stopPropagation()} + placeholder="라벨" + className="h-6 flex-1 text-[10px]" + /> + - - + + ) : ( + + {btn.label || "(미입력)"} | {getCondSummary(btn)} | {actionSummary} + + )} + +
+ + {/* 펼쳐진 상세 */} + {isExpanded && ( +
+ {/* === 조건 섹션 === */} +
toggleSection(`${bi}-cond`)} + > + {isSectionOpen(`${bi}-cond`) + ? + : } + 조건 + {!isSectionOpen(`${bi}-cond`) && ( + {getCondSummary(btn)} + )}
- - {isExpanded && ( -
- 클릭 시 동작 - + {isSectionOpen(`${bi}-cond`) && ( +
- 대상 테이블 - updateButton(ri, bi, { targetTable: e.target.value })} - placeholder="work_order_process" - className="h-6 flex-1 text-[10px]" - /> -
- -
- 확인 메시지 - updateButton(ri, bi, { confirmMessage: e.target.value })} - placeholder="접수하시겠습니까?" - className="h-6 flex-1 text-[10px]" - /> -
- -
- 변경할 컬럼 - -
- - {(btn.updates || []).map((u, ui) => ( -
- updateCondition(bi, { type: v as ActionButtonShowCondition["type"] })}> + + + 항상 + 타임라인 + 카드 컬럼 + + + {condType === "timeline-status" && ( + - updateCondition(bi, { column: v === "__none__" ? "" : v })} + > + + + 선택 + {allColumnOptions.map((o) => ( + {o.label} + ))} + + + updateCondition(bi, { value: e.target.value })} + placeholder="값" + className="h-6 w-20 text-[10px]" + /> + + )} +
+ {condType !== "always" && ( +
+ 그 외 + - {(u.valueType === "static" || u.valueType === "columnRef") && ( - updateUpdateEntry(ri, bi, ui, { value: e.target.value })} - placeholder={u.valueType === "static" ? "값" : "컬럼명"} - className="h-6 flex-1 text-[10px]" - /> - )} - + + {(btn.showCondition?.unmatchBehavior || "hidden") === "disabled" + ? "보이지만 클릭 불가" + : "버튼 안 보임"} +
- ))} - - {(!btn.updates || btn.updates.length === 0) && ( -

변경 항목을 추가하면 버튼 클릭 시 DB가 변경됩니다.

)}
)} -
- ); - })} - + )} +
+ + {aType === "immediate" && ( + addActionUpdate(bi, ai)} + onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)} + onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)} + onUpdateAction={(p) => updateAction(bi, ai, p)} + /> + )} + + {aType === "select-mode" && ( +
+
+ 로직 순서 + +
+ {(action.selectModeButtons || []).map((smBtn, si) => ( +
+
+ updateSelectModeBtn(bi, ai, si, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> + + +
+
+ 동작 + +
+ {smBtn.clickMode === "status-change" && ( + addSmBtnUpdate(bi, ai, si)} + onUpdateUpdate={(ui, p) => updateSmBtnUpdate(bi, ai, si, ui, p)} + onRemoveUpdate={(ui) => removeSmBtnUpdate(bi, ai, si, ui)} + onUpdateAction={(p) => updateSelectModeBtn(bi, ai, si, { targetTable: p.targetTable ?? smBtn.targetTable, confirmMessage: p.confirmMessage ?? smBtn.confirmMessage })} + /> + )} + {smBtn.clickMode === "modal-open" && ( +
+ POP 화면 + updateSelectModeBtn(bi, ai, si, { modalScreenId: e.target.value })} + placeholder="화면 ID (예: 4481)" + className="h-6 flex-1 text-[10px]" + /> +
+ )} +
+ ))} + {(!action.selectModeButtons || action.selectModeButtons.length === 0) && ( +

로직 순서를 추가하세요.

+ )} +
+ )} + + {aType === "modal-open" && ( +
+ POP 화면 + updateAction(bi, ai, { modalScreenId: e.target.value })} + placeholder="화면 ID (예: 4481)" + className="h-6 flex-1 text-[10px]" + /> +
+ )} +
+ ); + })} + +
+ )} +
+ )} +
+ ); + })} +
+ ); +} + +const SYSTEM_COLUMNS = new Set([ + "id", "company_code", "created_date", "updated_date", "writer", +]); + +function ImmediateActionEditor({ + action, + allColumnOptions, + availableTableOptions, + onAddUpdate, + onUpdateUpdate, + onRemoveUpdate, + onUpdateAction, +}: { + action: ActionButtonClickAction; + allColumnOptions: { value: string; label: string }[]; + availableTableOptions: { value: string; label: string }[]; + onAddUpdate: () => void; + onUpdateUpdate: (uIdx: number, partial: Partial) => void; + onRemoveUpdate: (uIdx: number) => void; + onUpdateAction: (partial: Partial) => void; +}) { + const isExternalTable = action.targetTable && !availableTableOptions.some((t) => t.value === action.targetTable); + const [dbSelectMode, setDbSelectMode] = useState(!!isExternalTable); + const [allTables, setAllTables] = useState([]); + + const [tableColumnGroups, setTableColumnGroups] = useState< + { table: string; label: string; business: { value: string; label: string }[]; system: { value: string; label: string }[] }[] + >([]); + + // 외부 DB 모드 시 전체 테이블 로드 + useEffect(() => { + if (dbSelectMode && allTables.length === 0) { + fetchTableList().then(setAllTables).catch(() => setAllTables([])); + } + }, [dbSelectMode, allTables.length]); + + // 선택된 테이블 컬럼 로드 (카드 소스 + 외부 공통) + const effectiveTableOptions = useMemo(() => { + if (dbSelectMode && action.targetTable) { + const existing = availableTableOptions.find((t) => t.value === action.targetTable); + if (!existing) return [...availableTableOptions, { value: action.targetTable, label: `${action.targetTable} (외부)` }]; + } + return availableTableOptions; + }, [availableTableOptions, dbSelectMode, action.targetTable]); + + useEffect(() => { + let cancelled = false; + const loadAll = async () => { + const groups: typeof tableColumnGroups = []; + for (const t of effectiveTableOptions) { + try { + const cols = await fetchTableColumns(t.value); + const mapped = cols.map((c) => ({ value: c.name, label: c.name })); + groups.push({ + table: t.value, + label: t.label, + business: mapped.filter((c) => !SYSTEM_COLUMNS.has(c.value)), + system: mapped.filter((c) => SYSTEM_COLUMNS.has(c.value)), + }); + } catch { + groups.push({ table: t.value, label: t.label, business: [], system: [] }); + } + } + if (!cancelled) setTableColumnGroups(groups); + }; + if (effectiveTableOptions.length > 0) loadAll(); + else setTableColumnGroups([]); + return () => { cancelled = true; }; + }, [effectiveTableOptions]); + + const selectedGroup = tableColumnGroups.find((g) => g.table === action.targetTable); + const businessCols = selectedGroup?.business || []; + const systemCols = selectedGroup?.system || []; + const tableName = action.targetTable?.trim() || ""; + + // 메인 테이블 컬럼 (조인키 소스 컬럼 선택 용도) + const mainTableGroup = tableColumnGroups.find((g) => availableTableOptions[0]?.value === g.table); + const mainCols = mainTableGroup ? [...mainTableGroup.business, ...mainTableGroup.system] : []; + + return ( +
+ {/* 대상 테이블 */} +
+ 대상 테이블 + {!dbSelectMode ? ( + + ) : ( +
+ onUpdateAction({ targetTable: v })} + /> + +
+ )} +
+ + {/* 외부 DB 선택 시 조인키 설정 */} + {dbSelectMode && action.targetTable && ( + <> +
+ 기준 컬럼 + +
+
+ 매칭 컬럼 + +
+

+ 메인.기준컬럼 = 외부.매칭컬럼 으로 연결하여 업데이트 +

+ + )} + +
+ 확인 메시지 + onUpdateAction({ confirmMessage: e.target.value })} + placeholder="처리하시겠습니까?" + className="h-6 flex-1 text-[10px]" + /> +
+
+ + 변경할 컬럼{tableName ? ` (${tableName})` : ""} + + +
+ {(action.updates || []).map((u, ui) => ( +
+ + + {(u.valueType === "static" || u.valueType === "columnRef") && ( + onUpdateUpdate(ui, { value: e.target.value })} + placeholder={u.valueType === "static" ? "값" : "컬럼명"} + className="h-6 flex-1 text-[10px]" + /> + )} +
))} + {(!action.updates || action.updates.length === 0) && ( +

변경 항목을 추가하면 클릭 시 DB가 변경됩니다.

+ )}
); } + +// ===== DB 테이블 검색 Combobox ===== + +function DbTableCombobox({ + value, + tables, + onSelect, +}: { + value: string; + tables: TableInfo[]; + onSelect: (tableName: string) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search) return tables; + const q = search.toLowerCase(); + return tables.filter( + (t) => + t.tableName.toLowerCase().includes(q) || + (t.tableComment || "").toLowerCase().includes(q), + ); + }, [tables, search]); + + const selectedLabel = useMemo(() => { + if (!value) return "DB 테이블 검색..."; + const found = tables.find((t) => t.tableName === value); + return found ? `${found.tableName}${found.tableComment ? ` (${found.tableComment})` : ""}` : value; + }, [value, tables]); + + return ( + + + + + + + + + + 검색 결과가 없습니다. + + + {filtered.map((t) => ( + { + onSelect(t.tableName); + setOpen(false); + setSearch(""); + }} + className="text-[10px]" + > + + {t.tableName} + {t.tableComment && ( + ({t.tableComment}) + )} + + ))} + + + + + + ); +} + // ===== 하단 상태 에디터 ===== function FooterStatusEditor({ @@ -2119,6 +2747,7 @@ function TabActions({ }) { const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; const clickAction = cfg.cardClickAction || "none"; + const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; return (
@@ -2126,7 +2755,7 @@ function TabActions({
- {(["none", "publish", "navigate"] as V2CardClickAction[]).map((action) => ( + {(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => ( ))}
+ {clickAction === "modal-open" && ( +
+
+ POP 화면 ID + onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} + placeholder="화면 ID (예: 4481)" + className="h-7 flex-1 text-[10px]" + /> +
+
+ 모달 제목 + onUpdate({ cardClickModalConfig: { ...modalConfig, modalTitle: e.target.value } })} + placeholder="비우면 '상세 작업' 표시" + className="h-7 flex-1 text-[10px]" + /> +
+
+ 조건 + +
+ {modalConfig.condition?.type === "timeline-status" && ( +
+ 상태 값 + onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })} + placeholder="예: in_progress" + className="h-7 flex-1 text-[10px]" + /> +
+ )} + {modalConfig.condition?.type === "column-value" && ( + <> +
+ 컬럼 + onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, column: e.target.value } } })} + placeholder="컬럼명" + className="h-7 flex-1 text-[10px]" + /> +
+
+ + onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })} + placeholder="값" + className="h-7 flex-1 text-[10px]" + /> +
+ + )} +
+ )}
+ {/* 필터 전 비표시 */} +
+ + onUpdate({ hideUntilFiltered: checked })} + /> +
+ {cfg.hideUntilFiltered && ( +

+ 연결된 컴포넌트에서 필터 값이 전달되기 전까지 데이터를 표시하지 않습니다. +

+ )} + {/* 스크롤 방향 */}
diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index 5cc9afb3..f1863b13 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -18,7 +18,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; -import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep } from "../types"; +import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep, ActionButtonDef } from "../types"; import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types"; import type { ButtonVariant } from "../pop-button"; @@ -67,6 +67,7 @@ export interface CellRendererProps { onCartCancel?: () => void; onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void; onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record) => void; + onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; packageEntries?: PackageEntry[]; inputUnit?: string; } @@ -591,23 +592,86 @@ function TimelineCell({ cell, row }: CellRendererProps) { // ===== 11. action-buttons ===== -function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps) { +function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" { + const cond = btn.showCondition; + if (!cond || cond.type === "always") return "visible"; + + let matched = false; + + if (cond.type === "timeline-status") { + const subStatus = row[VIRTUAL_SUB_STATUS]; + matched = subStatus !== undefined && String(subStatus) === cond.value; + } else if (cond.type === "column-value" && cond.column) { + matched = String(row[cond.column] ?? "") === (cond.value ?? ""); + } else { + return "visible"; + } + + if (matched) return "visible"; + return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden"; +} + +function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) { + const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; + const currentProcess = processFlow?.find((s) => s.isCurrent); + const currentProcessId = currentProcess?.processId; + + if (cell.actionButtons && cell.actionButtons.length > 0) { + const evaluated = cell.actionButtons.map((btn) => ({ + btn, + state: evaluateShowCondition(btn, row), + })); + + const activeBtn = evaluated.find((e) => e.state === "visible"); + const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled"); + const pick = activeBtn || disabledBtn; + if (!pick) return null; + + const { btn, state } = pick; + + return ( +
+ +
+ ); + } + + // 기존 구조 (actionRules) 폴백 const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined; const statusValue = hasSubStatus ? String(row[VIRTUAL_SUB_STATUS] || "") : (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : "")); const rules = cell.actionRules || []; - const matchedRule = rules.find((r) => r.whenStatus === statusValue); - - if (!matchedRule) { - return null; - } - - // __processFlow__에서 isCurrent 공정의 processId 추출 - const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; - const currentProcess = processFlow?.find((s) => s.isCurrent); - const currentProcessId = currentProcess?.processId; + if (!matchedRule) return null; return (
@@ -620,8 +684,10 @@ function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps onClick={(e) => { e.stopPropagation(); const config = { ...(btn as Record) }; - if (currentProcessId !== undefined) { - config.__processId = currentProcessId; + if (currentProcessId !== undefined) config.__processId = currentProcessId; + if (btn.clickMode === "select-mode" && onEnterSelectMode) { + onEnterSelectMode(matchedRule.whenStatus, config); + return; } onActionButtonClick?.(btn.taskPreset, row, config); }} diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index e883202f..3b7ff73e 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -833,19 +833,12 @@ export interface CardCellDefinitionV2 { timelinePriority?: "before" | "after"; showDetailModal?: boolean; - // action-buttons 타입 전용 + // action-buttons 타입 전용 (신규: 버튼 중심 구조) + actionButtons?: ActionButtonDef[]; + // action-buttons 타입 전용 (구: 조건 중심 구조, 하위호환) actionRules?: Array<{ whenStatus: string; - buttons: Array<{ - label: string; - variant: ButtonVariant; - taskPreset: string; - confirm?: ConfirmConfig; - targetTable?: string; - confirmMessage?: string; - allowMultiSelect?: boolean; - updates?: ActionButtonUpdate[]; - }>; + buttons: Array; }>; // footer-status 타입 전용 @@ -861,6 +854,72 @@ export interface ActionButtonUpdate { valueType: "static" | "currentUser" | "currentTime" | "columnRef"; } +// 액션 버튼 클릭 시 동작 모드 +export type ActionButtonClickMode = "status-change" | "modal-open" | "select-mode"; + +// 액션 버튼 개별 설정 +export interface ActionButtonConfig { + label: string; + variant: ButtonVariant; + taskPreset: string; + confirm?: ConfirmConfig; + targetTable?: string; + confirmMessage?: string; + allowMultiSelect?: boolean; + updates?: ActionButtonUpdate[]; + clickMode?: ActionButtonClickMode; + selectModeConfig?: SelectModeConfig; +} + +// 선택 모드 설정 +export interface SelectModeConfig { + filterStatus?: string; + buttons: Array; +} + +// 선택 모드 하단 버튼 설정 +export interface SelectModeButtonConfig { + label: string; + variant: ButtonVariant; + clickMode: "status-change" | "modal-open" | "cancel-select"; + targetTable?: string; + updates?: ActionButtonUpdate[]; + confirmMessage?: string; + modalScreenId?: string; +} + +// ===== 버튼 중심 구조 (신규) ===== + +export interface ActionButtonShowCondition { + type: "timeline-status" | "column-value" | "always"; + value?: string; + column?: string; + unmatchBehavior?: "hidden" | "disabled"; +} + +export interface ActionButtonClickAction { + type: "immediate" | "select-mode" | "modal-open"; + targetTable?: string; + updates?: ActionButtonUpdate[]; + confirmMessage?: string; + selectModeButtons?: SelectModeButtonConfig[]; + modalScreenId?: string; + // 외부 테이블 조인 설정 (DB 직접 선택 시) + joinConfig?: { + sourceColumn: string; // 메인 테이블의 FK 컬럼 + targetColumn: string; // 외부 테이블의 매칭 컬럼 + }; +} + +export interface ActionButtonDef { + label: string; + variant: ButtonVariant; + showCondition?: ActionButtonShowCondition; + /** 단일 액션 (하위호환) 또는 다중 액션 체이닝 */ + clickAction: ActionButtonClickAction; + clickActions?: ActionButtonClickAction[]; +} + export interface CardGridConfigV2 { rows: number; cols: number; @@ -873,7 +932,17 @@ export interface CardGridConfigV2 { // ----- V2 카드 선택 동작 ----- -export type V2CardClickAction = "none" | "publish" | "navigate"; +export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open"; + +export interface V2CardClickModalConfig { + screenId: string; + modalTitle?: string; + condition?: { + type: "timeline-status" | "column-value" | "always"; + value?: string; + column?: string; + }; +} // ----- V2 오버플로우 설정 ----- @@ -898,6 +967,9 @@ export interface PopCardListV2Config { cardGap?: number; overflow?: V2OverflowConfig; cardClickAction?: V2CardClickAction; + cardClickModalConfig?: V2CardClickModalConfig; + /** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */ + hideUntilFiltered?: boolean; responsiveDisplay?: CardResponsiveConfig; inputField?: CardInputFieldConfig; packageConfig?: CardPackageConfig;