From 47384e1c2ba934b61b51eb138d3c9ee9f045a9f9 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Mar 2026 11:00:31 +0900 Subject: [PATCH 01/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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 2406052742d7d0b77db974d990cef9729ad33fad Mon Sep 17 00:00:00 2001 From: syc0123 Date: Wed, 11 Mar 2026 10:15:17 +0900 Subject: [PATCH 13/25] refactor: Enhance rack structure component with format configuration and segment handling - Introduced `FormatSegment` and `LocationFormatConfig` types to manage the formatting of location codes and names. - Added `defaultFormatConfig` to provide default segment configurations for location codes and names. - Implemented `buildFormattedString` function to generate formatted strings based on active segments and their configurations. - Updated `RackStructureComponent` to utilize the new formatting logic for generating location codes and names. - Enhanced `RackStructureConfigPanel` to allow users to edit format settings for location codes and names using `FormatSegmentEditor`. These changes improve the flexibility and usability of the rack structure component by allowing dynamic formatting of location identifiers. --- .../LFC[계획]-위치포맷-사용자설정.md | 374 ++++++++++++++++++ .../LFC[맥락]-위치포맷-사용자설정.md | 123 ++++++ .../LFC[체크]-위치포맷-사용자설정.md | 84 ++++ .../RFO[계획]-렉구조-층필수해제.md | 350 ++++++++++++++++ .../RFO[맥락]-렉구조-층필수해제.md | 92 +++++ .../RFO[체크]-렉구조-층필수해제.md | 57 +++ .../v2-rack-structure/FormatSegmentEditor.tsx | 203 ++++++++++ .../RackStructureComponent.tsx | 45 +-- .../RackStructureConfigPanel.tsx | 42 +- .../components/v2-rack-structure/config.ts | 101 ++++- .../components/v2-rack-structure/types.ts | 25 +- frontend/lib/utils/buttonActions.ts | 22 +- 12 files changed, 1471 insertions(+), 47 deletions(-) create mode 100644 docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md create mode 100644 docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md create mode 100644 docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md create mode 100644 docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md create mode 100644 docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md create mode 100644 docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md create mode 100644 frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx diff --git a/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md b/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md new file mode 100644 index 00000000..d5a44b05 --- /dev/null +++ b/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md @@ -0,0 +1,374 @@ +# [계획서] 렉 구조 위치코드/위치명 포맷 사용자 설정 + +> 관련 문서: [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md) + +## 개요 + +물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서 생성되는 **위치코드(`location_code`)와 위치명(`location_name`)의 포맷을 관리자가 화면 디자이너에서 자유롭게 설정**할 수 있도록 합니다. + +현재 위치코드/위치명 생성 로직은 하드코딩되어 있어, 구분자("-"), 세그먼트 순서(창고코드-층-구역-열-단), 한글 접미사("구역", "열", "단") 등을 변경할 수 없습니다. + +--- + +## 현재 동작 + +### 1. 타입/설정에 패턴 필드가 정의되어 있지만 사용하지 않음 + +`types.ts`(57~58행)에 `codePattern`/`namePattern`이 정의되어 있고, `config.ts`(14~15행)에 기본값도 있으나, 실제 컴포넌트에서는 **전혀 참조하지 않음**: + +```typescript +// types.ts:57~58 - 정의만 있음 +codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}") +namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단") + +// config.ts:14~15 - 기본값만 있음 +codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}", +namePattern: "{zone}구역-{row:02d}열-{level}단", +``` + +### 2. 위치 코드 생성 하드코딩 (RackStructureComponent.tsx:494~510) + +```tsx +const generateLocationCode = useCallback( + (row: number, level: number): { code: string; name: string } => { + const warehouseCode = context?.warehouseCode || "WH001"; + const floor = context?.floor; + const zone = context?.zone || "A"; + + const floorPrefix = floor ? `${floor}` : ""; + const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`; + + const zoneName = zone.includes("구역") ? zone : `${zone}구역`; + const floorNamePrefix = floor ? `${floor}-` : ""; + const name = `${floorNamePrefix}${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; + + return { code, name }; + }, + [context], +); +``` + +### 3. ConfigPanel에 포맷 관련 설정 UI 없음 + +`RackStructureConfigPanel.tsx`에는 필드 매핑, 제한 설정, UI 설정만 있고, `codePattern`/`namePattern`을 편집하는 UI가 없음. + +--- + +## 변경 후 동작 + +### 1. ConfigPanel에 "포맷 설정" 섹션 추가 + +화면 디자이너 좌측 속성 패널의 v2-rack-structure ConfigPanel에 새 섹션이 추가됨: + +- 위치코드/위치명 각각의 세그먼트 목록 +- 최상단에 컬럼 헤더(`라벨` / `구분` / `자릿수`) 표시 +- 세그먼트별로 **드래그 순서변경**, **체크박스로 한글 라벨 표시/숨김**, **라벨 텍스트 입력**, **구분자 입력**, **자릿수 입력** +- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 나머지(창고코드, 층, 구역)는 비활성화 +- 변경 시 실시간 미리보기로 결과 확인 + +### 2. 컴포넌트에서 config 기반 코드 생성 + +`RackStructureComponent`의 `generateLocationCode`가 하드코딩 대신 `config.formatConfig`의 세그먼트 배열을 순회하며 동적으로 코드/이름 생성. + +### 3. 기본값은 현재 하드코딩과 동일 + +`formatConfig`가 설정되지 않으면 기본 세그먼트가 적용되어 현재와 완전히 동일한 결과 생성 (하위 호환). + +--- + +## 시각적 예시 + +### ConfigPanel UI (화면 디자이너 좌측 속성 패널) + +``` +┌─ 포맷 설정 ──────────────────────────────────────────────┐ +│ │ +│ 위치코드 포맷 │ +│ 라벨 구분 자릿수 │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ ☰ 창고코드 [✓] [ ] [ - ] [ 0 ] (비활성) │ │ +│ │ ☰ 층 [✓] [ 층 ] [ ] [ 0 ] (비활성) │ │ +│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │ +│ │ ☰ 열 [✓] [ ] [ - ] [ 2 ] │ │ +│ │ ☰ 단 [✓] [ ] [ ] [ 0 ] │ │ +│ └──────────────────────────────────────────────────┘ │ +│ 미리보기: WH001-1층A구역-01-1 │ +│ │ +│ 위치명 포맷 │ +│ 라벨 구분 자릿수 │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │ +│ │ ☰ 열 [✓] [ 열 ] [ - ] [ 2 ] │ │ +│ │ ☰ 단 [✓] [ 단 ] [ ] [ 0 ] │ │ +│ └──────────────────────────────────────────────────┘ │ +│ 미리보기: A구역-01열-1단 │ +│ │ +└───────────────────────────────────────────────────────────┘ +``` + +### 사용자 커스터마이징 예시 + +| 설정 변경 | 위치코드 결과 | 위치명 결과 | +|-----------|-------------|------------| +| 기본값 (변경 없음) | `WH001-1층A구역-01-1` | `A구역-01열-1단` | +| 구분자를 "/" 로 변경 | `WH001/1층A구역/01/1` | `A구역/01열/1단` | +| 층 라벨 해제 | `WH001-1A구역-01-1` | `A구역-01열-1단` | +| 구역+열 라벨 해제 | `WH001-1층A-01-1` | `A-01-1단` | +| 순서를 구역→층→열→단 으로 변경 | `WH001-A구역1층-01-1` | `A구역-1층-01열-1단` | +| 한글 라벨 모두 해제 | `WH001-1A-01-1` | `A-01-1` | + +--- + +## 아키텍처 + +### 데이터 흐름 + +```mermaid +flowchart TD + A["관리자: 화면 디자이너 열기"] --> B["RackStructureConfigPanel\n포맷 세그먼트 편집"] + B --> C["componentConfig.formatConfig\n에 세그먼트 배열 저장"] + C --> D["screen_layouts_v2.layout_data\nDB JSONB에 영구 저장"] + D --> E["엔드유저: 렉 구조 모달 열기"] + E --> F["RackStructureComponent\nconfig.formatConfig 읽기"] + F --> G["generateLocationCode\n세그먼트 배열 순회하며 동적 생성"] + G --> H["미리보기 테이블에 표시\nlocation_code / location_name"] +``` + +### 컴포넌트 관계 + +```mermaid +graph LR + subgraph designer ["화면 디자이너 (관리자)"] + CP["RackStructureConfigPanel"] + FE["FormatSegmentEditor\n(신규 서브컴포넌트)"] + CP --> FE + end + subgraph runtime ["렉 구조 모달 (엔드유저)"] + RC["RackStructureComponent"] + GL["generateLocationCode\n(세그먼트 기반으로 교체)"] + RC --> GL + end + subgraph storage ["저장소"] + DB["screen_layouts_v2\nlayout_data.overrides.formatConfig"] + end + + FE -->|"onChange → componentConfig"| DB + DB -->|"config prop 전달"| RC +``` + +> 노란색 영역은 없음. 기존 설정-저장-전달 파이프라인을 그대로 활용. + +--- + +## 변경 대상 파일 + +| 파일 | 수정 내용 | 수정 규모 | +|------|----------|----------| +| `frontend/lib/registry/components/v2-rack-structure/types.ts` | `FormatSegment`, `LocationFormatConfig` 타입 추가, `RackStructureComponentConfig`에 `formatConfig` 필드 추가 | ~25줄 | +| `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 코드/이름 세그먼트 상수 정의 | ~40줄 | +| `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | **신규** - grid 레이아웃 + 컬럼 헤더 + 드래그 순서변경 + showLabel 체크박스 + 라벨/구분/자릿수 고정 필드 + 자릿수 비숫자 타입 비활성화 + 미리보기 | ~200줄 | +| `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | "포맷 설정" 섹션 추가, FormatSegmentEditor 배치 | ~30줄 | +| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | `generateLocationCode`를 세그먼트 기반으로 교체 | ~20줄 | + +### 변경하지 않는 파일 + +- `buttonActions.ts` - 생성된 `location_code`/`location_name`을 그대로 저장하므로 변경 불필요 +- 백엔드 전체 - 포맷은 프론트엔드에서만 처리 +- DB 스키마 - `screen_layouts_v2.layout_data` JSONB에 자동 포함 + +--- + +## 코드 설계 + +### 1. 타입 추가 (types.ts) + +```typescript +// 포맷 세그먼트 (위치코드/위치명의 각 구성요소) +export interface FormatSegment { + type: 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level'; + enabled: boolean; // 이 세그먼트를 포함할지 여부 + showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거) + label: string; // 한글 라벨 (예: "층", "구역", "열", "단") + separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "") + pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤) +} + +// 위치코드 + 위치명 포맷 설정 +export interface LocationFormatConfig { + codeSegments: FormatSegment[]; + nameSegments: FormatSegment[]; +} +``` + +`RackStructureComponentConfig`에 필드 추가: + +```typescript +export interface RackStructureComponentConfig { + // ... 기존 필드 유지 ... + codePattern?: string; // (기존, 하위 호환용 유지) + namePattern?: string; // (기존, 하위 호환용 유지) + formatConfig?: LocationFormatConfig; // 신규: 구조화된 포맷 설정 +} +``` + +### 2. 기본 세그먼트 상수 (config.ts) + +```typescript +import { FormatSegment, LocationFormatConfig } from "./types"; + +// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과) +export const defaultCodeSegments: FormatSegment[] = [ + { type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 }, + { type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 }, + { type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 }, + { type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 }, + { type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 }, +]; + +// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과) +export const defaultNameSegments: FormatSegment[] = [ + { type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 }, + { type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 }, + { type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 }, +]; + +export const defaultFormatConfig: LocationFormatConfig = { + codeSegments: defaultCodeSegments, + nameSegments: defaultNameSegments, +}; +``` + +### 3. 세그먼트 기반 문자열 생성 함수 (config.ts) + +```typescript +// context 값에 포함된 한글 접미사 ("1층", "A구역") +const KNOWN_SUFFIXES: Partial> = { + floor: "층", + zone: "구역", +}; + +function stripKnownSuffix(type: FormatSegmentType, val: string): string { + const suffix = KNOWN_SUFFIXES[type]; + if (suffix && val.endsWith(suffix)) { + return val.slice(0, -suffix.length); + } + return val; +} + +export function buildFormattedString( + segments: FormatSegment[], + values: Record, +): string { + const activeSegments = segments.filter( + (seg) => seg.enabled && values[seg.type], + ); + + return activeSegments + .map((seg, idx) => { + // 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1") + let val = stripKnownSuffix(seg.type, values[seg.type]); + + // 2) showLabel이 켜져 있고 label이 있으면 붙임 + if (seg.showLabel && seg.label) { + val += seg.label; + } + + if (seg.pad > 0 && !isNaN(Number(val))) { + val = val.padStart(seg.pad, "0"); + } + + if (idx < activeSegments.length - 1) { + val += seg.separatorAfter; + } + return val; + }) + .join(""); +} +``` + +### 4. generateLocationCode 교체 (RackStructureComponent.tsx:494~510) + +```typescript +// 변경 전 (하드코딩) +const generateLocationCode = useCallback( + (row: number, level: number): { code: string; name: string } => { + const warehouseCode = context?.warehouseCode || "WH001"; + const floor = context?.floor; + const zone = context?.zone || "A"; + const floorPrefix = floor ? `${floor}` : ""; + const code = `${warehouseCode}-${floorPrefix}${zone}-...`; + // ... + }, + [context], +); + +// 변경 후 (세그먼트 기반) +const formatConfig = config.formatConfig || defaultFormatConfig; + +const generateLocationCode = useCallback( + (row: number, level: number): { code: string; name: string } => { + const values: Record = { + warehouseCode: context?.warehouseCode || "WH001", + floor: context?.floor || "", + zone: context?.zone || "A", + row: row.toString(), + level: level.toString(), + }; + + const code = buildFormattedString(formatConfig.codeSegments, values); + const name = buildFormattedString(formatConfig.nameSegments, values); + + return { code, name }; + }, + [context, formatConfig], +); +``` + +### 5. ConfigPanel에 포맷 설정 섹션 추가 (RackStructureConfigPanel.tsx:284행 위) + +```tsx +{/* 포맷 설정 - UI 설정 섹션 아래에 추가 */} +
+
포맷 설정
+

+ 위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고, + 구분자/라벨을 편집할 수 있습니다 +

+ + handleFormatChange("codeSegments", segs)} + sampleValues={sampleValues} + /> + + handleFormatChange("nameSegments", segs)} + sampleValues={sampleValues} + /> +
+``` + +### 6. FormatSegmentEditor 서브컴포넌트 (신규 파일) + +- `@dnd-kit/core` + `@dnd-kit/sortable`로 드래그 순서변경 +- 프로젝트 표준 패턴: `useSortable`, `DndContext`, `SortableContext` 사용 +- **grid 레이아웃** (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`): 드래그핸들 / 타입명 / 체크박스 / 라벨 / 구분 / 자릿수 +- 최상단에 **컬럼 헤더** (`라벨` / `구분` / `자릿수`) 표시 — 각 행에서 텍스트 라벨 제거하여 공간 절약 +- 라벨/구분/자릿수 3개 필드는 **항상 고정 표시** (빈 값이어도 입력 필드가 사라지지 않음) +- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 비숫자 타입은 `disabled` + 회색 배경 +- 하단에 `buildFormattedString`으로 실시간 미리보기 표시 + +--- + +## 설계 원칙 + +- `formatConfig` 미설정 시 `defaultFormatConfig` 적용으로 **기존 동작 100% 유지** (하위 호환) +- 포맷 설정은 **화면 디자이너 ConfigPanel에서만** 편집 (프로젝트의 설정-사용 분리 관행 준수) +- `componentConfig` → `screen_layouts_v2.layout_data` 저장 파이프라인을 **그대로 활용** (추가 인프라 불필요) +- 기존 `codePattern`/`namePattern` 문자열 필드는 삭제하지 않고 유지 (하위 호환) +- v2-pivot-grid의 `format` 설정 패턴과 동일한 구조: ConfigPanel에서 설정 → 런타임에서 읽어 사용 +- `@dnd-kit` 드래그 구현은 `SortableCodeItem.tsx`, `useDragAndDrop.ts`의 기존 패턴 재사용 +- 백엔드 변경 없음, DB 스키마 변경 없음 diff --git a/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md b/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md new file mode 100644 index 00000000..73c79cef --- /dev/null +++ b/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md @@ -0,0 +1,123 @@ +# [맥락노트] 렉 구조 위치코드/위치명 포맷 사용자 설정 + +> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md) + +--- + +## 왜 이 작업을 하는가 + +- 위치코드(`WH001-1층A구역-01-1`)와 위치명(`A구역-01열-1단`)의 포맷이 하드코딩되어 있음 +- 회사마다 구분자("-" vs "/"), 세그먼트 순서, 한글 라벨 유무 등 요구사항이 다름 +- 현재는 코드를 직접 수정하지 않으면 포맷 변경 불가 → 관리자가 화면 디자이너에서 설정할 수 있어야 함 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 엔드유저 모달이 아닌 화면 디자이너 ConfigPanel에 설정 UI 배치 + +- **결정**: 포맷 편집 UI를 렉 구조 등록 모달이 아닌 화면 디자이너 좌측 속성 패널(ConfigPanel)에 배치 +- **근거**: 프로젝트의 설정-사용 분리 패턴 준수. 모든 v2 컴포넌트가 ConfigPanel에서 설정하고 런타임에서 읽기만 하는 구조를 따름 +- **대안 검토**: 모달 안에 포맷 편집 UI 배치(방법 B) → 기각 (프로젝트 관행에 맞지 않음, 매번 설정해야 함, 설정이 휘발됨) + +### 2. 패턴 문자열이 아닌 구조화된 세그먼트 배열 사용 + +- **결정**: `"{warehouseCode}-{floor}{zone}-{row:02d}-{level}"` 같은 문자열 대신 `FormatSegment[]` 배열로 포맷 정의 +- **근거**: 관리자가 패턴 문법을 알 필요 없이 드래그/토글/Input으로 직관적 편집 가능 +- **대안 검토**: 기존 `codePattern`/`namePattern` 문자열 활용 → 기각 (관리자가 패턴 문법을 모를 수 있고, 오타 가능성 높음) + +### 2-1. 체크박스는 한글 라벨 표시/숨김 제어 (showLabel) + +- **결정**: 세그먼트의 체크박스는 `showLabel` 속성을 토글하며, 세그먼트 자체를 제거하지 않음 +- **근거**: "A구역-01열-1단"에서 "구역", "열" 체크 해제 시 → "A-01-1단"이 되어야 함 (값은 유지, 한글만 제거) +- **주의**: `enabled`는 세그먼트 자체의 포함 여부, `showLabel`은 한글 라벨만 표시/숨김. 혼동하지 않도록 분리 + +### 2-2. 라벨/구분/자릿수 3개 필드 항상 고정 표시 + +- **결정**: 라벨 필드를 비워도 입력 필드가 사라지지 않고, 3개 필드(라벨, 구분, 자릿수)가 모든 세그먼트에 항상 표시 +- **근거**: 라벨을 지웠을 때 "라벨 없음"이 뜨면서 입력 필드가 사라지면 다시 라벨을 추가할 수 없는 문제 발생 +- **UI 개선**: 컬럼 헤더를 최상단에 배치하고, 각 행에서는 "구분", "자릿수" 텍스트를 제거하여 공간 확보 + +### 2-3. stripKnownSuffix로 원본 값의 한글 접미사를 먼저 벗긴 뒤 라벨 붙임 + +- **결정**: `buildFormattedString`에서 값을 처리할 때, 먼저 `KNOWN_SUFFIXES`(층, 구역)를 벗겨내고 순수 값만 남긴 뒤, `showLabel && label`일 때만 라벨을 붙이는 구조 +- **근거**: context 값이 "1층", "A구역"처럼 한글이 이미 포함된 상태로 들어옴. 이전 방식(`if (seg.label)`)은 라벨 필드가 빈 문자열이면 조건을 건너뛰어서 한글이 제거되지 않는 버그 발생 +- **핵심 흐름**: 원본 값 → `stripKnownSuffix` → 순수 값 → `showLabel && label`이면 라벨 붙임 + +### 2-4. 자릿수 필드는 숫자 타입만 활성화 + +- **결정**: 자릿수(pad) 필드는 열(row), 단(level)만 편집 가능, 나머지(창고코드, 층, 구역)는 disabled + 회색 배경 +- **근거**: 자릿수(zero-padding)는 숫자 값에만 의미가 있음. 비숫자 타입에 자릿수를 설정하면 혼란을 줄 수 있음 + +### 3. 기존 codePattern/namePattern 필드는 삭제하지 않고 유지 + +- **결정**: `types.ts`의 `codePattern`, `namePattern` 필드를 삭제하지 않음 +- **근거**: 하위 호환. 기존에 이 필드를 참조하는 코드가 없지만, 향후 다른 용도로 활용될 수 있음 + +### 4. formatConfig 미설정 시 기본값으로 현재 동작 유지 + +- **결정**: `config.formatConfig`가 없으면 `defaultFormatConfig` 사용 +- **근거**: 기존 화면 설정을 수정하지 않아도 현재와 동일한 위치코드/위치명이 생성됨 (무중단 배포 가능) + +### 5. UI 라벨에서 "패딩" 대신 "자릿수" 사용 + +- **결정**: ConfigPanel UI에서 숫자 제로패딩 설정을 "자릿수"로 표시 +- **근거**: 관리자급 사용자가 "패딩"이라는 개발 용어를 모를 수 있음. "자릿수: 2 → 01, 02, ... 99"가 직관적 +- **코드 내부**: 변수명은 `pad` 유지 (개발자 영역) + +### 6. @dnd-kit으로 드래그 구현 + +- **결정**: `@dnd-kit/core` + `@dnd-kit/sortable` 사용 +- **근거**: 프로젝트에 이미 설치되어 있고(`package.json`), `SortableCodeItem.tsx`, `useDragAndDrop.ts` 등 표준 패턴이 확립되어 있음 +- **대안 검토**: 위/아래 화살표 버튼으로 순서 변경 → 기각 (프로젝트에 이미 DnD 패턴이 있으므로 일관성 유지) + +### 7. v2-pivot-grid의 format 설정 패턴을 참고 + +- **결정**: ConfigPanel에서 설정 → componentConfig에 저장 → 런타임에서 읽어 사용하는 흐름 +- **근거**: v2-pivot-grid가 필드별 `format`(type, precision, thousandSeparator 등)을 동일한 패턴으로 구현하고 있음. 가장 유사한 선례 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | FormatSegment, LocationFormatConfig 타입 | +| 기본 설정 | `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 세그먼트 상수, buildFormattedString 함수 | +| 신규 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | 포맷 편집 UI 서브컴포넌트 | +| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | FormatSegmentEditor 배치 | +| 런타임 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | generateLocationCode 세그먼트 기반 교체 | +| DnD 참고 | `frontend/hooks/useDragAndDrop.ts` | 프로젝트 표준 DnD 패턴 | +| DnD 참고 | `frontend/components/admin/SortableCodeItem.tsx` | useSortable 사용 예시 | +| 선례 참고 | `frontend/lib/registry/components/v2-pivot-grid/` | ConfigPanel에서 format 설정하는 패턴 | + +--- + +## 기술 참고 + +### 세그먼트 기반 문자열 생성 흐름 + +``` +FormatSegment[] → filter(enabled && 값 있음) → map(stripKnownSuffix → showLabel && label이면 라벨 붙임 → 자릿수 → 구분자) → join("") → 최종 문자열 +``` + +### componentConfig 저장/로드 흐름 + +``` +ConfigPanel onChange + → V2PropertiesPanel.onUpdateProperty("componentConfig", mergedConfig) + → layout.components[i].componentConfig.formatConfig + → convertLegacyToV2 → screen_layouts_v2.layout_data.overrides.formatConfig (DB) + → convertV2ToLegacy → componentConfig.formatConfig (런타임) + → RackStructureComponent config.formatConfig (prop) +``` + +### context 값 참고 + +``` +context.warehouseCode = "WH001" (창고 코드) +context.floor = "1층" (층 라벨 - 값 자체에 "층" 포함) +context.zone = "A구역" 또는 "A" (구역 라벨 - "구역" 포함 여부 불확실) +row = 1, 2, 3, ... (열 번호 - 숫자) +level = 1, 2, 3, ... (단 번호 - 숫자) +``` diff --git a/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md b/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md new file mode 100644 index 00000000..b904d815 --- /dev/null +++ b/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md @@ -0,0 +1,84 @@ +# [체크리스트] 렉 구조 위치코드/위치명 포맷 사용자 설정 + +> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 타입 및 기본값 정의 + +- [x] `types.ts`에 `FormatSegment` 인터페이스 추가 +- [x] `types.ts`에 `LocationFormatConfig` 인터페이스 추가 +- [x] `types.ts`의 `RackStructureComponentConfig`에 `formatConfig?: LocationFormatConfig` 필드 추가 +- [x] `config.ts`에 `defaultCodeSegments` 상수 정의 (현재 하드코딩과 동일한 결과) +- [x] `config.ts`에 `defaultNameSegments` 상수 정의 (현재 하드코딩과 동일한 결과) +- [x] `config.ts`에 `defaultFormatConfig` 상수 정의 +- [x] `config.ts`에 `buildFormattedString()` 함수 구현 (stripKnownSuffix 방식) + +### 2단계: FormatSegmentEditor 서브컴포넌트 생성 + +- [x] `FormatSegmentEditor.tsx` 신규 파일 생성 +- [x] `@dnd-kit/sortable` 기반 드래그 순서변경 구현 +- [x] 세그먼트별 체크박스로 한글 라벨 표시/숨김 토글 (showLabel) +- [x] 라벨/구분/자릿수 3개 필드 항상 고정 표시 (빈 값이어도 입력 필드 유지) +- [x] 최상단 컬럼 헤더 추가 (라벨 / 구분 / 자릿수), 각 행에서 텍스트 라벨 제거 +- [x] grid 레이아웃으로 정렬 (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`) +- [x] 자릿수 필드: 숫자 타입(열, 단)만 활성화, 비숫자 타입은 disabled + 회색 배경 +- [x] `buildFormattedString`으로 실시간 미리보기 표시 + +### 3단계: ConfigPanel에 포맷 설정 섹션 추가 + +- [x] `RackStructureConfigPanel.tsx`에 FormatSegmentEditor import +- [x] UI 설정 섹션 아래에 "포맷 설정" 섹션 추가 +- [x] 위치코드 포맷용 FormatSegmentEditor 배치 +- [x] 위치명 포맷용 FormatSegmentEditor 배치 +- [x] `onChange`로 `formatConfig` 업데이트 연결 + +### 4단계: 컴포넌트에서 세그먼트 기반 코드 생성 + +- [x] `RackStructureComponent.tsx`에서 `defaultFormatConfig` import +- [x] `generateLocationCode` 함수를 세그먼트 기반으로 교체 +- [x] `config.formatConfig || defaultFormatConfig` 폴백 적용 + +### 5단계: 검증 + +- [x] formatConfig 미설정 시: 기존과 동일한 위치코드/위치명 생성 확인 +- [x] ConfigPanel에서 구분자 변경: 미리보기에 즉시 반영 확인 +- [x] ConfigPanel에서 라벨 체크 해제: 한글만 사라지고 값은 유지 확인 (예: "A구역" → "A") +- [x] ConfigPanel에서 순서 드래그 변경: 미리보기에 반영 확인 +- [x] ConfigPanel에서 라벨 텍스트 변경: 미리보기에 반영 확인 +- [x] 설정 저장 후 화면 재로드: 설정 유지 확인 +- [x] 렉 구조 모달에서 미리보기 생성: 설정된 포맷으로 생성 확인 +- [x] 렉 구조 저장: DB에 설정된 포맷의 코드/이름 저장 확인 + +### 6단계: 정리 + +- [x] 린트 에러 없음 확인 +- [x] 미사용 import 제거 (FormatSegmentEditor.tsx: useState) +- [x] 파일 끝 불필요한 빈 줄 제거 (types.ts, config.ts) +- [x] 계획서/맥락노트/체크리스트 최종 반영 +- [x] 이 체크리스트 완료 표시 업데이트 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-10 | 1~4단계 구현 완료 (types, config, FormatSegmentEditor, ConfigPanel, Component) | +| 2026-03-10 | showLabel 로직 수정: 체크박스가 세그먼트 제거가 아닌 한글 라벨만 표시/숨김 처리 | +| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 showLabel 변경사항 반영 | +| 2026-03-10 | UI 개선: 3필드 고정표시 + 컬럼 헤더 + grid 레이아웃 + 자릿수 비숫자 비활성화 | +| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 UI 개선사항 반영 | +| 2026-03-10 | 라벨 필드 비움 시 한글 미제거 버그 수정 (stripKnownSuffix 도입) | +| 2026-03-10 | 코드 정리 (미사용 import, 빈 줄) + 문서 최종 반영 | +| 2026-03-10 | 5단계 검증 완료, 전체 작업 완료 | diff --git a/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md b/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md new file mode 100644 index 00000000..74b9b6a8 --- /dev/null +++ b/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md @@ -0,0 +1,350 @@ +# [계획서] 렉 구조 등록 - 층(floor) 필수 입력 해제 + +> 관련 문서: [맥락노트](./RFO[맥락]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md) + +## 개요 + +탑씰 회사의 물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서, "층" 필드를 필수 입력에서 선택 입력으로 변경합니다. 현재 "창고 코드 / 층 / 구역" 3개가 모두 필수로 하드코딩되어 있어, 층을 선택하지 않으면 미리보기 생성과 저장이 불가능합니다. + +--- + +## 현재 동작 + +### 1. 필수 필드 경고 (RackStructureComponent.tsx:291~298) + +층을 선택하지 않으면 빨간 경고가 표시됨: + +```tsx +const missingFields = useMemo(() => { + const missing: string[] = []; + if (!context.warehouseCode) missing.push("창고 코드"); + if (!context.floor) missing.push("층"); // ← 하드코딩 필수 + if (!context.zone) missing.push("구역"); + return missing; +}, [context]); +``` + +> "다음 필드를 먼저 입력해주세요: **층**" + +### 2. 미리보기 생성 차단 (RackStructureComponent.tsx:517~521) + +`missingFields`에 "층"이 포함되어 있으면 `generatePreview()` 실행이 차단됨: + +```tsx +if (missingFields.length > 0) { + alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`); + return; +} +``` + +### 3. 위치 코드 생성 (RackStructureComponent.tsx:497~513) + +floor가 없으면 기본값 `"1"`을 사용하여 위치 코드를 생성: + +```tsx +const floor = context?.floor || "1"; +const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; +// 예: WH001-1층A구역-01-1 +``` + +### 4. 기존 데이터 조회 (RackStructureComponent.tsx:378~432) + +floor가 비어있으면 기존 데이터 조회 자체를 건너뜀 → 중복 체크 불가: + +```tsx +if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { + setExistingLocations([]); + return; +} +``` + +### 5. 렉 구조 화면 감지 (buttonActions.ts:692~698) + +floor가 비어있으면 렉 구조 화면으로 인식하지 않음 → 일반 저장으로 빠짐: + +```tsx +const isRackStructureScreen = + context.tableName === "warehouse_location" && + context.formData?.floor && // ← floor 없으면 false + context.formData?.zone && + !rackStructureLocations; +``` + +### 6. 저장 전 중복 체크 (buttonActions.ts:2085~2131) + +floor가 없으면 중복 체크 전체를 건너뜀: + +```tsx +if (warehouseCode && floor && zone) { + // 중복 체크 로직 +} +``` + +--- + +## 변경 후 동작 + +### 1. 필수 필드에서 "층" 제거 + +- "창고 코드"와 "구역"만 필수 +- 층을 선택하지 않아도 경고가 뜨지 않음 + +### 2. 미리보기 생성 정상 동작 + +- 층 없이도 미리보기 생성 가능 +- 위치 코드에서 층 부분을 생략하여 깔끔하게 생성 + +### 3. 위치 코드 생성 규칙 변경 + +- 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일) +- 층 없을 때: `WH001-A구역-01-1` (층 부분 생략) + +### 4. 기존 데이터 조회 (중복 체크) + +- 층 있을 때: `warehouse_code + floor + zone`으로 조회 (기존과 동일) +- 층 없을 때: `warehouse_code + zone`으로 조회 (floor 조건 제외) + +### 5. 렉 구조 화면 감지 + +- floor 유무와 관계없이 `warehouse_location` 테이블 + zone 필드가 있으면 렉 구조 화면으로 인식 + +### 6. 저장 시 floor 값 + +- 층 선택함: `floor = "1층"` 등 선택한 값 저장 +- 층 미선택: `floor = NULL`로 저장 + +--- + +## 시각적 예시 + +| 상태 | 경고 메시지 | 미리보기 | 위치 코드 | DB floor 값 | +|------|------------|---------|-----------|------------| +| 창고+층+구역 모두 선택 | 없음 | 생성 가능 | `WH001-1층A구역-01-1` | `"1층"` | +| 창고+구역만 선택 (층 미선택) | 없음 | 생성 가능 | `WH001-A구역-01-1` | `NULL` | +| 창고만 선택 | "구역을 먼저 입력해주세요" | 차단 | - | - | +| 아무것도 미선택 | "창고 코드, 구역을 먼저 입력해주세요" | 차단 | - | - | + +--- + +## 아키텍처 + +### 데이터 흐름 (변경 전) + +```mermaid +flowchart TD + A[사용자: 창고/층/구역 입력] --> B{필수 필드 검증} + B -->|층 없음| C[경고: 층을 입력하세요] + B -->|3개 다 있음| D[기존 데이터 조회
warehouse_code + floor + zone] + D --> E[미리보기 생성] + E --> F{저장 버튼} + F --> G[렉 구조 화면 감지
floor && zone 필수] + G --> H[중복 체크
warehouse_code + floor + zone] + H --> I[일괄 INSERT
floor = 선택값] +``` + +### 데이터 흐름 (변경 후) + +```mermaid +flowchart TD + A[사용자: 창고/구역 입력
층은 선택사항] --> B{필수 필드 검증} + B -->|창고 or 구역 없음| C[경고: 해당 필드를 입력하세요] + B -->|창고+구역 있음| D{floor 값 존재?} + D -->|있음| E1[기존 데이터 조회
warehouse_code + floor + zone] + D -->|없음| E2[기존 데이터 조회
warehouse_code + zone] + E1 --> F[미리보기 생성] + E2 --> F + F --> G{저장 버튼} + G --> H[렉 구조 화면 감지
zone만 필수] + H --> I{floor 값 존재?} + I -->|있음| J1[중복 체크
warehouse_code + floor + zone] + I -->|없음| J2[중복 체크
warehouse_code + zone] + J1 --> K[일괄 INSERT
floor = 선택값] + J2 --> K2[일괄 INSERT
floor = NULL] +``` + +### 컴포넌트 관계 + +```mermaid +graph LR + subgraph 프론트엔드 + A[폼 필드
창고/층/구역] -->|formData| B[RackStructureComponent
필수 검증 + 미리보기] + B -->|locations 배열| C[buttonActions.ts
화면 감지 + 중복 체크 + 저장] + end + subgraph 백엔드 + C -->|POST /dynamic-form/save| D[DynamicFormApi
데이터 저장] + D --> E[(warehouse_location
floor: nullable)] + end + + style B fill:#fff3cd,stroke:#ffc107 + style C fill:#fff3cd,stroke:#ffc107 +``` + +> 노란색 = 이번에 수정하는 부분 + +--- + +## 변경 대상 파일 + +| 파일 | 수정 내용 | 수정 규모 | +|------|----------|----------| +| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증에서 floor 제거, 위치 코드 생성 로직 수정, 기존 데이터 조회 로직 수정 | ~20줄 | +| `frontend/lib/utils/buttonActions.ts` | 렉 구조 화면 감지 조건 수정, 중복 체크 조건 수정 | ~10줄 | + +### 사전 확인 필요 + +| 확인 항목 | 내용 | +|----------|------| +| DB 스키마 | `warehouse_location.floor` 컬럼이 `NULL` 허용인지 확인. NOT NULL이면 `ALTER TABLE` 필요 | + +--- + +## 코드 설계 + +### 1. 필수 필드 검증 수정 (RackStructureComponent.tsx:291~298) + +```tsx +// 변경 전 +const missingFields = useMemo(() => { + const missing: string[] = []; + if (!context.warehouseCode) missing.push("창고 코드"); + if (!context.floor) missing.push("층"); + if (!context.zone) missing.push("구역"); + return missing; +}, [context]); + +// 변경 후 +const missingFields = useMemo(() => { + const missing: string[] = []; + if (!context.warehouseCode) missing.push("창고 코드"); + if (!context.zone) missing.push("구역"); + return missing; +}, [context]); +``` + +### 2. 위치 코드 생성 수정 (RackStructureComponent.tsx:497~513) + +```tsx +// 변경 전 +const floor = context?.floor || "1"; +const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; + +// 변경 후 +const floor = context?.floor; +const floorPrefix = floor ? `${floor}` : ""; +const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`; +// 층 있을 때: WH001-1층A구역-01-1 +// 층 없을 때: WH001-A구역-01-1 +``` + +### 3. 기존 데이터 조회 수정 (RackStructureComponent.tsx:378~432) + +```tsx +// 변경 전 +if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { + setExistingLocations([]); + return; +} + +const searchParams = { + warehouse_code: { value: warehouseCodeForQuery, operator: "equals" }, + floor: { value: floorForQuery, operator: "equals" }, + zone: { value: zoneForQuery, operator: "equals" }, +}; + +// 변경 후 +if (!warehouseCodeForQuery || !zoneForQuery) { + setExistingLocations([]); + return; +} + +const searchParams: Record = { + warehouse_code: { value: warehouseCodeForQuery, operator: "equals" }, + zone: { value: zoneForQuery, operator: "equals" }, +}; +if (floorForQuery) { + searchParams.floor = { value: floorForQuery, operator: "equals" }; +} +``` + +### 4. 렉 구조 화면 감지 수정 (buttonActions.ts:692~698) + +```tsx +// 변경 전 +const isRackStructureScreen = + context.tableName === "warehouse_location" && + context.formData?.floor && + context.formData?.zone && + !rackStructureLocations; + +// 변경 후 +const isRackStructureScreen = + context.tableName === "warehouse_location" && + context.formData?.zone && + !rackStructureLocations; +``` + +### 5. 저장 전 중복 체크 수정 (buttonActions.ts:2085~2131) + +```tsx +// 변경 전 +if (warehouseCode && floor && zone) { + const existingResponse = await DynamicFormApi.getTableData(tableName, { + search: { + warehouse_code: { value: warehouseCode, operator: "equals" }, + floor: { value: floor, operator: "equals" }, + zone: { value: zone, operator: "equals" }, + }, + // ... + }); +} + +// 변경 후 +if (warehouseCode && zone) { + const searchParams: Record = { + warehouse_code: { value: warehouseCode, operator: "equals" }, + zone: { value: zone, operator: "equals" }, + }; + if (floor) { + searchParams.floor = { value: floor, operator: "equals" }; + } + + const existingResponse = await DynamicFormApi.getTableData(tableName, { + search: searchParams, + // ... + }); +} +``` + +--- + +## 적용 범위 및 영향도 + +### 이번 변경은 전역 설정 + +방법 B는 렉 구조 컴포넌트 코드에서 직접 "층 필수"를 제거하는 방식이므로, 이 컴포넌트를 사용하는 **모든 회사**에 동일하게 적용됩니다. + +| 회사 | 변경 후 | +|------|--------| +| 탑씰 | 층 안 골라도 됨 (요청 사항) | +| 다른 회사 | 층 안 골라도 됨 (동일하게 적용) | + +### 기존 사용자에 대한 영향 + +- 층을 안 골라도 **되는** 것이지, 안 골라야 **하는** 것이 아님 +- 기존처럼 층을 선택하면 **완전히 동일하게** 동작함 (하위 호환 보장) +- 즉, 기존 사용 패턴을 유지하는 회사에는 아무런 차이가 없음 + +### 회사별 독립 제어가 필요한 경우 + +만약 특정 회사는 층을 필수로 유지하고, 다른 회사는 선택으로 해야 하는 상황이 발생하면, 방법 A(설정 기능 추가)로 업그레이드가 필요합니다. 이번 방법 B의 변경은 향후 방법 A로 전환할 때 충돌 없이 확장 가능합니다. + +--- + +## 설계 원칙 + +- "창고 코드"와 "구역"의 필수 검증은 기존과 동일하게 유지 +- 층을 선택한 경우의 동작은 기존과 완전히 동일 (하위 호환) +- 층 미선택 시 위치 코드에서 층 부분을 깔끔하게 생략 (폴백값 "1" 사용하지 않음) +- 중복 체크는 가용한 필드 기준으로 수행 (floor 없으면 warehouse_code + zone 기준) +- DB에는 NULL로 저장하여 "미입력"을 정확하게 표현 (프로젝트 표준 패턴) +- 특수 문자열("상관없음" 등) 사용하지 않음 (프로젝트 관행에 맞지 않으므로) diff --git a/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md b/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md new file mode 100644 index 00000000..08be3da0 --- /dev/null +++ b/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md @@ -0,0 +1,92 @@ +# [맥락노트] 렉 구조 등록 - 층(floor) 필수 입력 해제 + +> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md) + +--- + +## 왜 이 작업을 하는가 + +- 탑씰 회사에서 창고 렉 구조 등록 시 "층"을 선택하지 않아도 되게 해달라는 요청 +- 현재 코드에 창고 코드 / 층 / 구역 3개가 필수로 하드코딩되어 있어, 층 미선택 시 미리보기 생성과 저장이 모두 차단됨 +- 층 필수 검증이 6곳에 분산되어 있어 한 곳만 고치면 다른 곳에서 오류 발생 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 방법 B(하드코딩 제거) 채택, 방법 A(설정 기능) 미채택 + +- **결정**: 코드에서 floor 필수 조건을 직접 제거 +- **근거**: 이 프로젝트의 다른 모달/컴포넌트들은 모두 코드에서 직접 "필수/선택"을 정해놓는 방식을 사용. 설정으로 필수 여부를 바꿀 수 있게 만든 패턴은 기존에 없음 +- **대안 검토**: + - 방법 A(ConfigPanel에 requiredFields 설정 추가): 유연하지만 4파일 수정 + 프로젝트에 없던 새 패턴 도입 → 기각 + - "상관없음" 값 추가 후 null 변환: 프로젝트 어디에서도 magic value → null 변환 패턴을 쓰지 않음 → 기각 + - "상관없음" 값만 추가 (코드 무변경): DB에 "상관없음" 텍스트가 저장되어 데이터가 지저분함 → 기각 +- **향후**: 회사별 독립 제어가 필요해지면 방법 A로 확장 가능 (충돌 없음) + +### 2. 전역 적용 (회사별 독립 설정 아님) + +- **결정**: 렉 구조 컴포넌트를 사용하는 모든 회사에 동일 적용 +- **근거**: 방법 B는 코드 직접 수정이므로 회사별 분기 불가. 단, 기존처럼 층을 선택하면 완전히 동일하게 동작하므로 다른 회사에 실질적 영향 없음 (선택 안 해도 "되는" 것이지, 안 해야 "하는" 것이 아님) + +### 3. floor 미선택 시 NULL 저장 (특수값 아님) + +- **결정**: floor를 선택하지 않으면 DB에 `NULL` 저장 +- **근거**: 프로젝트 표준 패턴. `UserFormModal`의 `email: formData.email || null`, `EnhancedFormService`의 빈 문자열 → null 자동 변환 등과 동일한 방식 +- **대안 검토**: "상관없음" 저장 후 null 변환 → 프로젝트에서 미사용 패턴이므로 기각 + +### 4. 위치 코드에서 층 부분 생략 (폴백값 "1" 사용 안 함) + +- **결정**: floor 없을 때 위치 코드에서 층 부분을 아예 빼버림 +- **근거**: 기존 코드는 `context?.floor || "1"`로 폴백하여 1층을 선택한 것처럼 위장됨. 이는 잘못된 데이터를 만들 수 있음 +- **결과**: + - 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일) + - 층 없을 때: `WH001-A구역-01-1` (층 부분 없이 깔끔) + +### 5. 중복 체크는 가용 필드 기준으로 수행 + +- **결정**: floor 없으면 `warehouse_code + zone`으로 중복 체크, floor 있으면 `warehouse_code + floor + zone`으로 중복 체크 +- **근거**: 기존 코드는 floor 없으면 중복 체크 전체를 건너뜀 → 중복 데이터 발생 위험. 가용 필드 기준으로 체크하면 floor 유무와 관계없이 안전 + +### 6. 렉 구조 화면 감지에서 floor 조건 제거 + +- **결정**: `buttonActions.ts`의 `isRackStructureScreen` 조건에서 `context.formData?.floor` 제거 +- **근거**: floor 없으면 렉 구조 화면으로 인식되지 않아 일반 단건 저장으로 빠짐 → 예기치 않은 동작. zone만으로 감지해야 floor 미선택 시에도 렉 구조 일괄 저장이 정상 동작 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 대상 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증, 위치 코드 생성, 기존 데이터 조회 | +| 수정 대상 | `frontend/lib/utils/buttonActions.ts` | 화면 감지, 중복 체크 | +| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | RackStructureContext, FieldMapping 등 | +| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | 필드 매핑 설정 (이번에 수정 안 함) | +| 저장 모달 | `frontend/components/screen/SaveModal.tsx` | 필수 검증 (DB NOT NULL 기반, 별도 확인 필요) | +| 사전 확인 | DB `warehouse_location.floor` 컬럼 | NULL 허용 여부 확인, NOT NULL이면 ALTER TABLE 필요 | + +--- + +## 기술 참고 + +### 수정 포인트 6곳 요약 + +| # | 파일 | 행 | 내용 | 수정 방향 | +|---|------|-----|------|----------| +| 1 | RackStructureComponent.tsx | 291~298 | missingFields에서 floor 체크 | floor 체크 제거 | +| 2 | RackStructureComponent.tsx | 517~521 | 미리보기 생성 차단 | 1번 수정으로 자동 해결 | +| 3 | RackStructureComponent.tsx | 497~513 | 위치 코드 생성 `floor \|\| "1"` | 폴백값 제거, 없으면 생략 | +| 4 | RackStructureComponent.tsx | 378~432 | 기존 데이터 조회 조건 | floor 없어도 조회 가능하게 | +| 5 | buttonActions.ts | 692~698 | 렉 구조 화면 감지 | floor 조건 제거 | +| 6 | buttonActions.ts | 2085~2131 | 저장 전 중복 체크 | floor 조건부로 포함 | + +### 프로젝트 표준 optional 필드 처리 패턴 + +``` +빈 값 → null 변환: value || null (UserFormModal) +nullable 자동 변환: value === "" && isNullable === "Y" → null (EnhancedFormService) +Select placeholder: "__none__" → "" 또는 undefined (여러 ConfigPanel) +``` + +이번 변경은 위 패턴들과 일관성을 유지합니다. diff --git a/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md b/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md new file mode 100644 index 00000000..a80bdacc --- /dev/null +++ b/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md @@ -0,0 +1,57 @@ +# [체크리스트] 렉 구조 등록 - 층(floor) 필수 입력 해제 + +> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [맥락노트](./RFO[맥락]-렉구조-층필수해제.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 전체 완료 + +--- + +## 구현 체크리스트 + +### 0단계: 사전 확인 + +- [x] DB `warehouse_location.floor` 컬럼 nullable 여부 확인 → 이미 NULL 허용 상태, 변경 불필요 + +### 1단계: RackStructureComponent.tsx 수정 + +- [x] `missingFields`에서 `if (!context.floor) missing.push("층")` 제거 (291~298행) +- [x] `generateLocationCode`에서 `context?.floor || "1"` 폴백 제거, floor 없으면 위치 코드에서 생략 (497~513행) +- [x] `loadExistingLocations`에서 floor 없어도 조회 가능하도록 조건 수정 (378~432행) +- [x] `searchParams`에 floor를 조건부로 포함하도록 변경 + +### 2단계: buttonActions.ts 수정 + +- [x] `isRackStructureScreen` 조건에서 `context.formData?.floor` 제거 (692~698행) +- [x] `handleRackStructureBatchSave` 중복 체크에서 floor를 조건부로 포함 (2085~2131행) + +### 3단계: 검증 + +- [x] 층 선택 + 구역 선택: 기존과 동일하게 동작 확인 +- [x] 층 미선택 + 구역 선택: 경고 없이 미리보기 생성 가능 확인 +- [x] 층 미선택 시 위치 코드에 층 부분이 빠져있는지 확인 +- [x] 층 미선택 시 저장 정상 동작 확인 +- [x] 층 미선택 시 기존 데이터 중복 체크 정상 동작 확인 +- [x] 창고 코드 미입력 시 여전히 경고 표시되는지 확인 +- [x] 구역 미입력 시 여전히 경고 표시되는지 확인 + +### 4단계: 정리 + +- [x] 린트 에러 없음 확인 (기존 WARNING 1개만 존재, 이번 변경과 무관) +- [x] 이 체크리스트 완료 표시 업데이트 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-10 | 1단계 코드 수정 완료 (RackStructureComponent.tsx) | +| 2026-03-10 | 2단계 코드 수정 완료 (buttonActions.ts) | +| 2026-03-10 | 린트 에러 확인 완료 | +| 2026-03-10 | 사용자 검증 완료, 전체 작업 완료 | diff --git a/frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx b/frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx new file mode 100644 index 00000000..5a56364f --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx @@ -0,0 +1,203 @@ +"use client"; + +import React, { useMemo } from "react"; +import { GripVertical } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +import { FormatSegment } from "./types"; +import { SEGMENT_TYPE_LABELS, buildFormattedString, SAMPLE_VALUES } from "./config"; + +// 개별 세그먼트 행 +interface SortableSegmentRowProps { + segment: FormatSegment; + index: number; + onChange: (index: number, updates: Partial) => void; +} + +function SortableSegmentRow({ segment, index, onChange }: SortableSegmentRowProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `${segment.type}-${index}` }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+ +
+ + + {SEGMENT_TYPE_LABELS[segment.type]} + + + + onChange(index, { showLabel: checked === true }) + } + className="h-3.5 w-3.5" + /> + + onChange(index, { label: e.target.value })} + placeholder="" + className={cn( + "h-6 px-1 text-xs", + !segment.showLabel && "text-gray-400 line-through", + )} + /> + + onChange(index, { separatorAfter: e.target.value })} + placeholder="" + className="h-6 px-1 text-center text-xs" + /> + + + onChange(index, { pad: parseInt(e.target.value) || 0 }) + } + disabled={segment.type !== "row" && segment.type !== "level"} + className={cn( + "h-6 px-1 text-center text-xs", + segment.type !== "row" && segment.type !== "level" && "bg-gray-100 opacity-50", + )} + /> +
+ ); +} + +// FormatSegmentEditor 메인 컴포넌트 +interface FormatSegmentEditorProps { + label: string; + segments: FormatSegment[]; + onChange: (segments: FormatSegment[]) => void; + sampleValues?: Record; +} + +export function FormatSegmentEditor({ + label, + segments, + onChange, + sampleValues = SAMPLE_VALUES, +}: FormatSegmentEditorProps) { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor), + ); + + const preview = useMemo( + () => buildFormattedString(segments, sampleValues), + [segments, sampleValues], + ); + + const sortableIds = useMemo( + () => segments.map((seg, i) => `${seg.type}-${i}`), + [segments], + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = sortableIds.indexOf(active.id as string); + const newIndex = sortableIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + + onChange(arrayMove([...segments], oldIndex, newIndex)); + }; + + const handleSegmentChange = (index: number, updates: Partial) => { + const updated = segments.map((seg, i) => + i === index ? { ...seg, ...updates } : seg, + ); + onChange(updated); + }; + + return ( +
+
{label}
+ +
+ + + + 라벨 + 구분 + 자릿수 +
+ + + +
+ {segments.map((segment, index) => ( + + ))} +
+
+
+ +
+ 미리보기: + + {preview || "(빈 값)"} + +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx index d670c25c..7309d861 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx @@ -20,6 +20,7 @@ import { GeneratedLocation, RackStructureContext, } from "./types"; +import { defaultFormatConfig, buildFormattedString } from "./config"; // 기존 위치 데이터 타입 interface ExistingLocation { @@ -288,11 +289,10 @@ export const RackStructureComponent: React.FC = ({ return ctx; }, [propContext, formData, fieldMapping, getCategoryLabel]); - // 필수 필드 검증 + // 필수 필드 검증 (층은 선택 입력) const missingFields = useMemo(() => { const missing: string[] = []; if (!context.warehouseCode) missing.push("창고 코드"); - if (!context.floor) missing.push("층"); if (!context.zone) missing.push("구역"); return missing; }, [context]); @@ -377,9 +377,8 @@ export const RackStructureComponent: React.FC = ({ // 기존 데이터 조회 (창고/층/구역이 변경될 때마다) useEffect(() => { const loadExistingLocations = async () => { - // 필수 조건이 충족되지 않으면 기존 데이터 초기화 - // DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용 - if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { + // 창고 코드와 구역은 필수, 층은 선택 + if (!warehouseCodeForQuery || !zoneForQuery) { setExistingLocations([]); setDuplicateErrors([]); return; @@ -387,14 +386,13 @@ export const RackStructureComponent: React.FC = ({ setIsCheckingDuplicates(true); try { - // warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회 - // DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링 - // equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용) - const searchParams = { + const searchParams: Record = { warehouse_code: { value: warehouseCodeForQuery, operator: "equals" }, - floor: { value: floorForQuery, operator: "equals" }, zone: { value: zoneForQuery, operator: "equals" }, }; + if (floorForQuery) { + searchParams.floor = { value: floorForQuery, operator: "equals" }; + } // 직접 apiClient 사용하여 정확한 형식으로 요청 // 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리 @@ -493,23 +491,26 @@ export const RackStructureComponent: React.FC = ({ return { totalLocations, totalRows, maxLevel }; }, [conditions]); - // 위치 코드 생성 + // 포맷 설정 (ConfigPanel에서 관리자가 설정한 값, 미설정 시 기본값) + const formatConfig = config.formatConfig || defaultFormatConfig; + + // 위치 코드 생성 (세그먼트 기반 - 순서/구분자/라벨/자릿수 모두 formatConfig에 따름) const generateLocationCode = useCallback( (row: number, level: number): { code: string; name: string } => { - const warehouseCode = context?.warehouseCode || "WH001"; - const floor = context?.floor || "1"; - const zone = context?.zone || "A"; + const values: Record = { + warehouseCode: context?.warehouseCode || "WH001", + floor: context?.floor || "", + zone: context?.zone || "A", + row: row.toString(), + level: level.toString(), + }; - // 코드 생성 (예: WH001-1층D구역-01-1) - const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; - - // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 - const zoneName = zone.includes("구역") ? zone : `${zone}구역`; - const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; + const code = buildFormattedString(formatConfig.codeSegments, values); + const name = buildFormattedString(formatConfig.nameSegments, values); return { code, name }; }, - [context], + [context, formatConfig], ); // 미리보기 생성 @@ -870,7 +871,7 @@ export const RackStructureComponent: React.FC = ({ {idx + 1} {loc.location_code} {loc.location_name} - {loc.floor || context?.floor || "1"} + {loc.floor || context?.floor || "-"} {loc.zone || context?.zone || "A"} {loc.row_num.padStart(2, "0")} {loc.level_num} diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx index 8f0c8177..88335dcc 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx @@ -11,7 +11,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { RackStructureComponentConfig, FieldMapping } from "./types"; +import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types"; +import { defaultFormatConfig, SAMPLE_VALUES } from "./config"; +import { FormatSegmentEditor } from "./FormatSegmentEditor"; interface RackStructureConfigPanelProps { config: RackStructureComponentConfig; @@ -69,6 +71,21 @@ export const RackStructureConfigPanel: React.FC = const fieldMapping = config.fieldMapping || {}; + const formatConfig = config.formatConfig || defaultFormatConfig; + + const handleFormatChange = ( + key: "codeSegments" | "nameSegments", + segments: FormatSegment[], + ) => { + onChange({ + ...config, + formatConfig: { + ...formatConfig, + [key]: segments, + }, + }); + }; + return (
{/* 필드 매핑 섹션 */} @@ -282,6 +299,29 @@ export const RackStructureConfigPanel: React.FC = />
+ + {/* 포맷 설정 */} +
+
포맷 설정
+

+ 위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고, + 구분자/라벨을 편집할 수 있습니다 +

+ + handleFormatChange("codeSegments", segs)} + sampleValues={SAMPLE_VALUES} + /> + + handleFormatChange("nameSegments", segs)} + sampleValues={SAMPLE_VALUES} + /> +
); }; diff --git a/frontend/lib/registry/components/v2-rack-structure/config.ts b/frontend/lib/registry/components/v2-rack-structure/config.ts index 09d9d04b..f5cc56a2 100644 --- a/frontend/lib/registry/components/v2-rack-structure/config.ts +++ b/frontend/lib/registry/components/v2-rack-structure/config.ts @@ -2,26 +2,107 @@ * 렉 구조 컴포넌트 기본 설정 */ -import { RackStructureComponentConfig } from "./types"; +import { + RackStructureComponentConfig, + FormatSegment, + FormatSegmentType, + LocationFormatConfig, +} from "./types"; + +// 세그먼트 타입별 한글 표시명 +export const SEGMENT_TYPE_LABELS: Record = { + warehouseCode: "창고코드", + floor: "층", + zone: "구역", + row: "열", + level: "단", +}; + +// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과) +export const defaultCodeSegments: FormatSegment[] = [ + { type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 }, + { type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 }, + { type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 }, + { type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 }, + { type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 }, +]; + +// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과) +export const defaultNameSegments: FormatSegment[] = [ + { type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 }, + { type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 }, + { type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 }, +]; + +export const defaultFormatConfig: LocationFormatConfig = { + codeSegments: defaultCodeSegments, + nameSegments: defaultNameSegments, +}; + +// 세그먼트 타입별 기본 한글 접미사 (context 값에 포함되어 있는 한글) +const KNOWN_SUFFIXES: Partial> = { + floor: "층", + zone: "구역", +}; + +// 값에서 알려진 한글 접미사를 제거하여 순수 값만 추출 +function stripKnownSuffix(type: FormatSegmentType, val: string): string { + const suffix = KNOWN_SUFFIXES[type]; + if (suffix && val.endsWith(suffix)) { + return val.slice(0, -suffix.length); + } + return val; +} + +// 세그먼트 배열로 포맷된 문자열 생성 +export function buildFormattedString( + segments: FormatSegment[], + values: Record, +): string { + const activeSegments = segments.filter( + (seg) => seg.enabled && values[seg.type], + ); + + return activeSegments + .map((seg, idx) => { + // 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1") + let val = stripKnownSuffix(seg.type, values[seg.type]); + + // 2) showLabel이 켜져 있고 label이 있으면 붙임 + if (seg.showLabel && seg.label) { + val += seg.label; + } + + if (seg.pad > 0 && !isNaN(Number(val))) { + val = val.padStart(seg.pad, "0"); + } + + if (idx < activeSegments.length - 1) { + val += seg.separatorAfter; + } + return val; + }) + .join(""); +} + +// 미리보기용 샘플 값 +export const SAMPLE_VALUES: Record = { + warehouseCode: "WH001", + floor: "1층", + zone: "A구역", + row: "1", + level: "1", +}; export const defaultConfig: RackStructureComponentConfig = { - // 기본 제한 maxConditions: 10, maxRows: 99, maxLevels: 20, - - // 기본 코드 패턴 codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}", namePattern: "{zone}구역-{row:02d}열-{level}단", - - // UI 설정 showTemplates: true, showPreview: true, showStatistics: true, readonly: false, - - // 초기 조건 없음 initialConditions: [], }; - - diff --git a/frontend/lib/registry/components/v2-rack-structure/types.ts b/frontend/lib/registry/components/v2-rack-structure/types.ts index 76214972..8fe714d4 100644 --- a/frontend/lib/registry/components/v2-rack-structure/types.ts +++ b/frontend/lib/registry/components/v2-rack-structure/types.ts @@ -43,6 +43,24 @@ export interface FieldMapping { statusField?: string; // 사용 여부로 사용할 폼 필드명 } +// 포맷 세그먼트 (위치코드/위치명의 각 구성요소) +export type FormatSegmentType = 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level'; + +export interface FormatSegment { + type: FormatSegmentType; + enabled: boolean; // 이 세그먼트를 포함할지 여부 + showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거) + label: string; // 한글 라벨 (예: "층", "구역", "열", "단") + separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "") + pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤) +} + +// 위치코드 + 위치명 포맷 설정 +export interface LocationFormatConfig { + codeSegments: FormatSegment[]; + nameSegments: FormatSegment[]; +} + // 컴포넌트 설정 export interface RackStructureComponentConfig { // 기본 설정 @@ -54,8 +72,9 @@ export interface RackStructureComponentConfig { fieldMapping?: FieldMapping; // 위치 코드 생성 규칙 - codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}") - namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단") + codePattern?: string; // 코드 패턴 (하위 호환용 유지) + namePattern?: string; // 이름 패턴 (하위 호환용 유지) + formatConfig?: LocationFormatConfig; // 구조화된 포맷 설정 // UI 설정 showTemplates?: boolean; // 템플릿 기능 표시 @@ -93,5 +112,3 @@ export interface RackStructureComponentProps { isPreview?: boolean; tableName?: string; } - - diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 0c9b1327..cc145262 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -689,11 +689,10 @@ export class ButtonActionExecutor { return false; } - // 🆕 렉 구조 등록 화면 감지 (warehouse_location 테이블 + floor/zone 필드 있음 + 렉 구조 데이터 없음) - // 이 경우 일반 저장을 차단하고 미리보기 생성을 요구 + // 렉 구조 등록 화면 감지 (warehouse_location 테이블 + zone 필드 있음 + 렉 구조 데이터 없음) + // floor는 선택 입력이므로 감지 조건에서 제외 const isRackStructureScreen = context.tableName === "warehouse_location" && - context.formData?.floor && context.formData?.zone && !rackStructureLocations; @@ -2085,15 +2084,18 @@ export class ButtonActionExecutor { const floor = firstLocation.floor; const zone = firstLocation.zone; - if (warehouseCode && floor && zone) { + if (warehouseCode && zone) { try { - // search 파라미터를 사용하여 백엔드에서 필터링 (filters는 백엔드에서 처리 안됨) + const searchParams: Record = { + warehouse_code: { value: warehouseCode, operator: "equals" }, + zone: { value: zone, operator: "equals" }, + }; + if (floor) { + searchParams.floor = { value: floor, operator: "equals" }; + } + const existingResponse = await DynamicFormApi.getTableData(tableName, { - search: { - warehouse_code: { value: warehouseCode, operator: "equals" }, - floor: { value: floor, operator: "equals" }, - zone: { value: zone, operator: "equals" }, - }, + search: searchParams, page: 1, pageSize: 1000, }); From 12ccb85308ee29819340ce6b70dba92697900ac2 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 11 Mar 2026 12:07:11 +0900 Subject: [PATCH 14/25] =?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 9c128cc52c37ccde840040c4d28cb678c7f693f7 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Wed, 11 Mar 2026 12:42:25 +0900 Subject: [PATCH 15/25] docs: Add project conventions and guidelines for ERP/PLM project - Introduced a comprehensive document outlining project conventions for the WACE ERP/PLM project. - Included sections on project structure, backend practices, frontend practices, and specific implementation patterns. - Established guidelines for file creation order, controller and service patterns, pagination handling, and caching strategies. - Enhanced documentation to improve consistency and maintainability across the codebase. These additions serve as a reference for developers to follow best practices and ensure uniformity in the project's development process. --- .cursor/rules/project-conventions.mdc | 731 ++++++++++++++++++ docs/ycshin-node/PGN[계획]-페이징-단락이동.md | 389 ++++++++++ docs/ycshin-node/PGN[맥락]-페이징-단락이동.md | 128 +++ docs/ycshin-node/PGN[체크]-페이징-단락이동.md | 90 +++ docs/ycshin-node/탭_시스템_설계.md | 48 +- frontend/components/common/PageGroupNav.tsx | 109 +++ frontend/components/layout/TabContent.tsx | 12 + .../v2-table-list/TableListComponent.tsx | 185 +++-- frontend/lib/tabStateCache.ts | 19 +- frontend/stores/tabStore.ts | 1 + 10 files changed, 1607 insertions(+), 105 deletions(-) create mode 100644 .cursor/rules/project-conventions.mdc create mode 100644 docs/ycshin-node/PGN[계획]-페이징-단락이동.md create mode 100644 docs/ycshin-node/PGN[맥락]-페이징-단락이동.md create mode 100644 docs/ycshin-node/PGN[체크]-페이징-단락이동.md create mode 100644 frontend/components/common/PageGroupNav.tsx diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc new file mode 100644 index 00000000..2f34326f --- /dev/null +++ b/.cursor/rules/project-conventions.mdc @@ -0,0 +1,731 @@ +# WACE ERP/PLM 프로젝트 관행 (Project Conventions) + +이 문서는 AI 에이전트가 새 기능을 구현할 때 기존 코드베이스의 관행을 따르기 위한 참조 문서입니다. +코드를 작성하기 전에 반드시 이 문서를 읽고 동일한 패턴을 사용하세요. + +--- + +## 1. 프로젝트 구조 + +``` +ERP-node/ +├── backend-node/src/ # Express + TypeScript 백엔드 +│ ├── app.ts # 엔트리포인트 (미들웨어, 라우트 등록) +│ ├── controllers/ # API 컨트롤러 (요청 처리, 응답 반환) +│ ├── services/ # 비즈니스 로직 (DB 접근, 트랜잭션) +│ ├── routes/ # Express 라우터 (URL 매핑) +│ ├── middleware/ # 인증, 에러처리, 권한 미들웨어 +│ ├── database/db.ts # PostgreSQL 연결 풀, query/queryOne/transaction +│ ├── config/environment.ts # 환경 변수 설정 +│ ├── types/ # TypeScript 타입 정의 +│ └── utils/logger.ts # winston 로거 +├── frontend/ # Next.js 15 (App Router) 프론트엔드 +│ ├── app/ # 페이지 (Route Groups: (main), (auth), (admin)) +│ ├── components/ # React 컴포넌트 +│ │ ├── ui/ # shadcn/ui 기본 컴포넌트 (33개) +│ │ ├── admin/ # 관리자 화면 컴포넌트 +│ │ └── screen/ # 화면 디자이너/렌더러 컴포넌트 +│ ├── hooks/ # 커스텀 React 훅 +│ ├── lib/api/ # API 클라이언트 모듈 (63개 파일) +│ ├── lib/utils.ts # cn() 등 유틸리티 +│ ├── types/ # 프론트엔드 타입 정의 +│ └── contexts/ # React Context (Auth, Menu 등) +├── db/migrations/ # SQL 마이그레이션 파일 +└── docs/ # 프로젝트 문서 +``` + +--- + +## 2. 백엔드 관행 + +### 2.1 새 기능 추가 시 파일 생성 순서 + +1. `backend-node/src/types/` — 타입 정의 (필요 시) +2. `backend-node/src/services/xxxService.ts` — 비즈니스 로직 +3. `backend-node/src/controllers/xxxController.ts` — 컨트롤러 +4. `backend-node/src/routes/xxxRoutes.ts` — 라우터 +5. `backend-node/src/app.ts` — 라우트 등록 (`app.use("/api/xxx", xxxRoutes)`) + +### 2.2 컨트롤러 패턴 + +```typescript +// backend-node/src/controllers/xxxController.ts +import { Request, Response } from "express"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; + +// 패턴 A: named async function (가장 많이 사용) +export async function getXxxList( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + + logger.info("XXX 목록 조회 요청", { companyCode, userId }); + + // ... 비즈니스 로직 ... + + res.status(200).json({ + success: true, + message: "XXX 목록 조회 성공", + data: result, + }); + } catch (error) { + logger.error("XXX 목록 조회 중 오류:", error); + res.status(500).json({ + success: false, + message: "XXX 목록 조회 중 오류가 발생했습니다.", + error: { + code: "XXX_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +} +``` + +**핵심 규칙:** +- `AuthenticatedRequest`로 인증된 사용자 정보 접근 +- `req.user?.companyCode`로 회사 코드 추출 +- `try-catch` + `logger.error` + `res.status().json()` 패턴 +- 응답 형식: `{ success, data?, message?, error?: { code, details } }` + +### 2.3 서비스 패턴 + +```typescript +// backend-node/src/services/xxxService.ts +import { logger } from "../utils/logger"; +import { query, queryOne, transaction } from "../database/db"; + +export class XxxService { + // static 메서드 또는 인스턴스 메서드 (둘 다 사용됨) + static async getList(companyCode: string, filters?: any) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters?.search) { + conditions.push(`name ILIKE $${paramIndex}`); + params.push(`%${filters.search}%`); + paramIndex++; + } + + const whereClause = conditions.length > 0 + ? `WHERE ${conditions.join(" AND ")}` + : ""; + + const rows = await query( + `SELECT * FROM xxx_table ${whereClause} ORDER BY created_date DESC`, + params + ); + return rows; + } +} +``` + +**핵심 규칙:** +- `query(sql, params)` — 다건 조회 (배열 반환) +- `queryOne(sql, params)` — 단건 조회 (객체 | null 반환) +- `transaction(async (client) => { ... })` — 트랜잭션 +- 동적 WHERE: `conditions[]` + `params[]` + `paramIndex` 패턴 +- 파라미터 바인딩: `$1`, `$2`, ... (절대 문자열 삽입 금지) + +### 2.4 DB 쿼리 함수 (database/db.ts) + +```typescript +import { query, queryOne, transaction } from "../database/db"; + +// 다건 조회 +const rows = await query<{ id: string; name: string }>( + "SELECT * FROM xxx WHERE company_code = $1", + [companyCode] +); + +// 단건 조회 +const row = await queryOne<{ id: string }>( + "SELECT * FROM xxx WHERE id = $1 AND company_code = $2", + [id, companyCode] +); + +// 트랜잭션 +const result = await transaction(async (client) => { + await client.query("INSERT INTO xxx (...) VALUES (...)", [params]); + await client.query("UPDATE yyy SET ... WHERE ...", [params]); + return { success: true }; +}); +``` + +### 2.5 라우터 패턴 + +```typescript +// backend-node/src/routes/xxxRoutes.ts +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { getXxxList, createXxx, updateXxx, deleteXxx } from "../controllers/xxxController"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// CRUD 라우트 +router.get("/", getXxxList); // GET /api/xxx +router.get("/:id", getXxxDetail); // GET /api/xxx/:id +router.post("/", createXxx); // POST /api/xxx +router.put("/:id", updateXxx); // PUT /api/xxx/:id +router.delete("/:id", deleteXxx); // DELETE /api/xxx/:id + +export default router; +``` + +**URL 네이밍:** +- 리소스명: 복수형, kebab-case (`/api/flow-definitions`, `/api/admin/users`) +- 하위 리소스: `/api/xxx/:id/yyy` +- 액션: `/api/xxx/:id/toggle`, `/api/xxx/check-duplicate` + +### 2.6 app.ts 라우트 등록 + +```typescript +// backend-node/src/app.ts 에 추가 +import xxxRoutes from "./routes/xxxRoutes"; +// ... +app.use("/api/xxx", xxxRoutes); +``` + +라우트 등록 위치: 기존 라우트들 사이에 알파벳 순서 또는 관련 기능 근처에 배치. + +### 2.7 타입 정의 + +```typescript +// backend-node/src/types/xxx.ts +export interface XxxItem { + id: string; + company_code: string; + name: string; + created_date?: string; + updated_date?: string; + writer?: string; +} +``` + +**공통 타입 (types/common.ts):** +- `ApiResponse` — 표준 API 응답 +- `AuthenticatedRequest` — 인증된 요청 (req.user 포함) +- `PaginationParams` — 페이지네이션 파라미터 + +**인증 타입 (types/auth.ts):** +- `PersonBean` — 세션 사용자 정보 (userId, companyCode, userType 등) +- `AuthenticatedRequest` — Request + PersonBean + +### 2.8 로깅 + +```typescript +import { logger } from "../utils/logger"; + +logger.info("작업 시작", { companyCode, userId }); +logger.error("작업 실패:", error); +logger.warn("경고 상황", { details }); +logger.debug("디버그 정보", { query, params }); +``` + +--- + +## 3. 프론트엔드 관행 + +### 3.1 새 기능 추가 시 파일 생성 순서 + +1. `frontend/lib/api/xxx.ts` — API 클라이언트 함수 +2. `frontend/hooks/useXxx.ts` — 커스텀 훅 (선택) +3. `frontend/components/xxx/XxxComponent.tsx` — 비즈니스 컴포넌트 +4. `frontend/app/(main)/xxx/page.tsx` — 페이지 + +### 3.2 페이지 패턴 + +```tsx +// frontend/app/(main)/xxx/page.tsx +"use client"; + +import { useState } from "react"; +import { useXxx } from "@/hooks/useXxx"; +import { XxxToolbar } from "@/components/xxx/XxxToolbar"; +import { XxxTable } from "@/components/xxx/XxxTable"; + +export default function XxxPage() { + const { data, isLoading, ... } = useXxx(); + + return ( +
+
+ {/* 페이지 헤더 */} +
+

페이지 제목

+

페이지 설명

+
+ + {/* 툴바 + 테이블 + 모달 등 */} + + +
+
+ ); +} +``` + +**핵심 규칙:** +- 모든 페이지: `"use client"` + `export default function` +- 비즈니스 로직은 커스텀 훅으로 분리 +- 페이지는 훅 + UI 컴포넌트 조합에 집중 + +### 3.3 컴포넌트 패턴 + +```tsx +// frontend/components/xxx/XxxToolbar.tsx + +interface XxxToolbarProps { + searchFilter: SearchFilter; + totalCount: number; + onSearchChange: (filter: Partial) => void; + onCreateClick: () => void; +} + +export function XxxToolbar({ + searchFilter, + totalCount, + onSearchChange, + onCreateClick, +}: XxxToolbarProps) { + return ( +
+ {/* ... */} +
+ ); +} +``` + +**핵심 규칙:** +- `export function ComponentName()` (arrow function 아님) +- `interface XxxProps` 정의 후 props 구조 분해 +- 이벤트 핸들러: 내부 `handle` 접두사, props 콜백 `on` 접두사 +- shadcn/ui 컴포넌트 우선 사용 + +### 3.4 커스텀 훅 패턴 + +```typescript +// frontend/hooks/useXxx.ts +import { useState, useCallback, useEffect, useMemo } from "react"; +import { xxxApi } from "@/lib/api/xxx"; +import { toast } from "sonner"; + +export const useXxx = () => { + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const response = await xxxApi.getList(); + if (response.success) { + setData(response.data); + } + } catch (err) { + setError("데이터 로딩 실패"); + toast.error("데이터를 불러올 수 없습니다."); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + return { + data, + isLoading, + error, + refreshData: loadData, + }; +}; +``` + +### 3.5 API 클라이언트 패턴 + +```typescript +// frontend/lib/api/xxx.ts +import { apiClient } from "./client"; + +interface ApiResponse { + success: boolean; + data?: T; + message?: string; +} + +export async function getXxxList(params?: Record) { + try { + const response = await apiClient.get("/xxx", { params }); + return response.data; + } catch (error) { + console.error("XXX 목록 API 오류:", error); + throw error; + } +} + +export async function createXxx(data: any) { + try { + const response = await apiClient.post("/xxx", data); + return response.data; + } catch (error) { + console.error("XXX 생성 API 오류:", error); + throw error; + } +} + +export async function updateXxx(id: string, data: any) { + const response = await apiClient.put(`/xxx/${id}`, data); + return response.data; +} + +export async function deleteXxx(id: string) { + const response = await apiClient.delete(`/xxx/${id}`); + return response.data; +} + +// 객체로도 export (선택) +export const xxxApi = { + getList: getXxxList, + create: createXxx, + update: updateXxx, + delete: deleteXxx, +}; +``` + +**핵심 규칙:** +- `apiClient` (Axios) 사용 — 절대 `fetch` 직접 사용 금지 +- `apiClient`는 자동으로 Authorization 헤더, 환경별 URL, 토큰 갱신 처리 +- URL에 `/api` 접두사 불필요 (client.ts에서 baseURL에 포함됨) +- 개별 함수 export + 객체 export 둘 다 가능 + +### 3.6 토스트/알림 + +```typescript +import { toast } from "sonner"; + +toast.success("저장되었습니다."); +toast.error("저장에 실패했습니다."); +toast.info("처리 중입니다."); +``` + +- `sonner` 라이브러리 직접 사용 +- 루트 레이아웃에 `` 설정됨 + +### 3.7 모달/다이얼로그 + +```tsx +import { + Dialog, DialogContent, DialogHeader, DialogTitle, + DialogDescription, DialogFooter +} from "@/components/ui/dialog"; + +interface XxxModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + editingItem?: XxxItem | null; +} + +export function XxxModal({ isOpen, onClose, onSuccess, editingItem }: XxxModalProps) { + return ( + + + + 모달 제목 + 설명 + + {/* 컨텐츠 */} + + + + + + + ); +} +``` + +### 3.8 레이아웃 계층 + +``` +app/layout.tsx → QueryProvider, RegistryProvider, Toaster + app/(main)/layout.tsx → AuthProvider, MenuProvider, AppLayout + app/(main)/admin/xxx/page.tsx → 실제 페이지 + app/(auth)/layout.tsx → 로그인 등 인증 페이지 +``` + +--- + +## 4. 데이터베이스 관행 + +### 4.1 테이블 생성 패턴 + +```sql +CREATE TABLE xxx_table ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(500) NOT NULL, + name VARCHAR(500), + description VARCHAR(500), + status VARCHAR(500) DEFAULT 'active', + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500) DEFAULT NULL +); + +CREATE INDEX idx_xxx_table_company_code ON xxx_table(company_code); +``` + +**기본 컬럼 (모든 테이블 필수):** +- `id` — VARCHAR(500), PK, `gen_random_uuid()::text` +- `company_code` — VARCHAR(500), NOT NULL +- `created_date` — TIMESTAMP, DEFAULT NOW() +- `updated_date` — TIMESTAMP, DEFAULT NOW() +- `writer` — VARCHAR(500) + +**컬럼 타입 관행:** +- 문자열: `VARCHAR(500)` (거의 모든 컬럼에 통일) +- 날짜: `TIMESTAMP` +- ID: `VARCHAR(500)` + `gen_random_uuid()::text` + +### 4.2 마이그레이션 파일명 + +``` +db/migrations/NNN_description.sql +예: 034_create_numbering_rules.sql + 078_create_production_plan_tables.sql + 1003_add_source_menu_objid_to_menu_info.sql +``` + +--- + +## 5. 멀티테넌시 (가장 중요) + +### 5.1 모든 쿼리에 company_code 필수 + +```typescript +// SELECT +WHERE company_code = $1 + +// INSERT +INSERT INTO xxx (company_code, ...) VALUES ($1, ...) + +// UPDATE +UPDATE xxx SET ... WHERE id = $1 AND company_code = $2 + +// DELETE +DELETE FROM xxx WHERE id = $1 AND company_code = $2 + +// JOIN +LEFT JOIN yyy ON xxx.yyy_id = yyy.id AND xxx.company_code = yyy.company_code +WHERE xxx.company_code = $1 +``` + +### 5.2 최고 관리자(SUPER_ADMIN) 예외 + +```typescript +const companyCode = req.user?.companyCode; + +if (companyCode === "*") { + // 최고 관리자: 전체 데이터 조회 + query = "SELECT * FROM xxx ORDER BY company_code, created_date DESC"; + params = []; +} else { + // 일반 사용자: 자기 회사만 + query = "SELECT * FROM xxx WHERE company_code = $1 ORDER BY created_date DESC"; + params = [companyCode]; +} +``` + +### 5.3 최고 관리자 가시성 제한 + +사용자 관련 API에서 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없음: + +```typescript +if (req.user && req.user.companyCode !== "*") { + whereConditions.push(`company_code != '*'`); +} +``` + +--- + +## 6. 인증 체계 + +### 6.1 JWT 토큰 기반 + +- 로그인 → JWT 발급 → `localStorage`에 저장 +- 모든 API 요청: `Authorization: Bearer {token}` 헤더 +- 프론트엔드 `apiClient`가 자동으로 토큰 관리 + +### 6.2 사용자 권한 3단계 + +| 역할 | company_code | userType | +|------|-------------|----------| +| 최고 관리자 | `"*"` | `SUPER_ADMIN` | +| 회사 관리자 | `"COMPANY_A"` | `COMPANY_ADMIN` | +| 일반 사용자 | `"COMPANY_A"` | `USER` | + +### 6.3 미들웨어 + +- `authenticateToken` — JWT 검증 (대부분의 라우트에 적용) +- `requireSuperAdmin` — 최고 관리자 전용 +- `requireAdmin` — 관리자(슈퍼+회사) 전용 + +--- + +## 7. 코드 스타일 관행 + +### 7.1 백엔드 + +- TypeScript strict: `false` (느슨한 타입 체크) +- 로거: `winston` (`logger` import) +- 컬럼명: `snake_case` (DB), `camelCase` (TypeScript 변수) +- 에러 코드: `UPPER_SNAKE_CASE` (예: `XXX_LIST_ERROR`) + +### 7.2 프론트엔드 + +- TypeScript strict: `true` +- 스타일: Tailwind CSS v4 + shadcn/ui +- 클래스 병합: `cn()` (clsx + tailwind-merge) +- 색상: CSS 변수 기반 (`bg-primary`, `text-muted-foreground`) +- 아이콘: `lucide-react` +- 상태 관리: `zustand` (전역), `useState`/`useReducer` (로컬) +- 데이터 패칭: `@tanstack/react-query` 또는 직접 `useEffect` + API 호출 +- 폼: `react-hook-form` + `zod` 또는 직접 `useState` +- 테이블: `@tanstack/react-table` 또는 shadcn `Table` +- 차트: `recharts` +- 날짜: `date-fns` + +### 7.3 네이밍 컨벤션 + +| 대상 | 컨벤션 | 예시 | +|------|--------|------| +| 파일명 (백엔드) | camelCase | `xxxController.ts`, `xxxService.ts`, `xxxRoutes.ts` | +| 파일명 (프론트엔드 컴포넌트) | PascalCase | `XxxToolbar.tsx`, `XxxModal.tsx` | +| 파일명 (프론트엔드 훅) | camelCase | `useXxx.ts` | +| 파일명 (프론트엔드 API) | camelCase | `xxx.ts` | +| 파일명 (프론트엔드 페이지) | camelCase 폴더 | `app/(main)/xxxMng/page.tsx` | +| DB 테이블명 | snake_case | `xxx_table`, `user_info` | +| DB 컬럼명 | snake_case | `company_code`, `created_date` | +| 컴포넌트명 | PascalCase | `XxxToolbar`, `XxxModal` | +| 함수명 | camelCase | `getXxxList`, `handleSubmit` | +| 이벤트 핸들러 (내부) | handle 접두사 | `handleCreateUser` | +| 이벤트 콜백 (props) | on 접두사 | `onSearchChange`, `onClose` | +| 상수 | UPPER_SNAKE_CASE | `MAX_PAGE_SIZE`, `DEFAULT_LIMIT` | + +--- + +## 8. 응답 형식 표준 + +### 8.1 성공 응답 + +```json +{ + "success": true, + "message": "조회 성공", + "data": [ ... ], + "pagination": { + "page": 1, + "limit": 20, + "total": 100, + "totalPages": 5 + } +} +``` + +### 8.2 에러 응답 + +```json +{ + "success": false, + "message": "조회 중 오류가 발생했습니다.", + "error": { + "code": "XXX_LIST_ERROR", + "details": "에러 상세 메시지" + } +} +``` + +--- + +## 9. 환경별 URL 매핑 + +| 환경 | 프론트엔드 | 백엔드 API | +|------|-----------|-----------| +| 프로덕션 | `v1.vexplor.com` | `https://api.vexplor.com/api` | +| 개발 (로컬) | `localhost:9771` 또는 `localhost:3000` | `http://localhost:8080/api` | + +- 프론트엔드 `apiClient`가 `window.location.hostname` 기반으로 자동 판별 +- 프론트엔드에서 API URL 하드코딩 금지 + +--- + +## 10. 자주 사용하는 import 경로 + +### 백엔드 + +```typescript +import { Request, Response } from "express"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest, PersonBean } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { query, queryOne, transaction } from "../database/db"; +import { authenticateToken } from "../middleware/authMiddleware"; +``` + +### 프론트엔드 + +```typescript +import { apiClient } from "@/lib/api/client"; +import { cn } from "@/lib/utils"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, ... } from "@/components/ui/dialog"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +``` + +--- + +## 11. 체크리스트: 새 기능 구현 시 + +### 백엔드 +- [ ] `company_code` 필터링이 모든 SELECT/INSERT/UPDATE/DELETE에 포함되어 있는가? +- [ ] `req.user?.companyCode`를 사용하는가? (클라이언트 입력 아님) +- [ ] SUPER_ADMIN (`company_code === "*"`) 예외 처리가 되어 있는가? +- [ ] JOIN 쿼리에도 `company_code` 매칭이 있는가? +- [ ] 파라미터 바인딩 (`$1`, `$2`) 사용하는가? (SQL 인젝션 방지) +- [ ] `try-catch` + `logger` + 적절한 HTTP 상태 코드를 반환하는가? +- [ ] `app.ts`에 라우트가 등록되어 있는가? + +### 프론트엔드 +- [ ] `apiClient`를 통해 API를 호출하는가? (fetch 직접 사용 금지) +- [ ] `"use client"` 지시어가 있는가? +- [ ] 비즈니스 로직이 커스텀 훅으로 분리되어 있는가? +- [ ] shadcn/ui 컴포넌트를 사용하는가? +- [ ] 에러 시 `toast.error()`로 사용자에게 피드백하는가? +- [ ] 로딩 상태를 표시하는가? +- [ ] 반응형 디자인 (모바일 우선)을 적용했는가? + +--- + +## 12. 주의사항 + +1. **백엔드 재시작 금지** — nodemon이 파일 변경 감지 시 자동 재시작 +2. **fetch 직접 사용 금지** — 반드시 `apiClient` 사용 +3. **하드코딩 색상 금지** — `bg-blue-500` 대신 `bg-primary` 등 CSS 변수 사용 +4. **company_code 누락 금지** — 모든 비즈니스 테이블/쿼리에 필수 +5. **중첩 박스 금지** — Card 안에 Card, Border 안에 Border 금지 +6. **항상 한글로 답변** diff --git a/docs/ycshin-node/PGN[계획]-페이징-단락이동.md b/docs/ycshin-node/PGN[계획]-페이징-단락이동.md new file mode 100644 index 00000000..4e39f276 --- /dev/null +++ b/docs/ycshin-node/PGN[계획]-페이징-단락이동.md @@ -0,0 +1,389 @@ +# [계획서] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 + +> 관련 문서: [맥락노트](./PGN[맥락]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md) + +## 개요 + +페이지네이션의 핵심 컨트롤(`<< < [번호들] > >>`)을 **재사용 가능한 공통 컴포넌트 `PageGroupNav`**로 분리합니다. +현재의 단순 `1 / n` 텍스트 표시를 **10개 단위 페이지 번호 버튼 그룹**으로 교체하고, `< >` 버튼을 **단락(그룹) 이동**으로, `<< >>` 버튼을 **첫/끝 단락 이동**으로 변경합니다. + +### 접근 전략: C안 (핵심 컨트롤 분리 + 단계적 적용) + +- **1단계 (이번 작업)**: `PageGroupNav.tsx` 생성 + v2-table-list에 적용 +- **2단계 (별도 작업)**: 나머지 페이징 사용처에 점진적 적용 + +이 전략을 선택한 이유: +- 레이아웃을 강제하지 않는 순수 컨트롤 컴포넌트 → 어디든 조합 가능 +- v2-table-list에서 먼저 검증 후 확산 → 리스크 최소화 +- 2단계는 `import` 한 줄로 적용 가능 → 미래 작업 비용 최소 + +--- + +## 현재 동작 + +### 페이지네이션 UI + +``` +[<<] [<] 1 / 38 [>] [>>] +``` + +| 버튼 | 현재 동작 | +|------|----------| +| `<<` | 첫 페이지(1)로 이동 | +| `<` | 이전 페이지(`currentPage - 1`)로 이동 | +| 중앙 | `currentPage / totalPages` 텍스트 표시 (클릭 불가) | +| `>` | 다음 페이지(`currentPage + 1`)로 이동 | +| `>>` | 마지막 페이지(`totalPages`)로 이동 | + +### 비활성화 조건 + +- `<<` `<` : `currentPage === 1` +- `>` `>>` : `currentPage >= totalPages` + +### 현재 코드 (TableListComponent.tsx, 5139~5182행) + +```tsx +{/* 중앙 페이지네이션 컨트롤 */} +
+
+``` + +--- + +## 변경 후 동작 + +### 페이지네이션 UI + +``` +[<<] [<] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [>] [>>] +``` + +| 버튼 | 변경 후 동작 | +|------|-------------| +| `<<` | **첫 번째 단락**으로 이동 (1페이지 선택) | +| `<` | **이전 단락**의 첫 페이지로 이동 | +| 중앙 | 현재 단락의 페이지 번호 버튼 나열 (클릭으로 해당 페이지 이동) | +| `>` | **다음 단락**의 첫 페이지로 이동 | +| `>>` | **마지막 단락**의 첫 페이지로 이동 (마지막 페이지가 아님) | + +### 비활성화 조건 + +- `<<` `<` : **첫 번째 단락**(1~10)을 보고 있을 때 +- `>` `>>` : **마지막 단락**을 보고 있을 때 + +### 단락(그룹) 개념 + +- 10개 단위로 페이지를 묶어 하나의 "단락"으로 취급 +- 단락 1: 1~10, 단락 2: 11~20, 단락 3: 21~30, ... +- 마지막 단락은 10개 미만일 수 있음 (예: 31~38) + +### 고정 슬롯 레이아웃 (핵심 제약) + +**페이지 번호 영역은 항상 10개 슬롯을 고정 렌더링한다.** + +- 각 슬롯은 동일한 고정 너비(`w-9` 등)를 가짐 +- 1자리(`1`)든 2자리(`11`)든 3자리(`100`)든 버튼 너비가 동일 +- 마지막 단락이 10개 미만이면 남은 슬롯은 빈 공간(투명 placeholder)으로 채움 +- 이로써 `< >` 버튼을 연속 클릭해도 **번호 버튼과 화살표 버튼의 위치가 절대 변하지 않음** + +``` +단락 1~10: [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] ← 10개 모두 채움 +단락 11~20: [11][12][13][14][15][16][17][18][19][20] ← 너비 동일 +단락 31~38: [31][32][33][34][35][36][37][38][ ][ ] ← 빈 슬롯 2개로 위치 고정 +``` + +--- + +## 시각적 동작 예시 + +총 38페이지 기준: + +### 단락별 페이지 번호 표시 + +| 현재 페이지 | 표시 번호 | `<<` `<` | `>` `>>` | +|-------------|-----------|----------|----------| +| 1 | **[1]** [2] [3] ... [10] | 비활성 | 활성 | +| 5 | [1] [2] [3] [4] **[5]** [6] [7] [8] [9] [10] | 비활성 | 활성 | +| 10 | [1] [2] [3] [4] [5] [6] [7] [8] [9] **[10]** | 비활성 | 활성 | +| 11 | **[11]** [12] [13] ... [20] | 활성 | 활성 | +| 25 | [21] [22] [23] [24] **[25]** [26] [27] [28] [29] [30] | 활성 | 활성 | +| 31 | **[31]** [32] [33] ... [38] [ ] [ ] | 활성 | 비활성 | +| 38 | [31] [32] [33] [34] [35] [36] [37] **[38]** [ ] [ ] | 활성 | 비활성 | + +### 버튼 클릭 시나리오 + +| 현재 상태 | 클릭 | 결과 | +|----------|------|------| +| 5페이지 (단락 1~10) | `>` | 11페이지 선택, 단락 11~20 표시 | +| 15페이지 (단락 11~20) | `<` | 1페이지 선택, 단락 1~10 표시 | +| 15페이지 (단락 11~20) | `>>` | 31페이지 선택, 단락 31~38 표시 | +| 35페이지 (단락 31~38) | `<<` | 1페이지 선택, 단락 1~10 표시 | +| 5페이지 (단락 1~10) | `[7]` | 7페이지 선택, 단락 1~10 유지 | + +--- + +## 아키텍처 + +### 컴포넌트 구조 (C안) + +```mermaid +flowchart TD + subgraph PageGroupNav ["PageGroupNav.tsx (새 공통 컴포넌트)"] + Props["props: currentPage, totalPages, onPageChange, disabled, groupSize"] + Logic["단락 계산 + 고정 슬롯 + 비활성화"] + UI["<< < [번호들] > >>"] + Props --> Logic --> UI + end + + subgraph Phase1 ["1단계: 이번 작업"] + V2Table["v2-table-list paginationJSX"] + end + + subgraph Phase2 ["2단계: 별도 작업 (미래)"] + TableList["table-list (구형)"] + PaginationTsx["Pagination.tsx (관리자)"] + DrillDown["DrillDown 모달"] + Mail["메일 수신/발송"] + Others["감사로그, 배치, DataTable 등"] + end + + PageGroupNav --> V2Table + PageGroupNav -.-> TableList + PageGroupNav -.-> PaginationTsx + PageGroupNav -.-> DrillDown + PageGroupNav -.-> Mail + PageGroupNav -.-> Others +``` + +### v2-table-list 내부 데이터 흐름 + +```mermaid +flowchart TD + A["currentPage, totalPages (state)"] --> B[PageGroupNav] + B -->|onPageChange| C[handlePageChange] + C --> D[setCurrentPage + onConfigChange] + D --> E[백엔드 API 호출] + E --> F[데이터 갱신] + F --> A +``` + +### v2-table-list 페이징 바 레이아웃 (변경 없음) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [페이지크기 입력] │ << < [PageGroupNav] > >> │ [내보내기][새로고침] │ +│ 좌측(유지) │ 중앙(교체) │ 우측(유지) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 변경 대상 파일 + +### 1단계 (이번 작업) + +| 구분 | 파일 | 변경 내용 | 변경 규모 | +|------|------|----------|----------| +| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 | 약 80줄 신규 | +| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | `paginationJSX` 중앙 영역을 `PageGroupNav`로 교체 (5139~5182행) | 약 40줄 → 5줄 | + +- `handlePageChange` 함수는 기존 것을 그대로 사용 (동작 변경 없음) +- 좌측(페이지크기 입력), 우측(내보내기/새로고침) 영역은 변경하지 않음 +- 백엔드 변경 없음, DB 변경 없음 + +### 1단계 적용 범위 + +v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용: +- 품목정보, 거래처관리, 판매품목정보, 설비정보 등 + +### 2단계 적용 대상 (별도 작업, 미래) + +| 사용처 | 파일 | 현재 페이징 형태 | +|--------|------|----------------| +| table-list (구형) | `lib/registry/components/table-list/TableListComponent.tsx` | `<< < 현재/총 > >>` | +| 공통 Pagination | `components/common/Pagination.tsx` | 번호 ±2 + `...` | +| 피벗 드릴다운 | `lib/registry/components/pivot-grid/components/DrillDownModal.tsx` | `<< < 현재/총 > >>` | +| v2 피벗 드릴다운 | `lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx` | 동일 | +| 메일 수신함 | `app/(main)/admin/automaticMng/mail/receive/page.tsx` | 번호 5개 클릭 | +| 메일 발송함 | `app/(main)/admin/automaticMng/mail/sent/page.tsx` | 동일 | +| 감사 로그 | `app/(main)/admin/audit-log/page.tsx` | `< 현재/총 >` | +| 배치 관리 | `app/(main)/admin/automaticMng/batchmngList/page.tsx` | 번호 5개 클릭 | +| DataTable | `components/common/DataTable.tsx` | `<< < > >>` + 텍스트 | +| FlowWidget | `components/screen/widgets/FlowWidget.tsx` | shadcn Pagination | + +--- + +## 코드 설계 + +### PageGroupNav.tsx 공통 컴포넌트 + +```tsx +// frontend/components/common/PageGroupNav.tsx +"use client"; + +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +const DEFAULT_GROUP_SIZE = 10; + +interface PageGroupNavProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + disabled?: boolean; + groupSize?: number; +} + +export function PageGroupNav({ + currentPage, + totalPages, + onPageChange, + disabled = false, + groupSize = DEFAULT_GROUP_SIZE, +}: PageGroupNavProps) { + const safeTotal = Math.max(1, totalPages); + const currentGroupIndex = Math.floor((currentPage - 1) / groupSize); + const groupStartPage = currentGroupIndex * groupSize + 1; + + const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize); + const lastGroupStartPage = lastGroupIndex * groupSize + 1; + + const isFirstGroup = currentGroupIndex === 0; + const isLastGroup = currentGroupIndex === lastGroupIndex; + + // 10개 고정 슬롯 배열 + const slots: (number | null)[] = []; + for (let i = 0; i < groupSize; i++) { + const page = groupStartPage + i; + slots.push(page <= safeTotal ? page : null); + } + + return ( +
+ {/* << 첫 단락 */} + + + {/* < 이전 단락 */} + + + {/* 페이지 번호 (고정 슬롯) */} + {slots.map((page, idx) => + page !== null ? ( + + ) : ( +
+ ) + )} + + {/* > 다음 단락 */} + + + {/* >> 마지막 단락 */} + +
+ ); +} +``` + +### v2-table-list 통합 (paginationJSX 중앙 영역 교체) + +기존 5139~5182행의 `
` 블록을 다음으로 교체: + +```tsx +import { PageGroupNav } from "@/components/common/PageGroupNav"; + +// paginationJSX 내부 중앙 영역 + +``` + +좌측(페이지크기 입력), 우측(내보내기/새로고침)은 기존 코드 그대로 유지. + +--- + +## 설계 원칙 + +- **레이아웃 무관 컴포넌트**: PageGroupNav는 순수 컨트롤만 담당. 외부 레이아웃(좌측/우측 부가 기능)을 강제하지 않음 +- **기존 동작 무변경**: `handlePageChange` 함수는 수정하지 않음. 좌측/우측 영역도 변경하지 않음 +- **고정 슬롯 레이아웃**: 페이지 번호 영역은 항상 `groupSize`개(기본 10) 슬롯 고정. 마지막 단락에서 부족하면 빈 div로 채움 +- **고정 너비 버튼**: 모든 번호 버튼은 `w-8 sm:w-9` 고정. 1자리/2자리/3자리에 관계없이 동일 +- **위치 불변**: `< >` `<< >>` 버튼을 연속 클릭해도 모든 버튼의 위치가 절대 변하지 않음 +- **현재 페이지 강조**: `variant="default"`(primary) + `ring-2 ring-primary font-bold`, 나머지 `variant="outline"` +- **엣지 케이스**: totalPages가 0이거나 1일 때도 정상 동작 (`safeTotal = Math.max(1, totalPages)`) +- **빈 슬롯 접근성**: 빈 슬롯에 `cursor-default` 적용 (클릭 가능한 것처럼 보이지 않게) +- **단계적 적용**: 1단계에서 v2-table-list로 검증 후, 2단계에서 나머지 사용처에 점진 적용 + +--- + +## 추가 구현: 표시갯수(pageSize) 캐시 정책 + +### 문제 + +기존 pageSize는 `onConfigChange`로 부모에 전파되어 DB에 저장되거나, `localStorage`에 저장되어 새로고침해도 사용자가 변경한 값이 남아있었음. + +### 해결 + +| 항목 | 정책 | +|------|------| +| 저장소 | sessionStorage (탭 닫으면 자동 소멸) | +| 키 구조 | `pageSize_{tabId}_{tableName}` (탭별 격리) | +| 기본값 | 20 | +| DB 전파 | 안 함 (onConfigChange 제거) | +| F5 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 | +| 탭 바 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 | +| 비활성 탭 전환 | 캐시에서 복원 | +| 입력 UX | onChange는 표시만, onBlur/Enter로 실제 적용 | + +### 테이블 캐시 탭 격리 + +동일한 정책을 테이블 관련 캐시 전체에 적용: + +| 키 | 구조 | +|----|------| +| `tableState_{tabId}_{tableName}` | 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터 | +| `pageSize_{tabId}_{tableName}` | 표시갯수 | +| `filterSettings_{tabId}_{base}` | 검색 필터 설정 | +| `groupSettings_{tabId}_{base}` | 그룹 설정 | + +사용자 설정(컬럼 가시성/순서/정렬 상태)은 localStorage에 유지 (세션 간 보존). diff --git a/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md b/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md new file mode 100644 index 00000000..024bd7a2 --- /dev/null +++ b/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md @@ -0,0 +1,128 @@ +# [맥락노트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 + +> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md) + +--- + +## 왜 이 작업을 하는가 + +- 현재 페이지네이션은 `1 / 38` 텍스트만 표시하고 `< >`로 한 페이지씩 이동 +- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음 +- 페이지 번호를 직접 클릭할 수 있어야 UX가 개선됨 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 공통 컴포넌트로 분리 (C안) + +- **결정**: `PageGroupNav.tsx`라는 순수 컨트롤 컴포넌트를 별도 파일로 생성 +- **근거**: 프로젝트에 페이징이 15곳 이상 존재. 인라인 수정하면 같은 로직을 복사해야 함 +- **대안 검토 A**: v2-table-list 인라인만 수정 → 기각 (미래 확장 시 복사-붙여넣기 기술 부채) +- **대안 검토 B**: 기존 `Pagination.tsx` 업그레이드 → 기각 (전체 행 레이아웃이 포함되어 v2-table-list와 레이아웃 충돌) +- **대안 검토 D**: 전체 한번에 적용 → 기각 (12파일 동시 수정은 블래스트 반경이 큼) + +### 2. 레이아웃 무관 설계 + +- **결정**: PageGroupNav는 `<< < [번호들] > >>`만 렌더링. 외부 레이아웃(페이지크기, 내보내기 등)을 포함하지 않음 +- **근거**: 사용처마다 레이아웃이 다름. v2-table-list는 좌측(페이지크기)+중앙(컨트롤)+우측(내보내기), Pagination.tsx는 좌측(페이지정보)+우측(크기선택+컨트롤). 레이아웃을 강제하면 props 분기가 증가하여 복잡해짐 + +### 3. 10개 단위 단락(그룹) + +- **결정**: 페이지를 10개씩 묶어 하나의 단락으로 취급 +- **근거**: 사용자에게 익숙한 패턴 (네이버, 구글 등). 5개는 너무 적고, 20개는 너무 많음 +- **확장성**: `groupSize` props로 기본값 10을 변경 가능하게 설계 + +### 4. `< >` = 단락 이동, `<< >>` = 첫/끝 단락 + +- **결정**: `<`는 이전 단락 첫 페이지, `>`는 다음 단락 첫 페이지. `<<`는 1페이지, `>>`는 마지막 단락 첫 페이지 +- **근거**: 사용자 요청. 기존의 "한 페이지씩 이동"은 번호 클릭으로 대체됨 +- **주의**: `>>`는 마지막 **페이지**가 아닌 마지막 **단락의 첫 페이지**로 이동. 예: 총 38페이지일 때 `>>` 클릭 → 31페이지 선택 (38이 아님) + +### 5. 고정 슬롯 + 고정 너비 + +- **결정**: 항상 10개 슬롯을 렌더링하고, 모든 버튼은 동일한 고정 너비(`w-8 sm:w-9`) +- **근거**: `< >` 버튼을 연속 클릭할 때 번호 자릿수(1자리→2자리)나 페이지 수(10개→8개) 변화로 버튼 위치가 흔들리면 안 됨 +- **구현**: 마지막 단락에서 페이지가 10개 미만이면 남은 슬롯은 동일 크기의 빈 `
`로 채움 + +### 6. 단계적 적용 (1단계: v2-table-list만) + +- **결정**: 이번 작업은 v2-table-list에만 적용. 나머지는 별도 작업으로 점진 적용 +- **근거**: 15곳 동시 수정은 리스크가 높음. v2-table-list가 가장 많이 사용되므로 여기서 검증 후 확산 + +### 7. 비활성화 기준은 단락 기준 + +- **결정**: `<< <`는 첫 번째 단락일 때 비활성화 (currentPage === 1이 아님). `> >>`는 마지막 단락일 때 비활성화 +- **근거**: 기존은 currentPage 기준이었지만, 단락 이동이므로 단락 기준으로 변경이 자연스러움. 첫 단락 안에서 5페이지에 있어도 `<`는 비활성화 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 | +| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 영역 교체 (5139~5182행) | +| 참고 | `frontend/components/common/Pagination.tsx` | 기존 공통 페이지네이션 (이번에 수정 안 함) | + +--- + +## 기술 참고 + +### 단락 계산 공식 + +``` +groupSize = 10 (기본값) +currentGroupIndex = Math.floor((currentPage - 1) / groupSize) +groupStartPage = currentGroupIndex * groupSize + 1 +groupEndPage = Math.min(groupStartPage + groupSize - 1, totalPages) + +lastGroupIndex = Math.floor((totalPages - 1) / groupSize) +lastGroupStartPage = lastGroupIndex * groupSize + 1 + +isFirstGroup = currentGroupIndex === 0 +isLastGroup = currentGroupIndex === lastGroupIndex +``` + +### 고정 슬롯 배열 생성 + +``` +slots = [groupStart, groupStart+1, ..., groupEnd, null, null, ...] (총 groupSize개) +예: 단락 31~38 → [31, 32, 33, 34, 35, 36, 37, 38, null, null] +``` + +### handlePageChange 호출 흐름 + +``` +PageGroupNav onPageChange(page) + → TableListComponent handlePageChange(newPage) + → setCurrentPage(newPage) + → useEffect 트리거 → 백엔드 API 재호출 (page 파라미터 변경) +``` + +- handlePageChange는 `setCurrentPage`만 호출. `onConfigChange` 전파는 제거됨 (pageSize/currentPage는 세션 전용) +- handlePageChange는 기존 함수 그대로 사용. PageGroupNav가 올바른 page 값을 전달하기만 하면 됨 + +--- + +## 추가 결정: 표시갯수(pageSize) 캐시 정책 + +### 8. pageSize는 세션 전용, DB에 저장 안 함 + +- **결정**: pageSize를 `onConfigChange`로 부모/DB에 전파하지 않음. sessionStorage에만 탭별로 저장 +- **근거**: pageSize는 일시적 탐색 설정이지 영구 화면 설정이 아님. DB에 저장하면 다른 사용자에게도 영향이 가고, 새로고침 시 의도치 않은 값이 남음 +- **F5 정책**: 활성 탭은 캐시 삭제 → 기본값 20으로 fresh start. 비활성 탭은 캐시 유지 + +### 9. 테이블 캐시는 탭별 격리 (탭 ID 스코프) + +- **결정**: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*` 키를 `{prefix}_{tabId}_{tableName}` 구조로 변경 +- **근거**: 같은 테이블이 여러 탭에서 열릴 수 있음. 탭 구분 없으면 "활성 탭 캐시만 삭제" 불가능 +- **구현**: `useTabId()` 훅으로 현재 탭 ID 접근. `clearTabCache(tabId)`에서 해당 탭의 모든 관련 키 일괄 삭제 + +### 10. localStorage vs sessionStorage 분류 + +- **결정**: 탭별 캐시는 sessionStorage, 사용자 설정은 localStorage +- **근거**: 탭별 캐시(컬럼 너비 캐시, 필터, 그룹, pageSize)는 탭 닫으면 무의미. 사용자 설정(컬럼 가시성, 순서, 정렬)은 사용자가 의도적으로 변경한 환경설정이므로 세션 간 보존 +- **분류**: + - sessionStorage: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*` + - localStorage: `table_column_visibility_*`, `table_sort_state_*`, `table_column_order_*` diff --git a/docs/ycshin-node/PGN[체크]-페이징-단락이동.md b/docs/ycshin-node/PGN[체크]-페이징-단락이동.md new file mode 100644 index 00000000..46b94395 --- /dev/null +++ b/docs/ycshin-node/PGN[체크]-페이징-단락이동.md @@ -0,0 +1,90 @@ +# [체크리스트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 + +> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [맥락노트](./PGN[맥락]-페이징-단락이동.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 4단계 완료 + +--- + +## 구현 체크리스트 + +### 1단계: PageGroupNav 공통 컴포넌트 생성 + +- [x] `frontend/components/common/PageGroupNav.tsx` 파일 생성 +- [x] `PageGroupNavProps` 인터페이스 정의 (currentPage, totalPages, onPageChange, disabled, groupSize) +- [x] 단락 계산 로직 구현 (currentGroupIndex, groupStartPage, lastGroupIndex 등) +- [x] 10개 고정 슬롯 배열 생성 (빈 슬롯은 null) +- [x] `<<` 첫 단락 버튼 (isFirstGroup일 때 비활성화) +- [x] `<` 이전 단락 버튼 (isFirstGroup일 때 비활성화) +- [x] 페이지 번호 버튼 렌더링 (현재 페이지 variant="default", 나머지 variant="outline") +- [x] 빈 슬롯 렌더링 (동일 크기 빈 div) +- [x] `>` 다음 단락 버튼 (isLastGroup일 때 비활성화) +- [x] `>>` 마지막 단락 버튼 (isLastGroup일 때 비활성화, 마지막 단락 첫 페이지로 이동) +- [x] 고정 너비 스타일 적용 (h-8 w-8 sm:h-9 sm:w-9) +- [x] totalPages가 0 또는 1일 때 엣지 케이스 처리 + +### 2단계: v2-table-list 통합 + +- [x] `TableListComponent.tsx`에 `PageGroupNav` import 추가 +- [x] `paginationJSX`의 중앙 컨트롤 영역(5139~5182행)을 `` 호출로 교체 +- [x] props 연결: currentPage, totalPages, handlePageChange, loading +- [x] 좌측(페이지크기 입력) 영역 변경 없음 확인 +- [x] 우측(내보내기/새로고침) 영역 변경 없음 확인 + +### 3단계: 검증 + +- [x] 품목정보 화면에서 페이지 번호 클릭 동작 확인 +- [x] `< >` 단락 이동 동작 확인 (1~10 → 11~20 → ...) +- [x] `<< >>` 첫/끝 단락 이동 동작 확인 +- [x] `>>` 클릭 시 마지막 단락의 첫 페이지 선택 확인 (마지막 페이지가 아님) +- [x] 첫 단락에서 `<< <` 비활성화 확인 +- [x] 마지막 단락에서 `> >>` 비활성화 확인 +- [x] 고정 슬롯: 단락 이동 시 버튼 위치 변동 없음 확인 +- [x] 고정 너비: 1자리/2자리 숫자에서 버튼 크기 동일 확인 +- [x] 마지막 단락이 10개 미만일 때 빈 슬롯으로 위치 고정 확인 +- [x] totalPages가 1일 때 정상 동작 확인 (단일 페이지) +- [x] 로딩 중 모든 버튼 비활성화 확인 +- [x] 페이지 크기 변경 시 첫 페이지로 리셋 확인 + +### 4단계: 정리 + +- [x] 린트 에러 없음 확인 +- [x] 이 체크리스트 완료 표시 업데이트 + +### 5단계: 표시갯수(pageSize) 캐시 정책 + +- [x] 표시갯수 입력 시 onChange → 표시만 변경, 실제 적용은 onBlur/Enter +- [x] 입력 필드 값 string 타입으로 변경 (백스페이스로 비우기 가능) +- [x] 표시갯수 변경 시 1페이지로 리셋 + 데이터 정상 로드 +- [x] onConfigChange로 DB/부모 전파 제거 (pageSize는 세션 전용) +- [x] localStorage → sessionStorage 전환 (탭 닫으면 자동 소멸) +- [x] 키를 탭 ID 스코프로 변경 (`pageSize_{tabId}_{tableName}`) +- [x] F5 새로고침 시 활성 탭 캐시 삭제 → 기본값 20 초기화 +- [x] 탭 바 새로고침 버튼 시 캐시 삭제 → 기본값 20 초기화 +- [x] 비활성 탭 캐시 유지 (탭 전환 시 복원) + +### 6단계: 테이블 캐시 탭 격리 + +- [x] tableStateKey 탭 ID 스코프 (`tableState_{tabId}_{tableName}`) + sessionStorage +- [x] filterSettingKey 탭 ID 스코프 (`filterSettings_{tabId}_{base}`) + sessionStorage +- [x] groupSettingKey 탭 ID 스코프 (`groupSettings_{tabId}_{base}`) + sessionStorage +- [x] clearTabCache 확장 (tableState_/pageSize_/filterSettings_/groupSettings_ 일괄 삭제) +- [x] TabContent.tsx 모듈 레벨 플래그로 F5 감지 → 활성 탭 캐시만 삭제 +- [x] tabStore.refreshTab에 clearTabCache 추가 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-11 | 1단계(PageGroupNav 생성) + 2단계(v2-table-list 통합) + 4단계(린트) 완료. 3단계(수동 검증)은 브라우저에서 확인 필요 | +| 2026-03-11 | 추가 개선: 선택 페이지 강조(ring + font-bold), 빈 슬롯 cursor-default 적용. 3단계 검증 완료. 전체 완료 | +| 2026-03-11 | 5단계: pageSize 입력 UX 개선 + 캐시 정책 (sessionStorage + 탭 스코프 + F5/탭새로고침 초기화) | +| 2026-03-11 | 6단계: 테이블 전체 캐시를 탭별 격리 (localStorage → sessionStorage + 탭 ID 스코프) | diff --git a/docs/ycshin-node/탭_시스템_설계.md b/docs/ycshin-node/탭_시스템_설계.md index 50ca2468..99ce4a8d 100644 --- a/docs/ycshin-node/탭_시스템_설계.md +++ b/docs/ycshin-node/탭_시스템_설계.md @@ -123,15 +123,49 @@ - [ ] 비활성 탭: 캐시에서 복원 - [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제 -### 6-3. 캐시 키 관리 (clearTabStateCache) +### 6-3. 캐시 키 관리 (clearTabCache) 탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거: -- `tab-cache-{screenId}-{menuObjid}` -- `page-scroll-{screenId}-{menuObjid}` -- `tsp-{screenId}-*`, `table-state-{screenId}-*` -- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*` -- `bom-tree-{screenId}-*` -- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}` +- `tab-cache-{tabId}` (폼/스크롤 캐시) +- `tableState_{tabId}_*` (컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터) +- `pageSize_{tabId}_*` (표시갯수) +- `filterSettings_{tabId}_*` (검색 필터 설정) +- `groupSettings_{tabId}_*` (그룹 설정) + +### 6-4. F5 새로고침 시 캐시 정책 (구현 완료) + +| 탭 상태 | F5 시 동작 | +|---------|-----------| +| **활성 탭** | `clearTabCache(activeTabId)` → 캐시 전체 삭제 → fresh API 호출 | +| **비활성 탭** | 캐시 유지 → 탭 전환 시 복원 | + +**구현 방식**: `TabContent.tsx`에 모듈 레벨 플래그(`hasHandledPageLoad`)를 사용. +전체 페이지 로드 시 모듈이 재실행되어 플래그가 `false`로 리셋. +SPA 내비게이션에서는 모듈이 유지되므로 `true`로 남아 중복 실행 방지. + +### 6-5. 탭 바 새로고침 버튼 (구현 완료) + +`tabStore.refreshTab(tabId)` 호출 시: +1. `clearTabCache(tabId)` → 해당 탭의 모든 sessionStorage 캐시 삭제 +2. `refreshKey` 증가 → 컴포넌트 리마운트 → 기본값으로 초기화 + +### 6-6. 저장소 분류 기준 (구현 완료) + +| 데이터 성격 | 저장소 | 키 구조 | 비고 | +|------------|--------|---------|------| +| 탭별 캐시 | sessionStorage | `{prefix}_{tabId}_{tableName}` | 탭 닫으면 소멸 | +| 사용자 설정 | localStorage | `{prefix}_{tableName}_{userId}` | 세션 간 보존 | + +**탭별 캐시 (sessionStorage)**: +- tableState: 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터 +- pageSize: 표시갯수 +- filterSettings: 검색 필터 설정 +- groupSettings: 그룹 설정 + +**사용자 설정 (localStorage)**: +- table_column_visibility: 컬럼 표시/숨김 +- table_sort_state: 정렬 상태 +- table_column_order: 컬럼 순서 --- diff --git a/frontend/components/common/PageGroupNav.tsx b/frontend/components/common/PageGroupNav.tsx new file mode 100644 index 00000000..dc59b35e --- /dev/null +++ b/frontend/components/common/PageGroupNav.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const DEFAULT_GROUP_SIZE = 10; + +interface PageGroupNavProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + disabled?: boolean; + groupSize?: number; +} + +export function PageGroupNav({ + currentPage, + totalPages, + onPageChange, + disabled = false, + groupSize = DEFAULT_GROUP_SIZE, +}: PageGroupNavProps) { + const safeTotal = Math.max(1, totalPages); + const currentGroupIndex = Math.floor((currentPage - 1) / groupSize); + const groupStartPage = currentGroupIndex * groupSize + 1; + + const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize); + const lastGroupStartPage = lastGroupIndex * groupSize + 1; + + const isFirstGroup = currentGroupIndex === 0; + const isLastGroup = currentGroupIndex === lastGroupIndex; + + const slots: (number | null)[] = []; + for (let i = 0; i < groupSize; i++) { + const page = groupStartPage + i; + slots.push(page <= safeTotal ? page : null); + } + + return ( +
+ {/* << 첫 단락 */} + + + {/* < 이전 단락 */} + + + {/* 페이지 번호 (고정 슬롯) */} + {slots.map((page, idx) => + page !== null ? ( + + ) : ( +
+ ), + )} + + {/* > 다음 단락 */} + + + {/* >> 마지막 단락 */} + +
+ ); +} diff --git a/frontend/components/layout/TabContent.tsx b/frontend/components/layout/TabContent.tsx index 0c1fabfb..836f3bcd 100644 --- a/frontend/components/layout/TabContent.tsx +++ b/frontend/components/layout/TabContent.tsx @@ -19,6 +19,11 @@ import { clearTabCache, } from "@/lib/tabStateCache"; +// 페이지 로드(F5 새로고침) 감지용 모듈 레벨 플래그. +// 전체 페이지 로드 시 모듈이 재실행되어 false로 리셋된다. +// SPA 내비게이션에서는 모듈이 유지되므로 true로 남는다. +let hasHandledPageLoad = false; + export function TabContent() { const tabs = useTabStore(selectTabs); const activeTabId = useTabStore(selectActiveTabId); @@ -39,6 +44,13 @@ export function TabContent() { // 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함) const pathCacheRef = useRef>(new WeakMap()); + // 페이지 로드(F5) 시 활성 탭 캐시만 삭제 → fresh API 호출 유도 + // 비활성 탭 캐시는 유지하여 탭 전환 시 복원 + if (!hasHandledPageLoad && activeTabId) { + hasHandledPageLoad = true; + clearTabCache(activeTabId); + } + if (activeTabId) { mountedTabIdsRef.current.add(activeTabId); } diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index cc36afd6..0f01370f 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2,15 +2,15 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { TableListConfig, ColumnConfig } from "./types"; -import { WebType } from "@/types/common"; +import type { WebType } from "@/types/common"; import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; -import { codeCache } from "@/lib/caching/codeCache"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { getFullImageUrl } from "@/lib/api/client"; import { getFilePreviewUrl } from "@/lib/api/file"; import { Button } from "@/components/ui/button"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; +import { useTabId } from "@/contexts/TabIdContext"; // 🖼️ 테이블 셀 이미지 썸네일 컴포넌트 // objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용 @@ -155,13 +155,8 @@ declare global { import { ChevronLeft, ChevronRight, - ChevronsLeft, - ChevronsRight, RefreshCw, - ArrowUp, - ArrowDown, TableIcon, - Settings, X, Layers, ChevronDown, @@ -174,14 +169,14 @@ import { Edit, CheckSquare, Trash2, - Lock, } from "lucide-react"; import * as XLSX from "xlsx"; -import { FileText, ChevronRightIcon } from "lucide-react"; +import { FileText } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; +import { PageGroupNav } from "@/components/common/PageGroupNav"; import { tableDisplayStore } from "@/stores/tableDisplayStore"; import { Dialog, @@ -193,7 +188,6 @@ import { } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; -import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; import { TableOptionsModal } from "@/components/common/TableOptionsModal"; @@ -201,7 +195,7 @@ import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; -import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; +import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; // ======================================== @@ -404,6 +398,8 @@ export const TableListComponent: React.FC = ({ // 디버그 로그 제거 (성능 최적화) + const currentTabId = useTabId(); + const buttonColor = component.style?.labelColor || "#212121"; const buttonTextColor = component.config?.buttonTextColor || "#ffffff"; @@ -694,7 +690,38 @@ export const TableListComponent: React.FC = ({ const hasInitializedSort = useRef(false); const [columnLabels, setColumnLabels] = useState>({}); const [tableLabel, setTableLabel] = useState(""); - const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); + const pageSizeKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + if (currentTabId) return `pageSize_${currentTabId}_${tableConfig.selectedTable}`; + return `pageSize_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable, currentTabId]); + + const [localPageSize, setLocalPageSize] = useState(() => { + const key = + currentTabId && tableConfig.selectedTable + ? `pageSize_${currentTabId}_${tableConfig.selectedTable}` + : tableConfig.selectedTable + ? `pageSize_${tableConfig.selectedTable}` + : null; + if (key) { + const val = sessionStorage.getItem(key); + if (val) return Number(val); + } + return 20; + }); + const [pageSizeInputValue, setPageSizeInputValue] = useState(() => { + const key = + currentTabId && tableConfig.selectedTable + ? `pageSize_${currentTabId}_${tableConfig.selectedTable}` + : tableConfig.selectedTable + ? `pageSize_${tableConfig.selectedTable}` + : null; + if (key) { + const val = sessionStorage.getItem(key); + if (val) return val; + } + return "20"; + }); const [displayColumns, setDisplayColumns] = useState([]); const [columnMeta, setColumnMeta] = useState< Record @@ -804,11 +831,12 @@ export const TableListComponent: React.FC = ({ const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState(null); const [isColumnDragEnabled] = useState((tableConfig as any).enableColumnDrag ?? true); - // 🆕 State Persistence: 통합 상태 키 + // 🆕 State Persistence: 통합 상태 키 (탭 ID 스코프 + sessionStorage) const tableStateKey = useMemo(() => { if (!tableConfig.selectedTable) return null; + if (currentTabId) return `tableState_${currentTabId}_${tableConfig.selectedTable}`; return `tableState_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable]); + }, [tableConfig.selectedTable, currentTabId]); // 🆕 Real-Time Updates 관련 상태 const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); @@ -1612,7 +1640,7 @@ export const TableListComponent: React.FC = ({ setError(null); try { - const page = tableConfig.pagination?.currentPage || currentPage; + const page = currentPage || tableConfig.pagination?.currentPage || 1; const pageSize = localPageSize; // 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용 const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined; @@ -1910,12 +1938,6 @@ export const TableListComponent: React.FC = ({ const handlePageChange = (newPage: number) => { if (newPage < 1 || newPage > totalPages) return; setCurrentPage(newPage); - if (tableConfig.pagination) { - tableConfig.pagination.currentPage = newPage; - } - if (onConfigChange) { - onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } }); - } }; const handleSort = (column: string) => { @@ -2952,12 +2974,11 @@ export const TableListComponent: React.FC = ({ headerFilters: Object.fromEntries( Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), ), - pageSize: localPageSize, timestamp: Date.now(), }; try { - localStorage.setItem(tableStateKey, JSON.stringify(state)); + sessionStorage.setItem(tableStateKey, JSON.stringify(state)); } catch (error) { console.error("❌ 테이블 상태 저장 실패:", error); } @@ -2972,7 +2993,6 @@ export const TableListComponent: React.FC = ({ frozenColumnCount, showGridLines, headerFilters, - localPageSize, ]); // 🆕 State Persistence: 통합 상태 복원 @@ -2980,7 +3000,7 @@ export const TableListComponent: React.FC = ({ if (!tableStateKey) return; try { - const saved = localStorage.getItem(tableStateKey); + const saved = sessionStorage.getItem(tableStateKey); if (!saved) return; const state = JSON.parse(saved); @@ -2991,7 +3011,6 @@ export const TableListComponent: React.FC = ({ if (state.sortDirection) setSortDirection(state.sortDirection); if (state.groupByColumns) setGroupByColumns(state.groupByColumns); if (state.frozenColumns) { - // 체크박스 컬럼이 항상 포함되도록 보장 const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null; const restoredFrozenColumns = checkboxColumn && !state.frozenColumns.includes(checkboxColumn) @@ -2999,7 +3018,7 @@ export const TableListComponent: React.FC = ({ : state.frozenColumns; setFrozenColumns(restoredFrozenColumns); } - if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원 + if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); if (state.headerFilters) { const filters: Record> = {}; @@ -3018,7 +3037,7 @@ export const TableListComponent: React.FC = ({ if (!tableStateKey) return; try { - localStorage.removeItem(tableStateKey); + sessionStorage.removeItem(tableStateKey); setColumnWidths({}); setColumnOrder([]); setSortColumn(null); @@ -3027,6 +3046,8 @@ export const TableListComponent: React.FC = ({ setFrozenColumns([]); setShowGridLines(true); setHeaderFilters({}); + setLocalPageSize(20); + setPageSizeInputValue("20"); toast.success("테이블 설정이 초기화되었습니다."); } catch (error) { console.error("❌ 테이블 상태 초기화 실패:", error); @@ -4442,33 +4463,36 @@ export const TableListComponent: React.FC = ({ // useEffect 훅 // ======================================== - // 필터 설정 localStorage 키 생성 (화면별로 독립적) + // 필터 설정 sessionStorage 키 생성 (탭 ID 스코프) const filterSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return screenId - ? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}` - : `tableList_filterSettings_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable, screenId]); + const base = screenId + ? `${tableConfig.selectedTable}_screen_${screenId}` + : tableConfig.selectedTable; + if (currentTabId) return `filterSettings_${currentTabId}_${base}`; + return `filterSettings_${base}`; + }, [tableConfig.selectedTable, screenId, currentTabId]); - // 그룹 설정 localStorage 키 생성 (화면별로 독립적) + // 그룹 설정 sessionStorage 키 생성 (탭 ID 스코프) const groupSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return screenId - ? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}` - : `tableList_groupSettings_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable, screenId]); + const base = screenId + ? `${tableConfig.selectedTable}_screen_${screenId}` + : tableConfig.selectedTable; + if (currentTabId) return `groupSettings_${currentTabId}_${base}`; + return `groupSettings_${base}`; + }, [tableConfig.selectedTable, screenId, currentTabId]); // 저장된 필터 설정 불러오기 useEffect(() => { if (!filterSettingKey || visibleColumns.length === 0) return; try { - const saved = localStorage.getItem(filterSettingKey); + const saved = sessionStorage.getItem(filterSettingKey); if (saved) { const savedFilters = JSON.parse(saved); setVisibleFilterColumns(new Set(savedFilters)); } else { - // 초기값: 빈 Set (아무것도 선택 안 함) setVisibleFilterColumns(new Set()); } } catch (error) { @@ -4482,7 +4506,7 @@ export const TableListComponent: React.FC = ({ if (!filterSettingKey) return; try { - localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); + sessionStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); setIsFilterSettingOpen(false); toast.success("검색 필터 설정이 저장되었습니다"); @@ -4537,7 +4561,7 @@ export const TableListComponent: React.FC = ({ if (!groupSettingKey) return; try { - localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + sessionStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); } catch (error) { console.error("그룹 설정 저장 실패:", error); } @@ -4617,7 +4641,7 @@ export const TableListComponent: React.FC = ({ setGroupByColumns([]); setCollapsedGroups(new Set()); if (groupSettingKey) { - localStorage.removeItem(groupSettingKey); + sessionStorage.removeItem(groupSettingKey); } toast.success("그룹이 해제되었습니다"); }, [groupSettingKey]); @@ -4801,7 +4825,7 @@ export const TableListComponent: React.FC = ({ if (!groupSettingKey || visibleColumns.length === 0) return; try { - const saved = localStorage.getItem(groupSettingKey); + const saved = sessionStorage.getItem(groupSettingKey); if (saved) { const savedGroups = JSON.parse(saved); setGroupByColumns(savedGroups); @@ -5100,13 +5124,11 @@ export const TableListComponent: React.FC = ({ // 페이지 크기 변경 핸들러 const handlePageSizeChange = (newSize: number) => { + setPageSizeInputValue(String(newSize)); setLocalPageSize(newSize); - setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 이동 - if (onConfigChange) { - onConfigChange({ - ...tableConfig, - pagination: { ...tableConfig.pagination, pageSize: newSize, currentPage: 1 }, - }); + setCurrentPage(1); + if (pageSizeKey) { + sessionStorage.setItem(pageSizeKey, String(newSize)); } }; @@ -5121,65 +5143,33 @@ export const TableListComponent: React.FC = ({ type="number" min={1} max={10000} - value={localPageSize} + value={pageSizeInputValue} onChange={(e) => { - const value = Math.min(10000, Math.max(1, Number(e.target.value) || 1)); - handlePageSizeChange(value); + setPageSizeInputValue(e.target.value); }} onBlur={(e) => { - // 포커스 잃을 때 유효 범위로 조정 const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10)); handlePageSizeChange(value); }} + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = Math.min(10000, Math.max(1, Number((e.target as HTMLInputElement).value) || 10)); + handlePageSizeChange(value); + (e.target as HTMLInputElement).blur(); + } + }} className="border-input bg-background focus:ring-ring h-7 w-14 rounded-md border px-2 text-center text-xs focus:ring-1 focus:outline-none sm:h-8 sm:w-16" />
{/* 중앙 페이지네이션 컨트롤 */} -
- - - - - {currentPage} / {totalPages || 1} - - - - -
+ {/* 우측 버튼 그룹 */}
@@ -5254,6 +5244,7 @@ export const TableListComponent: React.FC = ({ exportToExcel, exportToPdf, localPageSize, + pageSizeInputValue, onConfigChange, tableConfig, ]); diff --git a/frontend/lib/tabStateCache.ts b/frontend/lib/tabStateCache.ts index bb33de3d..af3e8c1a 100644 --- a/frontend/lib/tabStateCache.ts +++ b/frontend/lib/tabStateCache.ts @@ -78,13 +78,30 @@ export function loadTabCache(tabId: string): TabCacheData | null { } /** - * 특정 탭의 캐시 삭제 + * 특정 탭의 캐시 삭제. + * tab-cache-{tabId} 외에도 테이블 관련 키(tableState_, pageSize_, filterSettings_, groupSettings_)를 일괄 제거한다. */ export function clearTabCache(tabId: string): void { if (typeof window === "undefined") return; try { sessionStorage.removeItem(CACHE_PREFIX + tabId); + + const suffix = `_${tabId}_`; + const keysToRemove: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if ( + key && + (key.startsWith("tableState" + suffix) || + key.startsWith("pageSize" + suffix) || + key.startsWith("filterSettings" + suffix) || + key.startsWith("groupSettings" + suffix)) + ) { + keysToRemove.push(key); + } + } + keysToRemove.forEach((k) => sessionStorage.removeItem(k)); } catch { // ignore } diff --git a/frontend/stores/tabStore.ts b/frontend/stores/tabStore.ts index ea0b8c5b..4414a3da 100644 --- a/frontend/stores/tabStore.ts +++ b/frontend/stores/tabStore.ts @@ -149,6 +149,7 @@ export const useTabStore = create()( }, refreshTab: (tabId) => { + clearTabCache(tabId); set((state) => ({ refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 }, })); From d9611f234e1157f68e9e6fb1c8312a86c076490c Mon Sep 17 00:00:00 2001 From: syc0123 Date: Wed, 11 Mar 2026 14:05:38 +0900 Subject: [PATCH 16/25] docs: Update pagination navigation documentation and remove obsolete components - Deleted the outdated `PageGroupNav` component and its related documentation. - Introduced a new document for the direct input navigation feature in pagination, detailing the rationale for the change and the new user experience. - Updated the checklist to reflect the completion of the new pagination input feature and its implementation steps. These changes enhance the clarity and usability of the pagination system in the project. --- docs/ycshin-node/PGN[계획]-페이징-단락이동.md | 389 ------------------ docs/ycshin-node/PGN[계획]-페이징-직접입력.md | 128 ++++++ docs/ycshin-node/PGN[맥락]-페이징-단락이동.md | 128 ------ docs/ycshin-node/PGN[맥락]-페이징-직접입력.md | 115 ++++++ docs/ycshin-node/PGN[체크]-페이징-단락이동.md | 90 ---- docs/ycshin-node/PGN[체크]-페이징-직접입력.md | 73 ++++ frontend/components/common/PageGroupNav.tsx | 109 ----- .../v2-table-list/TableListComponent.tsx | 208 ++++++---- 8 files changed, 452 insertions(+), 788 deletions(-) delete mode 100644 docs/ycshin-node/PGN[계획]-페이징-단락이동.md create mode 100644 docs/ycshin-node/PGN[계획]-페이징-직접입력.md delete mode 100644 docs/ycshin-node/PGN[맥락]-페이징-단락이동.md create mode 100644 docs/ycshin-node/PGN[맥락]-페이징-직접입력.md delete mode 100644 docs/ycshin-node/PGN[체크]-페이징-단락이동.md create mode 100644 docs/ycshin-node/PGN[체크]-페이징-직접입력.md delete mode 100644 frontend/components/common/PageGroupNav.tsx diff --git a/docs/ycshin-node/PGN[계획]-페이징-단락이동.md b/docs/ycshin-node/PGN[계획]-페이징-단락이동.md deleted file mode 100644 index 4e39f276..00000000 --- a/docs/ycshin-node/PGN[계획]-페이징-단락이동.md +++ /dev/null @@ -1,389 +0,0 @@ -# [계획서] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 - -> 관련 문서: [맥락노트](./PGN[맥락]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md) - -## 개요 - -페이지네이션의 핵심 컨트롤(`<< < [번호들] > >>`)을 **재사용 가능한 공통 컴포넌트 `PageGroupNav`**로 분리합니다. -현재의 단순 `1 / n` 텍스트 표시를 **10개 단위 페이지 번호 버튼 그룹**으로 교체하고, `< >` 버튼을 **단락(그룹) 이동**으로, `<< >>` 버튼을 **첫/끝 단락 이동**으로 변경합니다. - -### 접근 전략: C안 (핵심 컨트롤 분리 + 단계적 적용) - -- **1단계 (이번 작업)**: `PageGroupNav.tsx` 생성 + v2-table-list에 적용 -- **2단계 (별도 작업)**: 나머지 페이징 사용처에 점진적 적용 - -이 전략을 선택한 이유: -- 레이아웃을 강제하지 않는 순수 컨트롤 컴포넌트 → 어디든 조합 가능 -- v2-table-list에서 먼저 검증 후 확산 → 리스크 최소화 -- 2단계는 `import` 한 줄로 적용 가능 → 미래 작업 비용 최소 - ---- - -## 현재 동작 - -### 페이지네이션 UI - -``` -[<<] [<] 1 / 38 [>] [>>] -``` - -| 버튼 | 현재 동작 | -|------|----------| -| `<<` | 첫 페이지(1)로 이동 | -| `<` | 이전 페이지(`currentPage - 1`)로 이동 | -| 중앙 | `currentPage / totalPages` 텍스트 표시 (클릭 불가) | -| `>` | 다음 페이지(`currentPage + 1`)로 이동 | -| `>>` | 마지막 페이지(`totalPages`)로 이동 | - -### 비활성화 조건 - -- `<<` `<` : `currentPage === 1` -- `>` `>>` : `currentPage >= totalPages` - -### 현재 코드 (TableListComponent.tsx, 5139~5182행) - -```tsx -{/* 중앙 페이지네이션 컨트롤 */} -
-
-``` - ---- - -## 변경 후 동작 - -### 페이지네이션 UI - -``` -[<<] [<] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [>] [>>] -``` - -| 버튼 | 변경 후 동작 | -|------|-------------| -| `<<` | **첫 번째 단락**으로 이동 (1페이지 선택) | -| `<` | **이전 단락**의 첫 페이지로 이동 | -| 중앙 | 현재 단락의 페이지 번호 버튼 나열 (클릭으로 해당 페이지 이동) | -| `>` | **다음 단락**의 첫 페이지로 이동 | -| `>>` | **마지막 단락**의 첫 페이지로 이동 (마지막 페이지가 아님) | - -### 비활성화 조건 - -- `<<` `<` : **첫 번째 단락**(1~10)을 보고 있을 때 -- `>` `>>` : **마지막 단락**을 보고 있을 때 - -### 단락(그룹) 개념 - -- 10개 단위로 페이지를 묶어 하나의 "단락"으로 취급 -- 단락 1: 1~10, 단락 2: 11~20, 단락 3: 21~30, ... -- 마지막 단락은 10개 미만일 수 있음 (예: 31~38) - -### 고정 슬롯 레이아웃 (핵심 제약) - -**페이지 번호 영역은 항상 10개 슬롯을 고정 렌더링한다.** - -- 각 슬롯은 동일한 고정 너비(`w-9` 등)를 가짐 -- 1자리(`1`)든 2자리(`11`)든 3자리(`100`)든 버튼 너비가 동일 -- 마지막 단락이 10개 미만이면 남은 슬롯은 빈 공간(투명 placeholder)으로 채움 -- 이로써 `< >` 버튼을 연속 클릭해도 **번호 버튼과 화살표 버튼의 위치가 절대 변하지 않음** - -``` -단락 1~10: [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] ← 10개 모두 채움 -단락 11~20: [11][12][13][14][15][16][17][18][19][20] ← 너비 동일 -단락 31~38: [31][32][33][34][35][36][37][38][ ][ ] ← 빈 슬롯 2개로 위치 고정 -``` - ---- - -## 시각적 동작 예시 - -총 38페이지 기준: - -### 단락별 페이지 번호 표시 - -| 현재 페이지 | 표시 번호 | `<<` `<` | `>` `>>` | -|-------------|-----------|----------|----------| -| 1 | **[1]** [2] [3] ... [10] | 비활성 | 활성 | -| 5 | [1] [2] [3] [4] **[5]** [6] [7] [8] [9] [10] | 비활성 | 활성 | -| 10 | [1] [2] [3] [4] [5] [6] [7] [8] [9] **[10]** | 비활성 | 활성 | -| 11 | **[11]** [12] [13] ... [20] | 활성 | 활성 | -| 25 | [21] [22] [23] [24] **[25]** [26] [27] [28] [29] [30] | 활성 | 활성 | -| 31 | **[31]** [32] [33] ... [38] [ ] [ ] | 활성 | 비활성 | -| 38 | [31] [32] [33] [34] [35] [36] [37] **[38]** [ ] [ ] | 활성 | 비활성 | - -### 버튼 클릭 시나리오 - -| 현재 상태 | 클릭 | 결과 | -|----------|------|------| -| 5페이지 (단락 1~10) | `>` | 11페이지 선택, 단락 11~20 표시 | -| 15페이지 (단락 11~20) | `<` | 1페이지 선택, 단락 1~10 표시 | -| 15페이지 (단락 11~20) | `>>` | 31페이지 선택, 단락 31~38 표시 | -| 35페이지 (단락 31~38) | `<<` | 1페이지 선택, 단락 1~10 표시 | -| 5페이지 (단락 1~10) | `[7]` | 7페이지 선택, 단락 1~10 유지 | - ---- - -## 아키텍처 - -### 컴포넌트 구조 (C안) - -```mermaid -flowchart TD - subgraph PageGroupNav ["PageGroupNav.tsx (새 공통 컴포넌트)"] - Props["props: currentPage, totalPages, onPageChange, disabled, groupSize"] - Logic["단락 계산 + 고정 슬롯 + 비활성화"] - UI["<< < [번호들] > >>"] - Props --> Logic --> UI - end - - subgraph Phase1 ["1단계: 이번 작업"] - V2Table["v2-table-list paginationJSX"] - end - - subgraph Phase2 ["2단계: 별도 작업 (미래)"] - TableList["table-list (구형)"] - PaginationTsx["Pagination.tsx (관리자)"] - DrillDown["DrillDown 모달"] - Mail["메일 수신/발송"] - Others["감사로그, 배치, DataTable 등"] - end - - PageGroupNav --> V2Table - PageGroupNav -.-> TableList - PageGroupNav -.-> PaginationTsx - PageGroupNav -.-> DrillDown - PageGroupNav -.-> Mail - PageGroupNav -.-> Others -``` - -### v2-table-list 내부 데이터 흐름 - -```mermaid -flowchart TD - A["currentPage, totalPages (state)"] --> B[PageGroupNav] - B -->|onPageChange| C[handlePageChange] - C --> D[setCurrentPage + onConfigChange] - D --> E[백엔드 API 호출] - E --> F[데이터 갱신] - F --> A -``` - -### v2-table-list 페이징 바 레이아웃 (변경 없음) - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ [페이지크기 입력] │ << < [PageGroupNav] > >> │ [내보내기][새로고침] │ -│ 좌측(유지) │ 중앙(교체) │ 우측(유지) │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 변경 대상 파일 - -### 1단계 (이번 작업) - -| 구분 | 파일 | 변경 내용 | 변경 규모 | -|------|------|----------|----------| -| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 | 약 80줄 신규 | -| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | `paginationJSX` 중앙 영역을 `PageGroupNav`로 교체 (5139~5182행) | 약 40줄 → 5줄 | - -- `handlePageChange` 함수는 기존 것을 그대로 사용 (동작 변경 없음) -- 좌측(페이지크기 입력), 우측(내보내기/새로고침) 영역은 변경하지 않음 -- 백엔드 변경 없음, DB 변경 없음 - -### 1단계 적용 범위 - -v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용: -- 품목정보, 거래처관리, 판매품목정보, 설비정보 등 - -### 2단계 적용 대상 (별도 작업, 미래) - -| 사용처 | 파일 | 현재 페이징 형태 | -|--------|------|----------------| -| table-list (구형) | `lib/registry/components/table-list/TableListComponent.tsx` | `<< < 현재/총 > >>` | -| 공통 Pagination | `components/common/Pagination.tsx` | 번호 ±2 + `...` | -| 피벗 드릴다운 | `lib/registry/components/pivot-grid/components/DrillDownModal.tsx` | `<< < 현재/총 > >>` | -| v2 피벗 드릴다운 | `lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx` | 동일 | -| 메일 수신함 | `app/(main)/admin/automaticMng/mail/receive/page.tsx` | 번호 5개 클릭 | -| 메일 발송함 | `app/(main)/admin/automaticMng/mail/sent/page.tsx` | 동일 | -| 감사 로그 | `app/(main)/admin/audit-log/page.tsx` | `< 현재/총 >` | -| 배치 관리 | `app/(main)/admin/automaticMng/batchmngList/page.tsx` | 번호 5개 클릭 | -| DataTable | `components/common/DataTable.tsx` | `<< < > >>` + 텍스트 | -| FlowWidget | `components/screen/widgets/FlowWidget.tsx` | shadcn Pagination | - ---- - -## 코드 설계 - -### PageGroupNav.tsx 공통 컴포넌트 - -```tsx -// frontend/components/common/PageGroupNav.tsx -"use client"; - -import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -const DEFAULT_GROUP_SIZE = 10; - -interface PageGroupNavProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - disabled?: boolean; - groupSize?: number; -} - -export function PageGroupNav({ - currentPage, - totalPages, - onPageChange, - disabled = false, - groupSize = DEFAULT_GROUP_SIZE, -}: PageGroupNavProps) { - const safeTotal = Math.max(1, totalPages); - const currentGroupIndex = Math.floor((currentPage - 1) / groupSize); - const groupStartPage = currentGroupIndex * groupSize + 1; - - const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize); - const lastGroupStartPage = lastGroupIndex * groupSize + 1; - - const isFirstGroup = currentGroupIndex === 0; - const isLastGroup = currentGroupIndex === lastGroupIndex; - - // 10개 고정 슬롯 배열 - const slots: (number | null)[] = []; - for (let i = 0; i < groupSize; i++) { - const page = groupStartPage + i; - slots.push(page <= safeTotal ? page : null); - } - - return ( -
- {/* << 첫 단락 */} - - - {/* < 이전 단락 */} - - - {/* 페이지 번호 (고정 슬롯) */} - {slots.map((page, idx) => - page !== null ? ( - - ) : ( -
- ) - )} - - {/* > 다음 단락 */} - - - {/* >> 마지막 단락 */} - -
- ); -} -``` - -### v2-table-list 통합 (paginationJSX 중앙 영역 교체) - -기존 5139~5182행의 `
` 블록을 다음으로 교체: - -```tsx -import { PageGroupNav } from "@/components/common/PageGroupNav"; - -// paginationJSX 내부 중앙 영역 - -``` - -좌측(페이지크기 입력), 우측(내보내기/새로고침)은 기존 코드 그대로 유지. - ---- - -## 설계 원칙 - -- **레이아웃 무관 컴포넌트**: PageGroupNav는 순수 컨트롤만 담당. 외부 레이아웃(좌측/우측 부가 기능)을 강제하지 않음 -- **기존 동작 무변경**: `handlePageChange` 함수는 수정하지 않음. 좌측/우측 영역도 변경하지 않음 -- **고정 슬롯 레이아웃**: 페이지 번호 영역은 항상 `groupSize`개(기본 10) 슬롯 고정. 마지막 단락에서 부족하면 빈 div로 채움 -- **고정 너비 버튼**: 모든 번호 버튼은 `w-8 sm:w-9` 고정. 1자리/2자리/3자리에 관계없이 동일 -- **위치 불변**: `< >` `<< >>` 버튼을 연속 클릭해도 모든 버튼의 위치가 절대 변하지 않음 -- **현재 페이지 강조**: `variant="default"`(primary) + `ring-2 ring-primary font-bold`, 나머지 `variant="outline"` -- **엣지 케이스**: totalPages가 0이거나 1일 때도 정상 동작 (`safeTotal = Math.max(1, totalPages)`) -- **빈 슬롯 접근성**: 빈 슬롯에 `cursor-default` 적용 (클릭 가능한 것처럼 보이지 않게) -- **단계적 적용**: 1단계에서 v2-table-list로 검증 후, 2단계에서 나머지 사용처에 점진 적용 - ---- - -## 추가 구현: 표시갯수(pageSize) 캐시 정책 - -### 문제 - -기존 pageSize는 `onConfigChange`로 부모에 전파되어 DB에 저장되거나, `localStorage`에 저장되어 새로고침해도 사용자가 변경한 값이 남아있었음. - -### 해결 - -| 항목 | 정책 | -|------|------| -| 저장소 | sessionStorage (탭 닫으면 자동 소멸) | -| 키 구조 | `pageSize_{tabId}_{tableName}` (탭별 격리) | -| 기본값 | 20 | -| DB 전파 | 안 함 (onConfigChange 제거) | -| F5 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 | -| 탭 바 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 | -| 비활성 탭 전환 | 캐시에서 복원 | -| 입력 UX | onChange는 표시만, onBlur/Enter로 실제 적용 | - -### 테이블 캐시 탭 격리 - -동일한 정책을 테이블 관련 캐시 전체에 적용: - -| 키 | 구조 | -|----|------| -| `tableState_{tabId}_{tableName}` | 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터 | -| `pageSize_{tabId}_{tableName}` | 표시갯수 | -| `filterSettings_{tabId}_{base}` | 검색 필터 설정 | -| `groupSettings_{tabId}_{base}` | 그룹 설정 | - -사용자 설정(컬럼 가시성/순서/정렬 상태)은 localStorage에 유지 (세션 간 보존). diff --git a/docs/ycshin-node/PGN[계획]-페이징-직접입력.md b/docs/ycshin-node/PGN[계획]-페이징-직접입력.md new file mode 100644 index 00000000..635041b5 --- /dev/null +++ b/docs/ycshin-node/PGN[계획]-페이징-직접입력.md @@ -0,0 +1,128 @@ +# [계획서] 페이징 - 페이지 번호 직접 입력 네비게이션 + +> 관련 문서: [맥락노트](./PGN[맥락]-페이징-직접입력.md) | [체크리스트](./PGN[체크]-페이징-직접입력.md) + +## 개요 + +v2-table-list 컴포넌트의 하단 페이지네이션 중앙 영역에서, 현재 페이지 번호를 **읽기 전용 텍스트**에서 **입력 가능한 필드**로 변경합니다. +사용자가 원하는 페이지 번호를 키보드로 직접 입력하여 빠르게 이동할 수 있게 합니다. + +### 이전 설계(10개 번호 버튼 그룹) 폐기 사유 + +- 10개 버튼은 공간을 많이 차지하고, 모바일에서 렌더링이 어려움 +- 고정 슬롯/고정 너비 등 복잡한 레이아웃 제약이 발생 +- 입력 필드 방식이 더 직관적이고 공간 효율적 + +--- + +## 변경 전 → 변경 후 + +### 페이지네이션 UI + +``` +변경 전: [<<] [<] 1 / 38 [>] [>>] ← 읽기 전용 텍스트 +변경 후: [<<] [<] [ 15 ] / 49 [>] [>>] ← 입력 가능 필드 +``` + +| 버튼 | 동작 (변경 없음) | +|------|-----------------| +| `<<` | 첫 페이지(1)로 이동 | +| `<` | 이전 페이지(`currentPage - 1`)로 이동 | +| 중앙 | **입력 필드** `/` **총 페이지** — 사용자가 원하는 페이지 번호를 직접 입력 | +| `>` | 다음 페이지(`currentPage + 1`)로 이동 | +| `>>` | 마지막 페이지(`totalPages`)로 이동 | + +### 입력 필드 동작 규칙 + +| 동작 | 설명 | +|------|------| +| 클릭 | 입력 필드에 포커스, 기존 숫자 전체 선택(select all) | +| 숫자 입력 | 자유롭게 타이핑 가능 (입력 중에는 페이지 이동 안 함) | +| Enter | 입력한 페이지로 이동 + 포커스 해제 | +| 포커스 아웃 (blur) | 입력한 페이지로 이동 | +| 유효 범위 보정 | 1 미만 → 1, totalPages 초과 → totalPages, 빈 값/비숫자 → 현재 페이지 유지 | +| `< >` 클릭 | 기존대로 한 페이지씩 이동 (입력 필드 값도 갱신) | +| `<< >>` 클릭 | 기존대로 첫/끝 페이지 이동 (입력 필드 값도 갱신) | + +### 비활성화 조건 (기존과 동일) + +- `<<` `<` : `currentPage === 1` +- `>` `>>` : `currentPage >= totalPages` + +--- + +## 시각적 동작 예시 + +총 49페이지 기준: + +| 사용자 동작 | 입력 필드 표시 | 결과 | +|------------|---------------|------| +| 초기 상태 | `1 / 49` | 1페이지 표시 | +| 입력 필드 클릭 | `[1]` 전체 선택됨 | 타이핑 대기 | +| `28` 입력 후 Enter | `28 / 49` | 28페이지로 이동 | +| `0` 입력 후 Enter | `1 / 49` | 1로 보정 | +| `999` 입력 후 Enter | `49 / 49` | 49로 보정 | +| 빈 값으로 blur | `28 / 49` | 이전 페이지(28) 유지 | +| `abc` 입력 후 Enter | `28 / 49` | 이전 페이지(28) 유지 | +| `>` 클릭 | `29 / 49` | 29페이지로 이동 | + +--- + +## 아키텍처 + +### 데이터 흐름 + +```mermaid +flowchart TD + A["currentPage (state, 단일 소스)"] --> B["입력 필드 표시값 (pageInputValue)"] + B -->|"사용자 타이핑"| C["pageInputValue 갱신 (표시만)"] + C -->|"Enter 또는 blur"| D["유효 범위 보정 (1~totalPages)"] + D -->|"보정된 값"| E[handlePageChange] + E --> F["setCurrentPage → useEffect → fetchTableDataDebounced"] + F --> G[백엔드 API 호출] + G --> H[데이터 갱신] + H --> A + + I["<< < > >> 클릭"] --> E + J["페이지크기 변경"] --> K["setCurrentPage(1) + setLocalPageSize + onConfigChange"] + K --> F +``` + +### 페이징 바 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [페이지크기 입력] │ << < [__입력__] / n > >> │ [내보내기][새로고침] │ +│ 좌측(유지) │ 중앙(입력필드 교체) │ 우측(유지) │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 변경 대상 파일 + +| 구분 | 파일 | 변경 내용 | +|------|------|----------| +| 수정 | `TableListComponent.tsx` | (1) `pageInputValue` 상태 + `useEffect` 동기화 + `commitPageInput` 핸들러 추가 | +| | | (2) paginationJSX 중앙 `` → `` + `/` + `` 교체 | +| | | (3) `handlePageSizeChange`에 `onConfigChange` 호출 추가 | +| | | (4) `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 사용 | +| | | (5) `useMemo` 의존성에 `pageInputValue` 추가 | +| 삭제 | `PageGroupNav.tsx` | 이전 설계 산출물 삭제 (이미 삭제됨) | + +- 신규 파일 생성 없음 +- 백엔드 변경 없음, DB 변경 없음 +- v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용 + +--- + +## 설계 원칙 + +- **최소 변경**: `` 1개를 `` + 유효성 검증으로 교체. 나머지 전부 유지 +- **기존 버튼 동작 무변경**: `<< < > >>` 4개 버튼의 onClick/disabled 로직은 그대로 +- **`handlePageChange` 재사용**: 기존 함수를 그대로 호출 +- **입력 중 페이지 이동 안 함**: onChange는 표시만 변경, Enter/blur로 실제 적용 +- **유효 범위 자동 보정**: 1 미만 → 1, totalPages 초과 → totalPages, 비숫자 → 현재 값 유지 +- **포커스 시 전체 선택**: 클릭하면 바로 타이핑 가능 +- **`currentPage`가 단일 소스**: fetch 시 `tableConfig.pagination?.currentPage` 대신 로컬 `currentPage`만 사용 (비동기 전파 문제 방지) +- **페이지크기 변경 시 1페이지로 리셋**: `handlePageSizeChange`가 `onConfigChange`를 호출하여 부모/백엔드 동기화 diff --git a/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md b/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md deleted file mode 100644 index 024bd7a2..00000000 --- a/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md +++ /dev/null @@ -1,128 +0,0 @@ -# [맥락노트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 - -> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md) - ---- - -## 왜 이 작업을 하는가 - -- 현재 페이지네이션은 `1 / 38` 텍스트만 표시하고 `< >`로 한 페이지씩 이동 -- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음 -- 페이지 번호를 직접 클릭할 수 있어야 UX가 개선됨 - ---- - -## 핵심 결정 사항과 근거 - -### 1. 공통 컴포넌트로 분리 (C안) - -- **결정**: `PageGroupNav.tsx`라는 순수 컨트롤 컴포넌트를 별도 파일로 생성 -- **근거**: 프로젝트에 페이징이 15곳 이상 존재. 인라인 수정하면 같은 로직을 복사해야 함 -- **대안 검토 A**: v2-table-list 인라인만 수정 → 기각 (미래 확장 시 복사-붙여넣기 기술 부채) -- **대안 검토 B**: 기존 `Pagination.tsx` 업그레이드 → 기각 (전체 행 레이아웃이 포함되어 v2-table-list와 레이아웃 충돌) -- **대안 검토 D**: 전체 한번에 적용 → 기각 (12파일 동시 수정은 블래스트 반경이 큼) - -### 2. 레이아웃 무관 설계 - -- **결정**: PageGroupNav는 `<< < [번호들] > >>`만 렌더링. 외부 레이아웃(페이지크기, 내보내기 등)을 포함하지 않음 -- **근거**: 사용처마다 레이아웃이 다름. v2-table-list는 좌측(페이지크기)+중앙(컨트롤)+우측(내보내기), Pagination.tsx는 좌측(페이지정보)+우측(크기선택+컨트롤). 레이아웃을 강제하면 props 분기가 증가하여 복잡해짐 - -### 3. 10개 단위 단락(그룹) - -- **결정**: 페이지를 10개씩 묶어 하나의 단락으로 취급 -- **근거**: 사용자에게 익숙한 패턴 (네이버, 구글 등). 5개는 너무 적고, 20개는 너무 많음 -- **확장성**: `groupSize` props로 기본값 10을 변경 가능하게 설계 - -### 4. `< >` = 단락 이동, `<< >>` = 첫/끝 단락 - -- **결정**: `<`는 이전 단락 첫 페이지, `>`는 다음 단락 첫 페이지. `<<`는 1페이지, `>>`는 마지막 단락 첫 페이지 -- **근거**: 사용자 요청. 기존의 "한 페이지씩 이동"은 번호 클릭으로 대체됨 -- **주의**: `>>`는 마지막 **페이지**가 아닌 마지막 **단락의 첫 페이지**로 이동. 예: 총 38페이지일 때 `>>` 클릭 → 31페이지 선택 (38이 아님) - -### 5. 고정 슬롯 + 고정 너비 - -- **결정**: 항상 10개 슬롯을 렌더링하고, 모든 버튼은 동일한 고정 너비(`w-8 sm:w-9`) -- **근거**: `< >` 버튼을 연속 클릭할 때 번호 자릿수(1자리→2자리)나 페이지 수(10개→8개) 변화로 버튼 위치가 흔들리면 안 됨 -- **구현**: 마지막 단락에서 페이지가 10개 미만이면 남은 슬롯은 동일 크기의 빈 `
`로 채움 - -### 6. 단계적 적용 (1단계: v2-table-list만) - -- **결정**: 이번 작업은 v2-table-list에만 적용. 나머지는 별도 작업으로 점진 적용 -- **근거**: 15곳 동시 수정은 리스크가 높음. v2-table-list가 가장 많이 사용되므로 여기서 검증 후 확산 - -### 7. 비활성화 기준은 단락 기준 - -- **결정**: `<< <`는 첫 번째 단락일 때 비활성화 (currentPage === 1이 아님). `> >>`는 마지막 단락일 때 비활성화 -- **근거**: 기존은 currentPage 기준이었지만, 단락 이동이므로 단락 기준으로 변경이 자연스러움. 첫 단락 안에서 5페이지에 있어도 `<`는 비활성화 - ---- - -## 관련 파일 위치 - -| 구분 | 파일 경로 | 설명 | -|------|----------|------| -| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 | -| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 영역 교체 (5139~5182행) | -| 참고 | `frontend/components/common/Pagination.tsx` | 기존 공통 페이지네이션 (이번에 수정 안 함) | - ---- - -## 기술 참고 - -### 단락 계산 공식 - -``` -groupSize = 10 (기본값) -currentGroupIndex = Math.floor((currentPage - 1) / groupSize) -groupStartPage = currentGroupIndex * groupSize + 1 -groupEndPage = Math.min(groupStartPage + groupSize - 1, totalPages) - -lastGroupIndex = Math.floor((totalPages - 1) / groupSize) -lastGroupStartPage = lastGroupIndex * groupSize + 1 - -isFirstGroup = currentGroupIndex === 0 -isLastGroup = currentGroupIndex === lastGroupIndex -``` - -### 고정 슬롯 배열 생성 - -``` -slots = [groupStart, groupStart+1, ..., groupEnd, null, null, ...] (총 groupSize개) -예: 단락 31~38 → [31, 32, 33, 34, 35, 36, 37, 38, null, null] -``` - -### handlePageChange 호출 흐름 - -``` -PageGroupNav onPageChange(page) - → TableListComponent handlePageChange(newPage) - → setCurrentPage(newPage) - → useEffect 트리거 → 백엔드 API 재호출 (page 파라미터 변경) -``` - -- handlePageChange는 `setCurrentPage`만 호출. `onConfigChange` 전파는 제거됨 (pageSize/currentPage는 세션 전용) -- handlePageChange는 기존 함수 그대로 사용. PageGroupNav가 올바른 page 값을 전달하기만 하면 됨 - ---- - -## 추가 결정: 표시갯수(pageSize) 캐시 정책 - -### 8. pageSize는 세션 전용, DB에 저장 안 함 - -- **결정**: pageSize를 `onConfigChange`로 부모/DB에 전파하지 않음. sessionStorage에만 탭별로 저장 -- **근거**: pageSize는 일시적 탐색 설정이지 영구 화면 설정이 아님. DB에 저장하면 다른 사용자에게도 영향이 가고, 새로고침 시 의도치 않은 값이 남음 -- **F5 정책**: 활성 탭은 캐시 삭제 → 기본값 20으로 fresh start. 비활성 탭은 캐시 유지 - -### 9. 테이블 캐시는 탭별 격리 (탭 ID 스코프) - -- **결정**: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*` 키를 `{prefix}_{tabId}_{tableName}` 구조로 변경 -- **근거**: 같은 테이블이 여러 탭에서 열릴 수 있음. 탭 구분 없으면 "활성 탭 캐시만 삭제" 불가능 -- **구현**: `useTabId()` 훅으로 현재 탭 ID 접근. `clearTabCache(tabId)`에서 해당 탭의 모든 관련 키 일괄 삭제 - -### 10. localStorage vs sessionStorage 분류 - -- **결정**: 탭별 캐시는 sessionStorage, 사용자 설정은 localStorage -- **근거**: 탭별 캐시(컬럼 너비 캐시, 필터, 그룹, pageSize)는 탭 닫으면 무의미. 사용자 설정(컬럼 가시성, 순서, 정렬)은 사용자가 의도적으로 변경한 환경설정이므로 세션 간 보존 -- **분류**: - - sessionStorage: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*` - - localStorage: `table_column_visibility_*`, `table_sort_state_*`, `table_column_order_*` diff --git a/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md b/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md new file mode 100644 index 00000000..c036a089 --- /dev/null +++ b/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md @@ -0,0 +1,115 @@ +# [맥락노트] 페이징 - 페이지 번호 직접 입력 네비게이션 + +> 관련 문서: [계획서](./PGN[계획]-페이징-직접입력.md) | [체크리스트](./PGN[체크]-페이징-직접입력.md) + +--- + +## 왜 이 작업을 하는가 + +- 현재 페이지네이션은 `1 / 38` 읽기 전용 텍스트만 표시 +- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음 (`>` 연타 필요) +- 페이지 번호를 직접 입력하여 즉시 이동할 수 있어야 UX가 개선됨 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 10개 번호 버튼 그룹 → 입력 필드로 설계 변경 + +- **결정**: 이전 설계(10개 페이지 번호 버튼 나열)를 폐기하고, 기존 `현재/총` 텍스트에서 현재 부분을 입력 필드로 교체 +- **근거**: 10개 버튼은 공간을 많이 차지하고 고정 슬롯/고정 너비 등 복잡한 레이아웃 제약이 발생. 입력 필드 방식이 더 직관적이고 공간 효율적 +- **이전 산출물**: `PageGroupNav.tsx` → 삭제 완료 + +### 2. `<< < > >>` 버튼 동작 유지 + +- **결정**: 4개 화살표 버튼의 동작은 기존과 완전히 동일하게 유지 +- **근거**: 입력 필드가 "원하는 페이지로 점프" 역할을 하므로, 버튼은 기존의 순차 이동(+1/-1, 첫/끝) 그대로 유지하는 것이 자연스러움 + +### 3. 입력 중에는 페이지 이동 안 함 + +- **결정**: onChange는 입력 필드 표시만 변경. Enter 또는 blur로 실제 페이지 이동 +- **근거**: `28`을 입력하려면 `2`를 먼저 치는데, `2`에서 바로 이동하면 안 됨 + +### 4. 포커스 시 전체 선택 (select all) + +- **결정**: 입력 필드 클릭 시 기존 숫자를 전체 선택 +- **근거**: 사용자가 "15페이지로 가고 싶다" → 클릭 → 바로 `15` 타이핑. 기존 값을 지우는 추가 동작 불필요 + +### 5. 유효 범위 자동 보정 + +- **결정**: 1 미만 → 1, totalPages 초과 → totalPages, 빈 값/비숫자 → 현재 페이지 유지 +- **근거**: 에러 메시지보다 자동 보정이 UX에 유리 +- **대안 검토**: 입력 자체를 숫자만 허용 → 기각 (백스페이스로 비울 때 불편) + +### 6. `inputMode="numeric"` 사용 + +- **결정**: `type="text"` + `inputMode="numeric"` +- **근거**: `type="number"`는 브라우저별 스피너 UI가 추가되고, 빈 값 처리가 어려움. `inputMode="numeric"`은 모바일에서 숫자 키보드를 띄우면서 text 입력의 유연성 유지 + +### 7. 신규 컴포넌트 분리 안 함 + +- **결정**: v2-table-list의 paginationJSX 내부에 인라인으로 구현 +- **근거**: 변경이 `` → `` + 핸들러 약 30줄 수준으로 매우 작음 + +### 8. `currentPage`를 fetch의 단일 소스로 사용 + +- **결정**: `fetchTableDataInternal`에서 `tableConfig.pagination?.currentPage || currentPage` 대신 `currentPage`만 사용 +- **근거**: `handlePageSizeChange`에서 `setCurrentPage(1)` + `onConfigChange(...)` 호출 시, `onConfigChange`를 통한 부모의 `tableConfig` 갱신은 다음 렌더 사이클에서 전파됨. fetch가 실행되는 시점에 `tableConfig.pagination?.currentPage`가 아직 이전 값(예: 4)이고 truthy이므로 로컬 `currentPage`(1) 대신 4를 사용하게 되는 문제 발생. 로컬 `currentPage`는 `setCurrentPage`로 즉시 갱신되므로 이 문제가 없음 +- **발견 과정**: 페이지 크기를 20→40으로 변경하면 1페이지로 설정되지만 리스트가 빈 상태로 표시되는 버그로 발견 + +### 9. `handlePageSizeChange`에서 `onConfigChange` 호출 필수 + +- **결정**: 페이지 크기 변경 시 `onConfigChange`로 `{ pageSize, currentPage: 1 }`을 부모에게 전달 +- **근거**: 기존 코드는 `setLocalPageSize` + `setCurrentPage(1)`만 호출하고 `onConfigChange`를 호출하지 않았음. 이로 인해 부모 컴포넌트의 `tableConfig.pagination`이 갱신되지 않아 후속 동작에서 stale 값 참조 가능 +- **발견 과정**: 위 8번과 같은 맥락에서 발견 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 입력 필드 + fetch 소스 수정 | +| 삭제 | `frontend/components/common/PageGroupNav.tsx` | 이전 설계 산출물 (삭제 완료) | + +--- + +## 기술 참고 + +### 로컬 입력 상태와 실제 페이지 상태 분리 + +``` +pageInputValue (string) — 입력 필드에 표시되는 값 (사용자가 타이핑 중일 수 있음) +currentPage (number) — 실제 현재 페이지 (API 호출의 단일 소스) + +동기화: +- currentPage 변경 시 → useEffect → setPageInputValue(String(currentPage)) +- Enter/blur 시 → commitPageInput → parseInt + clamp → handlePageChange(보정된 값) +``` + +### handlePageChange 호출 흐름 + +``` +입력 필드 Enter/blur + → commitPageInput() + → parseInt + clamp(1, totalPages) + → handlePageChange(clampedPage) + → setCurrentPage(clampedPage) + onConfigChange + → useEffect 트리거 → fetchTableDataDebounced + → fetchTableDataInternal(page = currentPage) + → 백엔드 API 호출 +``` + +### handlePageSizeChange 호출 흐름 + +``` +좌측 페이지크기 입력 onChange/onBlur + → handlePageSizeChange(newSize) + → setLocalPageSize(newSize) + → setCurrentPage(1) + → sessionStorage 저장 + → onConfigChange({ pageSize: newSize, currentPage: 1 }) + → useEffect 트리거 → fetchTableDataDebounced + → fetchTableDataInternal(page = 1, pageSize = newSize) + → 백엔드 API 호출 +``` diff --git a/docs/ycshin-node/PGN[체크]-페이징-단락이동.md b/docs/ycshin-node/PGN[체크]-페이징-단락이동.md deleted file mode 100644 index 46b94395..00000000 --- a/docs/ycshin-node/PGN[체크]-페이징-단락이동.md +++ /dev/null @@ -1,90 +0,0 @@ -# [체크리스트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 - -> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [맥락노트](./PGN[맥락]-페이징-단락이동.md) - ---- - -## 공정 상태 - -- 전체 진행률: **100%** (완료) -- 현재 단계: 4단계 완료 - ---- - -## 구현 체크리스트 - -### 1단계: PageGroupNav 공통 컴포넌트 생성 - -- [x] `frontend/components/common/PageGroupNav.tsx` 파일 생성 -- [x] `PageGroupNavProps` 인터페이스 정의 (currentPage, totalPages, onPageChange, disabled, groupSize) -- [x] 단락 계산 로직 구현 (currentGroupIndex, groupStartPage, lastGroupIndex 등) -- [x] 10개 고정 슬롯 배열 생성 (빈 슬롯은 null) -- [x] `<<` 첫 단락 버튼 (isFirstGroup일 때 비활성화) -- [x] `<` 이전 단락 버튼 (isFirstGroup일 때 비활성화) -- [x] 페이지 번호 버튼 렌더링 (현재 페이지 variant="default", 나머지 variant="outline") -- [x] 빈 슬롯 렌더링 (동일 크기 빈 div) -- [x] `>` 다음 단락 버튼 (isLastGroup일 때 비활성화) -- [x] `>>` 마지막 단락 버튼 (isLastGroup일 때 비활성화, 마지막 단락 첫 페이지로 이동) -- [x] 고정 너비 스타일 적용 (h-8 w-8 sm:h-9 sm:w-9) -- [x] totalPages가 0 또는 1일 때 엣지 케이스 처리 - -### 2단계: v2-table-list 통합 - -- [x] `TableListComponent.tsx`에 `PageGroupNav` import 추가 -- [x] `paginationJSX`의 중앙 컨트롤 영역(5139~5182행)을 `` 호출로 교체 -- [x] props 연결: currentPage, totalPages, handlePageChange, loading -- [x] 좌측(페이지크기 입력) 영역 변경 없음 확인 -- [x] 우측(내보내기/새로고침) 영역 변경 없음 확인 - -### 3단계: 검증 - -- [x] 품목정보 화면에서 페이지 번호 클릭 동작 확인 -- [x] `< >` 단락 이동 동작 확인 (1~10 → 11~20 → ...) -- [x] `<< >>` 첫/끝 단락 이동 동작 확인 -- [x] `>>` 클릭 시 마지막 단락의 첫 페이지 선택 확인 (마지막 페이지가 아님) -- [x] 첫 단락에서 `<< <` 비활성화 확인 -- [x] 마지막 단락에서 `> >>` 비활성화 확인 -- [x] 고정 슬롯: 단락 이동 시 버튼 위치 변동 없음 확인 -- [x] 고정 너비: 1자리/2자리 숫자에서 버튼 크기 동일 확인 -- [x] 마지막 단락이 10개 미만일 때 빈 슬롯으로 위치 고정 확인 -- [x] totalPages가 1일 때 정상 동작 확인 (단일 페이지) -- [x] 로딩 중 모든 버튼 비활성화 확인 -- [x] 페이지 크기 변경 시 첫 페이지로 리셋 확인 - -### 4단계: 정리 - -- [x] 린트 에러 없음 확인 -- [x] 이 체크리스트 완료 표시 업데이트 - -### 5단계: 표시갯수(pageSize) 캐시 정책 - -- [x] 표시갯수 입력 시 onChange → 표시만 변경, 실제 적용은 onBlur/Enter -- [x] 입력 필드 값 string 타입으로 변경 (백스페이스로 비우기 가능) -- [x] 표시갯수 변경 시 1페이지로 리셋 + 데이터 정상 로드 -- [x] onConfigChange로 DB/부모 전파 제거 (pageSize는 세션 전용) -- [x] localStorage → sessionStorage 전환 (탭 닫으면 자동 소멸) -- [x] 키를 탭 ID 스코프로 변경 (`pageSize_{tabId}_{tableName}`) -- [x] F5 새로고침 시 활성 탭 캐시 삭제 → 기본값 20 초기화 -- [x] 탭 바 새로고침 버튼 시 캐시 삭제 → 기본값 20 초기화 -- [x] 비활성 탭 캐시 유지 (탭 전환 시 복원) - -### 6단계: 테이블 캐시 탭 격리 - -- [x] tableStateKey 탭 ID 스코프 (`tableState_{tabId}_{tableName}`) + sessionStorage -- [x] filterSettingKey 탭 ID 스코프 (`filterSettings_{tabId}_{base}`) + sessionStorage -- [x] groupSettingKey 탭 ID 스코프 (`groupSettings_{tabId}_{base}`) + sessionStorage -- [x] clearTabCache 확장 (tableState_/pageSize_/filterSettings_/groupSettings_ 일괄 삭제) -- [x] TabContent.tsx 모듈 레벨 플래그로 F5 감지 → 활성 탭 캐시만 삭제 -- [x] tabStore.refreshTab에 clearTabCache 추가 - ---- - -## 변경 이력 - -| 날짜 | 내용 | -|------|------| -| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 | -| 2026-03-11 | 1단계(PageGroupNav 생성) + 2단계(v2-table-list 통합) + 4단계(린트) 완료. 3단계(수동 검증)은 브라우저에서 확인 필요 | -| 2026-03-11 | 추가 개선: 선택 페이지 강조(ring + font-bold), 빈 슬롯 cursor-default 적용. 3단계 검증 완료. 전체 완료 | -| 2026-03-11 | 5단계: pageSize 입력 UX 개선 + 캐시 정책 (sessionStorage + 탭 스코프 + F5/탭새로고침 초기화) | -| 2026-03-11 | 6단계: 테이블 전체 캐시를 탭별 격리 (localStorage → sessionStorage + 탭 ID 스코프) | diff --git a/docs/ycshin-node/PGN[체크]-페이징-직접입력.md b/docs/ycshin-node/PGN[체크]-페이징-직접입력.md new file mode 100644 index 00000000..50f8fe8d --- /dev/null +++ b/docs/ycshin-node/PGN[체크]-페이징-직접입력.md @@ -0,0 +1,73 @@ +# [체크리스트] 페이징 - 페이지 번호 직접 입력 네비게이션 + +> 관련 문서: [계획서](./PGN[계획]-페이징-직접입력.md) | [맥락노트](./PGN[맥락]-페이징-직접입력.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 이전 설계 산출물 정리 + +- [x] `frontend/components/common/PageGroupNav.tsx` 삭제 +- [x] `TableListComponent.tsx`에서 `PageGroupNav` import 제거 (있으면) — 이미 없음 + +### 2단계: 입력 필드 구현 + +- [x] `pageInputValue` 로컬 상태 추가 (`useState`) +- [x] `currentPage` 변경 시 `pageInputValue` 동기화 (`useEffect`) +- [x] `commitPageInput` 핸들러 구현 (parseInt + clamp + handlePageChange) +- [x] paginationJSX 중앙의 `` → `` + `/` + `` 교체 +- [x] `inputMode="numeric"` 적용 +- [x] `onFocus`에 전체 선택 (`e.target.select()`) +- [x] `onChange`에 `setPageInputValue` (표시만 변경) +- [x] `onKeyDown` Enter에 `commitPageInput` + `blur()` +- [x] `onBlur`에 `commitPageInput` +- [x] `disabled={loading}` 적용 +- [x] 기존 좌측 페이지크기 입력과 일관된 스타일 적용 + +### 3단계: 버그 수정 + +- [x] `handlePageSizeChange`에 `onConfigChange` 호출 추가 (`pageSize` + `currentPage: 1` 전달) +- [x] `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 변경 (stale `tableConfig.pagination?.currentPage` 문제 해결) +- [x] `useCallback` 의존성에서 `tableConfig.pagination?.currentPage` 제거 +- [x] `useMemo` 의존성에 `pageInputValue` 추가 + +### 4단계: 검증 + +- [x] 입력 필드에 숫자 입력 후 Enter → 해당 페이지로 이동 +- [x] 입력 필드에 숫자 입력 후 포커스 아웃 → 해당 페이지로 이동 +- [x] 0 입력 → 1로 보정 +- [x] totalPages 초과 입력 → totalPages로 보정 +- [x] 빈 값으로 blur → 현재 페이지 유지 +- [x] 비숫자(abc) 입력 후 Enter → 현재 페이지 유지 +- [x] 입력 필드 클릭 시 기존 숫자 전체 선택 확인 +- [x] `< >` 버튼 클릭 시 입력 필드 값도 갱신 확인 +- [x] `<< >>` 버튼 클릭 시 입력 필드 값도 갱신 확인 +- [x] 로딩 중 입력 필드 비활성화 확인 +- [x] 좌측 페이지크기 입력과 스타일 일관성 확인 +- [x] 기존 `<< < > >>` 버튼 동작 변화 없음 확인 +- [x] 페이지크기 변경 시 1페이지로 리셋 + 데이터 정상 로딩 확인 + +### 5단계: 정리 + +- [x] 린트 에러 없음 확인 (기존 에러만 존재, 신규 없음) +- [x] 문서(계획서/맥락노트/체크리스트) 최신화 완료 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 최초 설계: 10개 번호 버튼 그룹 (PageGroupNav) | +| 2026-03-11 | 설계 변경: 입력 필드 방식으로 전면 재작성 | +| 2026-03-11 | 구현 완료: 입력 필드 + 유효성 검증 | +| 2026-03-11 | 버그 수정: 페이지크기 변경 시 빈 데이터 문제 (onConfigChange 누락 + stale currentPage) | +| 2026-03-11 | 문서 최신화: 버그 수정 내역 반영, 코드 설계 섹션 제거 (구현 완료) | diff --git a/frontend/components/common/PageGroupNav.tsx b/frontend/components/common/PageGroupNav.tsx deleted file mode 100644 index dc59b35e..00000000 --- a/frontend/components/common/PageGroupNav.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; - -import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -const DEFAULT_GROUP_SIZE = 10; - -interface PageGroupNavProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - disabled?: boolean; - groupSize?: number; -} - -export function PageGroupNav({ - currentPage, - totalPages, - onPageChange, - disabled = false, - groupSize = DEFAULT_GROUP_SIZE, -}: PageGroupNavProps) { - const safeTotal = Math.max(1, totalPages); - const currentGroupIndex = Math.floor((currentPage - 1) / groupSize); - const groupStartPage = currentGroupIndex * groupSize + 1; - - const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize); - const lastGroupStartPage = lastGroupIndex * groupSize + 1; - - const isFirstGroup = currentGroupIndex === 0; - const isLastGroup = currentGroupIndex === lastGroupIndex; - - const slots: (number | null)[] = []; - for (let i = 0; i < groupSize; i++) { - const page = groupStartPage + i; - slots.push(page <= safeTotal ? page : null); - } - - return ( -
- {/* << 첫 단락 */} - - - {/* < 이전 단락 */} - - - {/* 페이지 번호 (고정 슬롯) */} - {slots.map((page, idx) => - page !== null ? ( - - ) : ( -
- ), - )} - - {/* > 다음 단락 */} - - - {/* >> 마지막 단락 */} - -
- ); -} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 593ab529..63cdc3f2 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2,16 +2,16 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { TableListConfig, ColumnConfig } from "./types"; -import type { WebType } from "@/types/common"; +import { WebType } from "@/types/common"; import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; +import { codeCache } from "@/lib/caching/codeCache"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { getFullImageUrl } from "@/lib/api/client"; import { getFilePreviewUrl } from "@/lib/api/file"; import { Button } from "@/components/ui/button"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; import { useTabId } from "@/contexts/TabIdContext"; -import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; // 🖼️ 테이블 셀 이미지 썸네일 컴포넌트 // objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용 @@ -156,8 +156,13 @@ declare global { import { ChevronLeft, ChevronRight, + ChevronsLeft, + ChevronsRight, RefreshCw, + ArrowUp, + ArrowDown, TableIcon, + Settings, X, Layers, ChevronDown, @@ -170,14 +175,14 @@ import { Edit, CheckSquare, Trash2, + Lock, } from "lucide-react"; import * as XLSX from "xlsx"; -import { FileText } from "lucide-react"; +import { FileText, ChevronRightIcon } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; -import { PageGroupNav } from "@/components/common/PageGroupNav"; import { tableDisplayStore } from "@/stores/tableDisplayStore"; import { Dialog, @@ -189,6 +194,7 @@ import { } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; +import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; import { TableOptionsModal } from "@/components/common/TableOptionsModal"; @@ -196,7 +202,7 @@ import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; -import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; +import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; // ======================================== @@ -401,7 +407,7 @@ export const TableListComponent: React.FC = ({ const currentTabId = useTabId(); - const buttonColor = getAdaptiveLabelColor(component.style?.labelColor); + const buttonColor = component.style?.labelColor || "#212121"; const buttonTextColor = component.config?.buttonTextColor || "#ffffff"; const gridColumns = component.gridColumns || 1; @@ -429,13 +435,7 @@ export const TableListComponent: React.FC = ({ width: "100%", height: "100%", minHeight: isDesignMode ? "300px" : "100%", - ...style, - // 런타임에서는 DB의 고정 px 크기를 무시하고 부모에 맞춤 - ...(!isDesignMode && { - width: "100%", - height: "100%", - minWidth: 0, - }), + ...style, // style prop이 위의 기본값들을 덮어씀 }; // ======================================== @@ -691,6 +691,7 @@ export const TableListComponent: React.FC = ({ const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); + const [pageInputValue, setPageInputValue] = useState("1"); const [searchTerm, setSearchTerm] = useState(""); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); @@ -714,20 +715,7 @@ export const TableListComponent: React.FC = ({ const val = sessionStorage.getItem(key); if (val) return Number(val); } - return 20; - }); - const [pageSizeInputValue, setPageSizeInputValue] = useState(() => { - const key = - currentTabId && tableConfig.selectedTable - ? `pageSize_${currentTabId}_${tableConfig.selectedTable}` - : tableConfig.selectedTable - ? `pageSize_${tableConfig.selectedTable}` - : null; - if (key) { - const val = sessionStorage.getItem(key); - if (val) return val; - } - return "20"; + return tableConfig.pagination?.pageSize || 20; }); const [displayColumns, setDisplayColumns] = useState([]); const [columnMeta, setColumnMeta] = useState< @@ -843,7 +831,7 @@ export const TableListComponent: React.FC = ({ if (!tableConfig.selectedTable) return null; if (currentTabId) return `tableState_${currentTabId}_${tableConfig.selectedTable}`; return `tableState_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable, currentTabId]); + }, [tableConfig.selectedTable]); // 🆕 Real-Time Updates 관련 상태 const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); @@ -1647,7 +1635,7 @@ export const TableListComponent: React.FC = ({ setError(null); try { - const page = currentPage || tableConfig.pagination?.currentPage || 1; + const page = currentPage; const pageSize = localPageSize; // 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용 const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined; @@ -1910,7 +1898,6 @@ export const TableListComponent: React.FC = ({ } }, [ tableConfig.selectedTable, - tableConfig.pagination?.currentPage, tableConfig.columns, currentPage, localPageSize, @@ -1945,6 +1932,29 @@ export const TableListComponent: React.FC = ({ const handlePageChange = (newPage: number) => { if (newPage < 1 || newPage > totalPages) return; setCurrentPage(newPage); + if (tableConfig.pagination) { + tableConfig.pagination.currentPage = newPage; + } + if (onConfigChange) { + onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } }); + } + }; + + useEffect(() => { + setPageInputValue(String(currentPage)); + }, [currentPage]); + + const commitPageInput = () => { + const parsed = parseInt(pageInputValue, 10); + if (isNaN(parsed) || pageInputValue.trim() === "") { + setPageInputValue(String(currentPage)); + return; + } + const clamped = Math.max(1, Math.min(parsed, totalPages || 1)); + if (clamped !== currentPage) { + handlePageChange(clamped); + } + setPageInputValue(String(clamped)); }; const handleSort = (column: string) => { @@ -2981,6 +2991,7 @@ export const TableListComponent: React.FC = ({ headerFilters: Object.fromEntries( Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), ), + pageSize: localPageSize, timestamp: Date.now(), }; @@ -3000,6 +3011,7 @@ export const TableListComponent: React.FC = ({ frozenColumnCount, showGridLines, headerFilters, + localPageSize, ]); // 🆕 State Persistence: 통합 상태 복원 @@ -3018,6 +3030,7 @@ export const TableListComponent: React.FC = ({ if (state.sortDirection) setSortDirection(state.sortDirection); if (state.groupByColumns) setGroupByColumns(state.groupByColumns); if (state.frozenColumns) { + // 체크박스 컬럼이 항상 포함되도록 보장 const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null; const restoredFrozenColumns = checkboxColumn && !state.frozenColumns.includes(checkboxColumn) @@ -3025,7 +3038,7 @@ export const TableListComponent: React.FC = ({ : state.frozenColumns; setFrozenColumns(restoredFrozenColumns); } - if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); + if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원 if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); if (state.headerFilters) { const filters: Record> = {}; @@ -3053,8 +3066,6 @@ export const TableListComponent: React.FC = ({ setFrozenColumns([]); setShowGridLines(true); setHeaderFilters({}); - setLocalPageSize(20); - setPageSizeInputValue("20"); toast.success("테이블 설정이 초기화되었습니다."); } catch (error) { console.error("❌ 테이블 상태 초기화 실패:", error); @@ -4280,8 +4291,8 @@ export const TableListComponent: React.FC = ({ return (
- - + + {fileNames} {files.length > 1 && ({files.length})} @@ -4500,6 +4511,7 @@ export const TableListComponent: React.FC = ({ const savedFilters = JSON.parse(saved); setVisibleFilterColumns(new Set(savedFilters)); } else { + // 초기값: 빈 Set (아무것도 선택 안 함) setVisibleFilterColumns(new Set()); } } catch (error) { @@ -5131,16 +5143,19 @@ export const TableListComponent: React.FC = ({ // 페이지 크기 변경 핸들러 const handlePageSizeChange = (newSize: number) => { - setPageSizeInputValue(String(newSize)); setLocalPageSize(newSize); setCurrentPage(1); if (pageSizeKey) { sessionStorage.setItem(pageSizeKey, String(newSize)); } + if (onConfigChange) { + onConfigChange({ + ...tableConfig, + pagination: { ...tableConfig.pagination, pageSize: newSize, currentPage: 1 }, + }); + } }; - const pageSizeOptions = tableConfig.pagination?.pageSizeOptions || [5, 10, 20, 50, 100]; - return (
{/* 좌측: 페이지 크기 입력 */} @@ -5150,20 +5165,15 @@ export const TableListComponent: React.FC = ({ type="number" min={1} max={10000} - value={pageSizeInputValue} + value={localPageSize} onChange={(e) => { - setPageSizeInputValue(e.target.value); - }} - onBlur={(e) => { - const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10)); + const value = Math.min(10000, Math.max(1, Number(e.target.value) || 1)); handlePageSizeChange(value); }} - onKeyDown={(e) => { - if (e.key === "Enter") { - const value = Math.min(10000, Math.max(1, Number((e.target as HTMLInputElement).value) || 10)); - handlePageSizeChange(value); - (e.target as HTMLInputElement).blur(); - } + onBlur={(e) => { + // 포커스 잃을 때 유효 범위로 조정 + const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10)); + handlePageSizeChange(value); }} className="border-input bg-background focus:ring-ring h-7 w-14 rounded-md border px-2 text-center text-xs focus:ring-1 focus:outline-none sm:h-8 sm:w-16" /> @@ -5171,12 +5181,68 @@ export const TableListComponent: React.FC = ({
{/* 중앙 페이지네이션 컨트롤 */} - +
+ + + +
+ setPageInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + commitPageInput(); + (e.target as HTMLInputElement).blur(); + } + }} + onBlur={commitPageInput} + onFocus={(e) => e.target.select()} + disabled={loading} + className="border-input bg-background focus:ring-ring h-7 w-10 rounded-md border px-1 text-center text-xs font-medium focus:ring-1 focus:outline-none sm:h-8 sm:w-12 sm:text-sm" + /> + / + + {totalPages || 1} + +
+ + + +
{/* 우측 버튼 그룹 */}
@@ -5191,7 +5257,7 @@ export const TableListComponent: React.FC = ({
Excel
PDF/인쇄
@@ -5251,9 +5317,9 @@ export const TableListComponent: React.FC = ({ exportToExcel, exportToPdf, localPageSize, - pageSizeInputValue, onConfigChange, tableConfig, + pageInputValue, ]); // ======================================== @@ -5265,7 +5331,7 @@ export const TableListComponent: React.FC = ({ onDragStart: isDesignMode ? onDragStart : undefined, onDragEnd: isDesignMode ? onDragEnd : undefined, draggable: isDesignMode, - className: cn("w-full h-full overflow-hidden", className, isDesignMode && "cursor-move"), + className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일 style: componentStyle, }; @@ -5335,7 +5401,7 @@ export const TableListComponent: React.FC = ({
)} -
+
= ({ className="h-7 text-xs" title="Excel 내보내기" > - + Excel )} @@ -5413,7 +5479,7 @@ export const TableListComponent: React.FC = ({ className="h-7 text-xs" title="PDF 내보내기" > - + PDF )} @@ -5645,7 +5711,6 @@ export const TableListComponent: React.FC = ({ width: "100%", height: "100%", overflow: "auto", - WebkitOverflowScrolling: "touch", }} onScroll={handleVirtualScroll} > @@ -5656,7 +5721,6 @@ export const TableListComponent: React.FC = ({ borderCollapse: "collapse", width: "100%", tableLayout: "fixed", - minWidth: "400px", }} > {/* 헤더 (sticky) */} @@ -5874,7 +5938,7 @@ export const TableListComponent: React.FC = ({ {/* 리사이즈 핸들 (체크박스 제외) */} {columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
e.stopPropagation()} // 정렬 클릭 방지 onMouseDown={(e) => { @@ -6227,11 +6291,11 @@ export const TableListComponent: React.FC = ({ // 🆕 배치 편집: 수정된 셀 스타일 (노란 배경) isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40", // 🆕 유효성 에러: 빨간 테두리 및 배경 - cellValidationError && "bg-destructive/10 ring-2 ring-destructive ring-inset dark:bg-destructive/15", + cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40", // 🆕 검색 하이라이트 스타일 (노란 배경) isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50", // 🆕 편집 불가 컬럼 스타일 (연한 회색 배경) - column.editable === false && "bg-muted dark:bg-foreground/30", + column.editable === false && "bg-gray-50 dark:bg-gray-900/30", )} // 🆕 유효성 에러 툴팁 title={cellValidationError || undefined} @@ -6624,7 +6688,7 @@ export const TableListComponent: React.FC = ({ {/* 행 삭제 */} @@ -6751,7 +6815,7 @@ export const TableListComponent: React.FC = ({ variant="ghost" size="sm" onClick={() => removeFilterCondition(group.id, condition.id)} - className="h-6 w-6 p-0 text-destructive hover:text-destructive" + className="h-6 w-6 p-0 text-red-500 hover:text-red-700" disabled={group.conditions.length === 1} > From 634f0cae18083624cd3cd634091acbbe7e4e0471 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Wed, 11 Mar 2026 14:44:34 +0900 Subject: [PATCH 17/25] docs: Add documentation for category tree modal updates with continuous registration mode - Introduced new documents detailing the modifications made to the category tree modal for continuous registration mode. - Updated the functionality to allow the modal to close after saving or remain open based on user preference via a checkbox. - Enhanced the user experience by aligning the modal behavior with existing patterns in the project. - Included a checklist to track implementation progress and ensure thorough testing. These changes aim to improve the usability and consistency of the category management feature in the application. --- .../CCA[계획]-카테고리-연속등록모드.md | 199 ++++++++++++++++++ .../CCA[맥락]-카테고리-연속등록모드.md | 84 ++++++++ .../CCA[체크]-카테고리-연속등록모드.md | 52 +++++ .../CategoryValueManagerTree.tsx | 39 +++- 4 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md create mode 100644 docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md create mode 100644 docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md diff --git a/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md b/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md new file mode 100644 index 00000000..964c389f --- /dev/null +++ b/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md @@ -0,0 +1,199 @@ +# [계획서] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정 + +> 관련 문서: [맥락노트](./CCA[맥락]-카테고리-연속등록모드.md) | [체크리스트](./CCA[체크]-카테고리-연속등록모드.md) + +## 개요 + +기준정보 - 옵션설정 화면에서 트리 구조 카테고리(예: 품목정보 > 재고단위)의 "대분류 추가" 모달이 저장 후 닫히지 않는 버그를 수정합니다. +평면 목록용 추가 모달(`CategoryValueAddDialog.tsx`)과 동일한 연속 입력 패턴을 적용합니다. + +--- + +## 현재 동작 + +- 대분류 추가 모달에서 값 입력 후 "추가" 클릭 시 **값은 정상 저장됨** +- 저장 후 **모달이 닫히지 않고** 폼만 초기화됨 (항상 연속 입력 상태) +- "연속 입력" 체크박스 UI가 **없음** → 사용자가 모드를 끌 수 없음 +- 모달을 닫으려면 "닫기" 버튼 또는 외부 클릭을 해야 함 + +### 현재 코드 (CategoryValueManagerTree.tsx - handleAdd, 512~530행) + +```tsx +if (response.success) { + toast.success("카테고리가 추가되었습니다"); + // 폼 초기화 (모달은 닫지 않고 연속 입력) + setFormData((prev) => ({ + ...prev, + valueCode: "", + valueLabel: "", + description: "", + color: "", + })); + setTimeout(() => addNameRef.current?.focus(), 50); + await loadTree(true); + if (parentValue) { + setExpandedNodes((prev) => new Set([...prev, parentValue.valueId])); + } +} +``` + +### 현재 DialogFooter (809~821행) + +```tsx + + + + +``` + +--- + +## 변경 후 동작 + +### 1. 기본 동작: 저장 후 모달 닫힘 + +- "추가" 클릭 → 저장 성공 → 모달 닫힘 + 트리 새로고침 +- `CategoryValueAddDialog.tsx`(평면 목록 추가 모달)와 동일한 기본 동작 + +### 2. 연속 입력 체크박스 추가 + +- DialogFooter 좌측에 "연속 입력" 체크박스 표시 +- 기본값: 체크 해제 (OFF) +- 체크 시: 저장 후 폼만 초기화, 모달 유지, 이름 필드에 포커스 +- 체크 해제 시: 저장 후 모달 닫힘 + +--- + +## 시각적 예시 + +| 상태 | 연속 입력 체크 | 추가 버튼 클릭 후 | +|------|---------------|-----------------| +| 기본 (체크 해제) | [ ] 연속 입력 | 저장 → 모달 닫힘 → 트리 갱신 | +| 연속 모드 (체크) | [x] 연속 입력 | 저장 → 폼 초기화 → 모달 유지 → 이름 필드 포커스 | + +### 모달 하단 레이아웃 (ScreenModal.tsx 패턴) + +``` +┌─────────────────────────────────────────┐ +│ [닫기] [추가] │ ← DialogFooter (버튼만) +├─────────────────────────────────────────┤ +│ [x] 저장 후 계속 입력 (연속 등록 모드) │ ← border-t 구분선 아래 별도 영역 +└─────────────────────────────────────────┘ +``` + +--- + +## 아키텍처 + +```mermaid +flowchart TD + A["사용자: '추가' 클릭"] --> B["handleAdd()"] + B --> C{"API 호출 성공?"} + C -- 실패 --> D["toast.error → 모달 유지"] + C -- 성공 --> E["toast.success + loadTree"] + E --> F{"continuousAdd?"} + F -- true --> G["폼 초기화 + 이름 필드 포커스\n모달 유지"] + F -- false --> H["폼 초기화 + 모달 닫힘"] +``` + +--- + +## 변경 대상 파일 + +| 파일 | 역할 | 변경 내용 | +|------|------|----------| +| `frontend/components/table-category/CategoryValueManagerTree.tsx` | 트리형 카테고리 값 관리 | 상태 추가, handleAdd 분기, DialogFooter UI | + +- **변경 규모**: 약 20줄 내외 소규모 변경 +- **참고 파일**: `frontend/components/table-category/CategoryValueAddDialog.tsx` (동일 패턴) + +--- + +## 코드 설계 + +### 1. 상태 추가 (286행 근처, 모달 상태 선언부) + +```tsx +const [continuousAdd, setContinuousAdd] = useState(false); +``` + +### 2. handleAdd 성공 분기 수정 (512~530행 대체) + +```tsx +if (response.success) { + toast.success("카테고리가 추가되었습니다"); + await loadTree(true); + if (parentValue) { + setExpandedNodes((prev) => new Set([...prev, parentValue.valueId])); + } + + if (continuousAdd) { + setFormData((prev) => ({ + ...prev, + valueCode: "", + valueLabel: "", + description: "", + color: "", + })); + setTimeout(() => addNameRef.current?.focus(), 50); + } else { + setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true }); + setIsAddModalOpen(false); + } +} +``` + +### 3. DialogFooter + 연속 등록 체크박스 수정 (809~821행 대체) + +DialogFooter는 버튼만 유지하고, 그 아래에 `border-t` 구분선과 체크박스를 별도 영역으로 배치합니다. +`ScreenModal.tsx` (1287~1303행) 패턴 그대로입니다. + +```tsx + + + + + +{/* 연속 등록 모드 체크박스 - ScreenModal.tsx 패턴 */} +
+
+ setContinuousAdd(checked as boolean)} + /> + +
+
+``` + +--- + +## 예상 문제 및 대응 + +`CategoryValueAddDialog.tsx`와 동일한 패턴이므로 별도 예상 문제 없음. + +--- + +## 설계 원칙 + +- `CategoryValueAddDialog.tsx`(같은 폴더, 같은 목적)의 패턴을 그대로 따름 +- 기존 수정/삭제 모달 동작은 변경하지 않음 +- 하위 추가(중분류/소분류) 모달도 동일한 `handleAdd`를 사용하므로 자동 적용 +- `Checkbox` import는 이미 존재 (24행)하므로 추가 import 불필요 +- `Label` import는 이미 존재 (53행)하므로 추가 import 불필요 +- 체크박스 위치/라벨/className 모두 `ScreenModal.tsx` (1287~1303행)과 동일 diff --git a/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md b/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md new file mode 100644 index 00000000..1b5cb92e --- /dev/null +++ b/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md @@ -0,0 +1,84 @@ +# [맥락노트] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정 + +> 관련 문서: [계획서](./CCA[계획]-카테고리-연속등록모드.md) | [체크리스트](./CCA[체크]-카테고리-연속등록모드.md) + +--- + +## 왜 이 작업을 하는가 + +- 기준정보 - 옵션설정에서 트리 구조 카테고리(품목정보 > 재고단위 등)의 "대분류 추가" 모달이 저장 후 닫히지 않음 +- 연속 등록 모드가 하드코딩되어 항상 ON 상태이고, 끌 수 있는 UI가 없음 +- 같은 폴더의 평면 목록 모달(`CategoryValueAddDialog.tsx`)은 이미 올바르게 구현되어 있음 +- 동일 패턴을 적용하여 일관성 확보 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 기본값: 연속 등록 OFF (모달 닫힘) + +- **결정**: `continuousAdd` 초기값을 `false`로 설정 +- **근거**: 대부분의 사용자는 한 건 추가 후 결과를 확인하려 함. 연속 입력은 선택적 기능 + +### 2. 체크박스 위치: DialogFooter 아래, border-t 구분선 별도 영역 + +- **결정**: `ScreenModal.tsx` (1287~1303행) 패턴 그대로 적용 +- **근거**: "기준정보 - 부서관리" 추가 모달과 동일한 디자인. 프로젝트 관행 준수 +- **대안 검토**: `CategoryValueAddDialog.tsx`는 DialogFooter 안에 체크박스 배치 → 부서 모달과 다른 디자인이므로 기각 + +### 3. 라벨: "저장 후 계속 입력 (연속 등록 모드)" + +- **결정**: `ScreenModal.tsx`와 동일한 라벨 텍스트 사용 +- **근거**: 부서 추가 모달과 동일한 문구로 사용자 혼란 방지 + +### 4. localStorage 미사용 + +- **결정**: 컴포넌트 state만 사용, localStorage 영속화 안 함 +- **근거**: `CategoryValueAddDialog.tsx`(같은 폴더 형제 컴포넌트)가 localStorage를 쓰지 않음. `ScreenModal.tsx`는 사용하지만 동적 화면 모달 전용 기능이므로 범위가 다름 + +### 5. 수정 대상: handleAdd 함수만 + +- **결정**: 저장 성공 분기에서만 `continuousAdd` 체크 +- **근거**: 실패 시에는 원래대로 모달 유지 + 에러 표시. 분기가 필요한 건 성공 시뿐 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 대상 | `frontend/components/table-category/CategoryValueManagerTree.tsx` | 트리형 카테고리 값 관리 (대분류/중분류/소분류) | +| 참고 패턴 (로직) | `frontend/components/table-category/CategoryValueAddDialog.tsx` | 평면 목록 추가 모달 - continuousAdd 분기 로직 | +| 참고 패턴 (UI) | `frontend/components/common/ScreenModal.tsx` | 동적 화면 모달 - 체크박스 위치/라벨/스타일 | + +--- + +## 기술 참고 + +### 현재 handleAdd 흐름 + +``` +handleAdd() → API 호출 → 성공 시: + 1. toast.success + 2. 폼 초기화 (모달 유지 - 하드코딩) + 3. addNameRef 포커스 + 4. loadTree(true) - 펼침 상태 유지 + 5. parentValue 있으면 해당 노드 펼침 +``` + +### 변경 후 handleAdd 흐름 + +``` +handleAdd() → API 호출 → 성공 시: + 1. toast.success + 2. loadTree(true) + parentValue 펼침 + 3. continuousAdd 체크: + - true: 폼 초기화 + addNameRef 포커스 (모달 유지) + - false: 폼 초기화 + setIsAddModalOpen(false) (모달 닫힘) +``` + +### import 현황 + +- `Checkbox`: 24행에서 이미 import (`@/components/ui/checkbox`) +- `Label`: 53행에서 이미 import (`@/components/ui/label`) +- 추가 import 불필요 diff --git a/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md b/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md new file mode 100644 index 00000000..f794e0ff --- /dev/null +++ b/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md @@ -0,0 +1,52 @@ +# [체크리스트] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정 + +> 관련 문서: [계획서](./CCA[계획]-카테고리-연속등록모드.md) | [맥락노트](./CCA[맥락]-카테고리-연속등록모드.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (구현 완료) +- 현재 단계: 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 상태 추가 + +- [x] `CategoryValueManagerTree.tsx` 모달 상태 선언부(286행 근처)에 `continuousAdd` 상태 추가 + +### 2단계: handleAdd 분기 수정 + +- [x] `handleAdd` 성공 분기(512~530행)에서 `continuousAdd` 체크 분기 추가 +- [x] `continuousAdd === true`: 폼 초기화 + addNameRef 포커스 (모달 유지) +- [x] `continuousAdd === false`: 폼 초기화 + `setIsAddModalOpen(false)` (모달 닫힘) + +### 3단계: DialogFooter UI 수정 + +- [x] DialogFooter(809~821행)는 버튼만 유지 +- [x] DialogFooter 아래에 `border-t px-4 py-3` 영역 추가 +- [x] "저장 후 계속 입력 (연속 등록 모드)" 체크박스 배치 +- [x] ScreenModal.tsx (1287~1303행) 패턴과 동일한 className/라벨 사용 + +### 4단계: 검증 + +- [ ] 대분류 추가: 체크 해제 상태에서 추가 → 모달 닫힘 확인 +- [ ] 대분류 추가: 체크 상태에서 추가 → 모달 유지 + 폼 초기화 + 포커스 확인 +- [ ] 하위 추가(중분류/소분류): 동일하게 동작하는지 확인 +- [ ] 수정/삭제 모달: 기존 동작 변화 없음 확인 + +### 5단계: 정리 + +- [x] 린트 에러 없음 확인 +- [x] 이 체크리스트 완료 표시 업데이트 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-11 | 구현 완료 (1~3단계, 5단계 정리). 4단계 검증은 수동 테스트 필요 | diff --git a/frontend/components/table-category/CategoryValueManagerTree.tsx b/frontend/components/table-category/CategoryValueManagerTree.tsx index 88ecfb49..f6f7ff8a 100644 --- a/frontend/components/table-category/CategoryValueManagerTree.tsx +++ b/frontend/components/table-category/CategoryValueManagerTree.tsx @@ -288,6 +288,7 @@ export const CategoryValueManagerTree: React.FC = const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); const [parentValue, setParentValue] = useState(null); + const [continuousAdd, setContinuousAdd] = useState(false); const [editingValue, setEditingValue] = useState(null); const [deletingValue, setDeletingValue] = useState(null); @@ -512,21 +513,24 @@ export const CategoryValueManagerTree: React.FC = const response = await createCategoryValue(input); if (response.success) { toast.success("카테고리가 추가되었습니다"); - // 폼 초기화 (모달은 닫지 않고 연속 입력) - setFormData((prev) => ({ - ...prev, - valueCode: "", - valueLabel: "", - description: "", - color: "", - })); - setTimeout(() => addNameRef.current?.focus(), 50); - // 기존 펼침 상태 유지하면서 데이터 새로고침 await loadTree(true); - // 부모 노드만 펼치기 (하위 추가 시) if (parentValue) { setExpandedNodes((prev) => new Set([...prev, parentValue.valueId])); } + + if (continuousAdd) { + setFormData((prev) => ({ + ...prev, + valueCode: "", + valueLabel: "", + description: "", + color: "", + })); + setTimeout(() => addNameRef.current?.focus(), 50); + } else { + setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true }); + setIsAddModalOpen(false); + } } else { toast.error(response.error || "추가 실패"); } @@ -818,6 +822,19 @@ export const CategoryValueManagerTree: React.FC = 추가 + +
+
+ setContinuousAdd(checked as boolean)} + /> + +
+
From 65026f14e4c870c6657e9751dc92c4988a7d26f6 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Wed, 11 Mar 2026 15:53:01 +0900 Subject: [PATCH 18/25] docs: Add documentation for category dropdown depth separation - Introduced new documents detailing the implementation of visual separation for three-level category dropdowns. - Updated the `flattenTree` function in both `V2Select.tsx` and `UnifiedSelect.tsx` to use Non-Breaking Space (`\u00A0`) for indentation, ensuring proper visual hierarchy. - Included a checklist to track the implementation progress and verification of the changes. - Documented the rationale behind the changes, including the issues with HTML whitespace collapsing and the decisions made to enhance user experience. These updates aim to improve the clarity and usability of the category selection interface in the application. --- .../CTI[계획]-카테고리-깊이구분.md | 122 ++++++++++++++++++ .../CTI[맥락]-카테고리-깊이구분.md | 105 +++++++++++++++ .../CTI[체크]-카테고리-깊이구분.md | 53 ++++++++ frontend/app/globals.css | 8 ++ frontend/components/unified/UnifiedSelect.tsx | 2 +- frontend/components/v2/V2Input.tsx | 10 +- frontend/components/v2/V2Select.tsx | 2 +- frontend/lib/hooks/useDialogAutoValidation.ts | 30 ++++- 8 files changed, 318 insertions(+), 14 deletions(-) create mode 100644 docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md create mode 100644 docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md create mode 100644 docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md diff --git a/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md b/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md new file mode 100644 index 00000000..7b524b82 --- /dev/null +++ b/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md @@ -0,0 +1,122 @@ +# [계획서] 카테고리 드롭다운 - 3단계 깊이 구분 표시 + +> 관련 문서: [맥락노트](./CTI[맥락]-카테고리-깊이구분.md) | [체크리스트](./CTI[체크]-카테고리-깊이구분.md) +> +> 상태: **완료** (2026-03-11) + +## 개요 + +카테고리 타입(`source="category"`) 드롭다운에서 3단계 계층(대분류 > 중분류 > 소분류)의 들여쓰기가 시각적으로 구분되지 않는 문제를 수정합니다. + +--- + +## 변경 전 동작 + +- `category_values` 테이블은 `parent_value_id`, `depth` 컬럼으로 3단계 계층 구조를 지원 +- 백엔드 `buildHierarchy()`가 트리 구조를 정상적으로 반환 +- 프론트엔드 `flattenTree()`가 트리를 평탄화하면서 **일반 ASCII 공백(`" "`)** 으로 들여쓰기 생성 +- HTML이 연속 공백을 하나로 축소(collapse)하여 depth 1과 depth 2가 동일하게 렌더링됨 + +### 변경 전 코드 (flattenTree) + +```tsx +const prefix = depth > 0 ? " ".repeat(depth) + "└ " : ""; +``` + +### 변경 전 렌더링 결과 + +``` +신예철 +└ 신2 +└ 신22 ← depth 2인데 depth 1과 구분 불가 +└ 신3 +└ 신4 +``` + +--- + +## 변경 후 동작 + +### 일반 공백을 Non-Breaking Space(`\u00A0`)로 교체 + +- `\u00A0`는 HTML에서 축소되지 않으므로 depth별 들여쓰기가 정확히 유지됨 +- depth당 3칸(`\u00A0\u00A0\u00A0`)으로 시각적 계층 구분을 명확히 함 +- 백엔드 변경 없음 (트리 구조는 이미 정상) + +### 변경 후 코드 (flattenTree) + +```tsx +const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : ""; +``` + +--- + +## 시각적 예시 + +| depth | prefix | 드롭다운 표시 | +|-------|--------|-------------| +| 0 (대분류) | `""` | `신예철` | +| 1 (중분류) | `"\u00A0\u00A0\u00A0└ "` | `···└ 신2` | +| 2 (소분류) | `"\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0└ "` | `······└ 신22` | + +### 변경 전후 비교 + +``` +변경 전: 변경 후: +신예철 신예철 +└ 신2 └ 신2 +└ 신22 ← 구분 불가 └ 신22 ← 명확히 구분 +└ 신3 └ 신3 +└ 신4 └ 신4 +``` + +--- + +## 아키텍처 + +```mermaid +flowchart TD + A[category_values 테이블] -->|parent_value_id, depth| B[백엔드 buildHierarchy] + B -->|트리 JSON 응답| C[프론트엔드 API 호출] + C --> D[flattenTree 함수] + D -->|"depth별 \u00A0 prefix 생성"| E[SelectOption 배열] + E --> F{렌더링 모드} + F -->|비검색| G[SelectItem - label 표시] + F -->|검색| H[CommandItem - displayLabel 표시] + + style D fill:#f96,stroke:#333,color:#000 +``` + +**변경 지점**: `flattenTree` 함수 내 prefix 생성 로직 (주황색 표시) + +--- + +## 변경 대상 파일 + +| 파일 경로 | 변경 내용 | 변경 규모 | +|-----------|----------|----------| +| `frontend/components/v2/V2Select.tsx` (904행) | `flattenTree` prefix를 `\u00A0` 기반으로 변경 | 1줄 | +| `frontend/components/unified/UnifiedSelect.tsx` (632행) | 동일한 `flattenTree` prefix 변경 | 1줄 | + +--- + +## 영향받는 기존 로직 + +V2Select.tsx의 `resolvedValue`(979행)에서 prefix를 제거하는 정규식: + +```tsx +const cleanLabel = o.label.replace(/^[\s└]+/, "").trim(); +``` + +- JavaScript `\s`는 `\u00A0`를 포함하므로 기존 정규식이 정상 동작함 +- 추가 수정 불필요 + +--- + +## 설계 원칙 + +- 백엔드 변경 없이 프론트엔드 표시 로직만 수정 +- `flattenTree` 공통 함수 수정이므로 카테고리 타입 드롭다운 전체에 자동 적용 +- DB 저장값(`valueCode`)에는 영향 없음 — `label`만 변경 +- 기존 prefix strip 정규식(`/^[\s└]+/`)과 호환 유지 +- `V2Select`와 `UnifiedSelect` 두 곳의 동일 패턴을 일관되게 수정 diff --git a/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md b/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md new file mode 100644 index 00000000..0cb61da0 --- /dev/null +++ b/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md @@ -0,0 +1,105 @@ +# [맥락노트] 카테고리 드롭다운 - 3단계 깊이 구분 표시 + +> 관련 문서: [계획서](./CTI[계획]-카테고리-깊이구분.md) | [체크리스트](./CTI[체크]-카테고리-깊이구분.md) + +--- + +## 왜 이 작업을 하는가 + +- 품목정보 등록 모달의 "재고단위" 등 카테고리 드롭다운에서 3단계 계층이 시각적으로 구분되지 않음 +- 예: "신22"가 "신2"의 하위인데, "신3", "신4"와 같은 레벨로 보임 +- 사용자가 대분류/중분류/소분류 관계를 파악할 수 없어 잘못된 항목을 선택할 위험 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 원인: HTML 공백 축소(collapse) + +- **현상**: `flattenTree`에서 `" ".repeat(depth)`로 들여쓰기를 만들지만, HTML이 연속 공백을 하나로 합침 +- **결과**: depth 1(`" └ "`)과 depth 2(`" └ "`)가 동일하게 렌더링됨 +- **확인**: `SelectItem`, `CommandItem` 모두 `white-space: pre` 미적용 상태 + +### 2. 해결: Non-Breaking Space(`\u00A0`) 사용 + +- **결정**: 일반 공백 `" "`를 `"\u00A0"`로 교체 +- **근거**: `\u00A0`는 HTML에서 축소되지 않아 depth별 들여쓰기가 정확히 유지됨 +- **대안 검토**: + - `white-space: pre` CSS 적용 → 기각 (SelectItem, CommandItem 양쪽 모두 수정 필요, shadcn 기본 스타일 오버라이드 부담) + - CSS `padding-left` 사용 → 기각 (label 문자열 기반 옵션 구조에서 개별 아이템에 스타일 전달 어려움) + - 트리 문자(`│`, `├`, `└`) 조합 → 기각 (과도한 시각 정보, 단순 들여쓰기면 충분) + +### 3. depth당 3칸 `\u00A0` + +- **결정**: `"\u00A0\u00A0\u00A0".repeat(depth)` (depth당 3칸) +- **근거**: 기존 2칸은 `\u00A0`로 바꿔도 depth간 차이가 작음. 3칸이 시각적 구분에 적절 + +### 4. 두 파일 동시 수정 + +- **결정**: `V2Select.tsx`와 `UnifiedSelect.tsx` 모두 수정 +- **근거**: 동일한 `flattenTree` 패턴이 두 컴포넌트에 존재. 하나만 수정하면 불일치 발생 + +### 5. 기존 prefix strip 정규식 호환 + +- **확인**: V2Select.tsx 979행의 `o.label.replace(/^[\s└]+/, "").trim()` +- **근거**: JavaScript `\s`는 `\u00A0`를 포함하므로 추가 수정 불필요 + +--- + +## 구현 중 발견한 사항 + +### CAT_ vs CATEGORY_ 접두사 불일치 + +테스트 과정에서 최고 관리자 계정으로 리스트 조회 시 `CAT_MMLL6U02_QH2V` 같은 코드가 그대로 표시되는 현상 발견. + +- **원인**: 카테고리 값 생성 함수가 두 곳에 존재하며 접두사가 다름 + - `CategoryValueAddDialog.tsx`: `CATEGORY_` 접두사 + - `CategoryValueManagerTree.tsx`: `CAT_` 접두사 +- **영향**: 리스트 해석 로직(`V2Repeater`, `InteractiveDataTable`, `UnifiedRepeater`)이 `CATEGORY_` 접두사만 인식하여 `CAT_` 코드는 라벨 변환 실패 +- **판단**: 일반 회사 계정에서는 정상 동작 확인. 본 작업(들여쓰기 표시) 범위 외로 별도 이슈로 분리 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 완료 | `frontend/components/v2/V2Select.tsx` | flattenTree 함수 (904행) | +| 수정 완료 | `frontend/components/unified/UnifiedSelect.tsx` | flattenTree 함수 (632행) | +| 백엔드 (변경 없음) | `backend-node/src/services/tableCategoryValueService.ts` | buildHierarchy 메서드 | +| UI 컴포넌트 (변경 없음) | `frontend/components/ui/select.tsx` | SelectItem 렌더링 | +| UI 컴포넌트 (변경 없음) | `frontend/components/ui/command.tsx` | CommandItem 렌더링 | + +--- + +## 기술 참고 + +### flattenTree 동작 흐름 + +``` +백엔드 API 응답 (트리 구조): +{ + valueCode: "CAT_001", valueLabel: "신예철", children: [ + { valueCode: "CAT_002", valueLabel: "신2", children: [ + { valueCode: "CAT_003", valueLabel: "신22", children: [] } + ]}, + { valueCode: "CAT_004", valueLabel: "신3", children: [] }, + { valueCode: "CAT_005", valueLabel: "신4", children: [] } + ] +} + +→ flattenTree 변환 후 (SelectOption 배열): +[ + { value: "CAT_001", label: "신예철" }, + { value: "CAT_002", label: "\u00A0\u00A0\u00A0└ 신2" }, + { value: "CAT_003", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0└ 신22" }, + { value: "CAT_004", label: "\u00A0\u00A0\u00A0└ 신3" }, + { value: "CAT_005", label: "\u00A0\u00A0\u00A0└ 신4" } +] +``` + +### value vs label 분리 + +- `value` (저장값): `valueCode` — DB에 저장되는 값, 들여쓰기 없음 +- `label` (표시값): prefix + `valueLabel` — 화면에만 보이는 값, 들여쓰기 포함 +- 데이터 무결성에 영향 없음 diff --git a/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md b/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md new file mode 100644 index 00000000..8a1cc237 --- /dev/null +++ b/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md @@ -0,0 +1,53 @@ +# [체크리스트] 카테고리 드롭다운 - 3단계 깊이 구분 표시 + +> 관련 문서: [계획서](./CTI[계획]-카테고리-깊이구분.md) | [맥락노트](./CTI[맥락]-카테고리-깊이구분.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 전체 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 코드 수정 + +- [x] `V2Select.tsx` 904행 — `flattenTree` prefix를 `\u00A0` 기반으로 변경 +- [x] `UnifiedSelect.tsx` 632행 — 동일한 `flattenTree` prefix 변경 + +### 2단계: 검증 + +- [x] depth 1 항목: 3칸 들여쓰기 + `└` 표시 확인 +- [x] depth 2 항목: 6칸 들여쓰기 + `└` 표시, depth 1과 명확히 구분됨 확인 +- [x] depth 0 항목: 들여쓰기 없이 원래대로 표시 확인 +- [x] 항목 선택 후 값이 정상 저장되는지 확인 (valueCode 기준) +- [x] 기존 prefix strip 로직 정상 동작 확인 — JS `\s`가 `\u00A0` 포함하므로 호환 +- [x] 검색 가능 모드(Combobox): 정상 동작 확인 +- [x] 비검색 모드(Select): 렌더링 정상 확인 + +### 3단계: 정리 + +- [x] 린트 에러 없음 확인 (기존 에러 제외) +- [x] 계맥체 문서 최신화 + +--- + +## 참고: 최고 관리자 계정 표시 이슈 + +- 최고 관리자(`company_code = "*"`)로 리스트 조회 시 `CAT_MMLL6U02_QH2V` 같은 코드값이 그대로 노출되는 현상 발견 +- 원인: `CategoryValueManagerTree.tsx`의 `generateCode()`가 `CAT_` 접두사를 사용하나, 리스트 해석 로직은 `CATEGORY_` 접두사만 인식 +- 일반 회사 계정에서는 정상 표시됨을 확인 +- 본 작업 범위 외로 판단하여 별도 이슈로 분리 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 | +| 2026-03-11 | 1단계 코드 수정 완료 (V2Select.tsx, UnifiedSelect.tsx) | +| 2026-03-11 | 2단계 검증 완료, 3단계 문서 정리 완료 | diff --git a/frontend/app/globals.css b/frontend/app/globals.css index abcbd2f8..b3dbab89 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -458,6 +458,14 @@ select { border-color: hsl(var(--destructive)) !important; } + +/* 채번 세그먼트 포커스 스타일 (shadcn Input과 동일한 3단 구조) */ +.numbering-segment:focus-within { + box-shadow: 0 0 0 3px hsl(var(--ring) / 0.5); + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; +} + /* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */ .validation-error-msg-wrapper { height: 0; diff --git a/frontend/components/unified/UnifiedSelect.tsx b/frontend/components/unified/UnifiedSelect.tsx index d307fbc1..1045ba8c 100644 --- a/frontend/components/unified/UnifiedSelect.tsx +++ b/frontend/components/unified/UnifiedSelect.tsx @@ -629,7 +629,7 @@ export const UnifiedSelect = forwardRef((pro ): SelectOption[] => { const result: SelectOption[] = []; for (const item of items) { - const prefix = depth > 0 ? " ".repeat(depth) + "└ " : ""; + const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : ""; result.push({ value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치) label: prefix + item.valueLabel, diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index e61ba143..2d7c3246 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -909,10 +909,10 @@ export const V2Input = forwardRef((props, ref) => const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : ""; return ( -
+
{/* 고정 접두어 */} {templatePrefix && ( - + {templatePrefix} )} @@ -945,13 +945,13 @@ export const V2Input = forwardRef((props, ref) => } }} placeholder="입력" - className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm ring-0" disabled={disabled || isGeneratingNumbering} - style={inputTextStyle} + style={{ ...inputTextStyle, outline: 'none' }} /> {/* 고정 접미어 */} {templateSuffix && ( - + {templateSuffix} )} diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 959abe05..9062e7bc 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -901,7 +901,7 @@ export const V2Select = forwardRef((props, ref) = ): SelectOption[] => { const result: SelectOption[] = []; for (const item of items) { - const prefix = depth > 0 ? " ".repeat(depth) + "└ " : ""; + const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : ""; result.push({ value: item.valueCode, // 🔧 valueCode를 value로 사용 label: prefix + item.valueLabel, diff --git a/frontend/lib/hooks/useDialogAutoValidation.ts b/frontend/lib/hooks/useDialogAutoValidation.ts index eefa9342..66e4f20b 100644 --- a/frontend/lib/hooks/useDialogAutoValidation.ts +++ b/frontend/lib/hooks/useDialogAutoValidation.ts @@ -98,6 +98,16 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) { return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden"); } + // 복합 입력 필드(채번 세그먼트 등)의 시각적 테두리 컨테이너 탐지 + // input 자체에 border가 없고 부모가 border를 가진 경우 부모를 반환 + function findBorderContainer(input: TargetEl): HTMLElement { + const parent = input.parentElement; + if (parent && parent.classList.contains("border")) { + return parent; + } + return input; + } + function isEmpty(input: TargetEl): boolean { if (input instanceof HTMLButtonElement) { // Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태 @@ -120,20 +130,24 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) { } function markError(input: TargetEl) { - input.setAttribute(ERROR_ATTR, "true"); + const container = findBorderContainer(input); + container.setAttribute(ERROR_ATTR, "true"); errorFields.add(input); showErrorMsg(input); } function clearError(input: TargetEl) { - input.removeAttribute(ERROR_ATTR); + const container = findBorderContainer(input); + container.removeAttribute(ERROR_ATTR); errorFields.delete(input); removeErrorMsg(input); } // 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper) + // 복합 입력(채번 세그먼트 등)은 border 컨테이너 바깥에 삽입 function showErrorMsg(input: TargetEl) { - if (input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return; + const container = findBorderContainer(input); + if (container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return; const wrapper = document.createElement("div"); wrapper.className = MSG_WRAPPER_CLASS; @@ -142,17 +156,19 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) { msg.textContent = "필수 입력 항목입니다"; wrapper.appendChild(msg); - input.insertAdjacentElement("afterend", wrapper); + container.insertAdjacentElement("afterend", wrapper); } function removeErrorMsg(input: TargetEl) { - const wrapper = input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`); + const container = findBorderContainer(input); + const wrapper = container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`); if (wrapper) wrapper.remove(); } function highlightField(input: TargetEl) { - input.setAttribute(HIGHLIGHT_ATTR, "true"); - input.addEventListener("animationend", () => input.removeAttribute(HIGHLIGHT_ATTR), { once: true }); + const container = findBorderContainer(input); + container.setAttribute(HIGHLIGHT_ATTR, "true"); + container.addEventListener("animationend", () => container.removeAttribute(HIGHLIGHT_ATTR), { once: true }); if (input instanceof HTMLButtonElement) { input.click(); From c7b8acbac3d92b4a314cd69fdaef6df05ff60e80 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 11 Mar 2026 16:35:49 +0900 Subject: [PATCH 19/25] =?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 20/25] =?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 21/25] =?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; From 238a7d1db4402f22a571eed5941b42d6522ceb77 Mon Sep 17 00:00:00 2001 From: kmh Date: Wed, 11 Mar 2026 23:38:42 +0900 Subject: [PATCH 22/25] feat: Enhance V2RepeaterConfigPanel with entity join column management - Updated the toggleEntityJoinColumn function to include an optional columnType parameter for better flexibility in handling join columns. - Improved the logic for managing entity joins and columns, ensuring that columns are correctly added or removed based on user interactions. - Introduced a new section in the UI to display entity join columns in a read-only format, providing users with clear visibility of the join configurations. - Added loading states and messages to enhance user experience during data retrieval for entity joins. These changes aim to improve the functionality and usability of the V2RepeaterConfigPanel in managing entity relationships. --- .../config-panels/V2RepeaterConfigPanel.tsx | 278 +++++++++--------- 1 file changed, 134 insertions(+), 144 deletions(-) diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index e3f5f6cc..92efdcd8 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -375,12 +375,15 @@ export const V2RepeaterConfigPanel: React.FC = ({ // Entity 조인 컬럼 토글 (추가/제거) const toggleEntityJoinColumn = useCallback( - (joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => { + (joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string, columnType?: string) => { const currentJoins = config.entityJoins || []; const existingJoinIdx = currentJoins.findIndex( (j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName, ); + let newEntityJoins = [...currentJoins]; + let newColumns = [...config.columns]; + if (existingJoinIdx >= 0) { const existingJoin = currentJoins[existingJoinIdx]; const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName); @@ -388,34 +391,49 @@ export const V2RepeaterConfigPanel: React.FC = ({ if (existingColIdx >= 0) { const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx); if (updatedColumns.length === 0) { - updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) }); + newEntityJoins = newEntityJoins.filter((_, i) => i !== existingJoinIdx); } else { - const updated = [...currentJoins]; - updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns }; - updateConfig({ entityJoins: updated }); + newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: updatedColumns }; } + // config.columns에서도 제거 + newColumns = newColumns.filter(c => !(c.key === displayField && c.isJoinColumn)); } else { - const updated = [...currentJoins]; - updated[existingJoinIdx] = { + newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }], }; - updateConfig({ entityJoins: updated }); + // config.columns에 추가 + newColumns.push({ + key: displayField, + title: refColumnLabel, + width: "auto", + visible: true, + editable: false, + isJoinColumn: true, + inputType: columnType || "text", + }); } } else { - updateConfig({ - entityJoins: [ - ...currentJoins, - { - sourceColumn, - referenceTable: joinTableName, - columns: [{ referenceField: refColumnName, displayField }], - }, - ], + newEntityJoins.push({ + sourceColumn, + referenceTable: joinTableName, + columns: [{ referenceField: refColumnName, displayField }], + }); + // config.columns에 추가 + newColumns.push({ + key: displayField, + title: refColumnLabel, + width: "auto", + visible: true, + editable: false, + isJoinColumn: true, + inputType: columnType || "text", }); } + + updateConfig({ entityJoins: newEntityJoins, columns: newColumns }); }, - [config.entityJoins, updateConfig], + [config.entityJoins, config.columns, updateConfig], ); // Entity 조인에 특정 컬럼이 설정되어 있는지 확인 @@ -604,9 +622,9 @@ export const V2RepeaterConfigPanel: React.FC = ({ // 컬럼 토글 (현재 테이블 컬럼 - 입력용) const toggleInputColumn = (column: ColumnOption) => { - const existingIndex = config.columns.findIndex((c) => c.key === column.columnName); + const existingIndex = config.columns.findIndex((c) => c.key === column.columnName && !c.isJoinColumn && !c.isSourceDisplay); if (existingIndex >= 0) { - const newColumns = config.columns.filter((c) => c.key !== column.columnName); + const newColumns = config.columns.filter((_, i) => i !== existingIndex); updateConfig({ columns: newColumns }); } else { // 컬럼의 inputType과 detailSettings 정보 포함 @@ -651,7 +669,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ }; const isColumnAdded = (columnName: string) => { - return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay); + return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay && !c.isJoinColumn); }; const isSourceColumnSelected = (columnName: string) => { @@ -761,10 +779,9 @@ export const V2RepeaterConfigPanel: React.FC = ({ return (
- + 기본 컬럼 - Entity 조인 {/* 기본 설정 탭 */} @@ -1365,6 +1382,84 @@ export const V2RepeaterConfigPanel: React.FC = ({ )}
+ {/* ===== 🆕 Entity 조인 컬럼 (표시용) ===== */} +
+
+ + +
+

+ FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다. +

+ + {loadingEntityJoins ? ( +

로딩 중...

+ ) : entityJoinData.joinTables.length === 0 ? ( +

+ {entityJoinTargetTable + ? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다` + : "저장 테이블을 먼저 설정해주세요"} +

+ ) : ( +
+ {entityJoinData.joinTables.map((joinTable, tableIndex) => { + const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || ""; + + return ( +
+
+ + {joinTable.tableName} + ({sourceColumn}) +
+
+ {joinTable.availableColumns.map((column, colIndex) => { + const isActive = isEntityJoinColumnActive( + joinTable.tableName, + sourceColumn, + column.columnName, + ); + const matchingCol = config.columns.find((c) => c.key === column.columnName && c.isJoinColumn); + const displayField = matchingCol?.key || column.columnName; + + return ( +
+ toggleEntityJoinColumn( + joinTable.tableName, + sourceColumn, + column.columnName, + column.columnLabel, + displayField, + column.inputType || column.dataType + ) + } + > + + + {column.columnLabel} + + {column.inputType || column.dataType} + +
+ ); + })} +
+
+ ); + })} +
+ )} +
+ {/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */} {config.columns.length > 0 && ( <> @@ -1381,7 +1476,7 @@ export const V2RepeaterConfigPanel: React.FC = ({
= ({ {/* 확장/축소 버튼 (입력 컬럼만) */} - {!col.isSourceDisplay && ( + {(!col.isSourceDisplay && !col.isJoinColumn) && (
{/* 확장된 상세 설정 (입력 컬럼만) */} - {!col.isSourceDisplay && expandedColumn === col.key && ( + {(!col.isSourceDisplay && !col.isJoinColumn) && expandedColumn === col.key && (
{/* 자동 입력 설정 */}
@@ -1812,120 +1916,6 @@ export const V2RepeaterConfigPanel: React.FC = ({ )} - {/* Entity 조인 설정 탭 */} - -
-
-

Entity 조인 연결

-

- FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다 -

-
-
- - {loadingEntityJoins ? ( -

로딩 중...

- ) : entityJoinData.joinTables.length === 0 ? ( -
-

- {entityJoinTargetTable - ? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다` - : "저장 테이블을 먼저 설정해주세요"} -

-
- ) : ( -
- {entityJoinData.joinTables.map((joinTable, tableIndex) => { - const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || ""; - - return ( -
-
- - {joinTable.tableName} - ({sourceColumn}) -
-
- {joinTable.availableColumns.map((column, colIndex) => { - const isActive = isEntityJoinColumnActive( - joinTable.tableName, - sourceColumn, - column.columnName, - ); - const matchingCol = config.columns.find((c) => c.key === column.columnName); - const displayField = matchingCol?.key || column.columnName; - - return ( -
- toggleEntityJoinColumn( - joinTable.tableName, - sourceColumn, - column.columnName, - column.columnLabel, - displayField, - ) - } - > - - - {column.columnLabel} - - {column.inputType || column.dataType} - -
- ); - })} -
-
- ); - })} -
- )} - - {/* 현재 설정된 Entity 조인 목록 */} - {config.entityJoins && config.entityJoins.length > 0 && ( -
-

설정된 조인

-
- {config.entityJoins.map((join, idx) => ( -
- - {join.sourceColumn} - - {join.referenceTable} - - ({join.columns.map((c) => c.referenceField).join(", ")}) - - -
- ))} -
-
- )} -
-
-
); From 20c85569b0a23b0d3613f25387e3c1aadb108230 Mon Sep 17 00:00:00 2001 From: kmh Date: Thu, 12 Mar 2026 07:02:22 +0900 Subject: [PATCH 23/25] fix: update filter handling in data filtering logic - Refactored the handling of "in" and "not_in" operators to ensure proper array handling and prevent errors when values are not provided. - Enhanced the InteractiveDataTable component to re-fetch data when filters are applied, improving user experience. - Updated DataFilterConfigPanel to correctly manage filter values based on selected operators. - Adjusted SplitPanelLayoutComponent to apply client-side data filtering based on defined conditions. These changes aim to improve the robustness and usability of the data filtering features across the application. --- ai-assistant/package-lock.json | 2 + .../src/services/tableManagementService.ts | 16 +++-- backend-node/src/utils/dataFilterUtil.ts | 24 ++++---- .../screen/InteractiveDataTable.tsx | 17 ++++++ .../config-panels/DataFilterConfigPanel.tsx | 27 ++++++++- .../table-options/TableOptionsToolbar.tsx | 5 +- .../SplitPanelLayoutComponent.tsx | 58 ++++++++++++++++++- .../SplitPanelLayoutConfigPanel.tsx | 8 +-- 8 files changed, 129 insertions(+), 28 deletions(-) diff --git a/ai-assistant/package-lock.json b/ai-assistant/package-lock.json index 30eef7bc..5cc0f755 100644 --- a/ai-assistant/package-lock.json +++ b/ai-assistant/package-lock.json @@ -947,6 +947,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -2184,6 +2185,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index d727a96e..0273b1fc 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3367,22 +3367,26 @@ export class TableManagementService { `${safeColumn} != '${String(value).replace(/'/g, "''")}'` ); break; - case "in": - if (Array.isArray(value) && value.length > 0) { - const values = value + case "in": { + const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (inArr.length > 0) { + const values = inArr .map((v) => `'${String(v).replace(/'/g, "''")}'`) .join(", "); filterConditions.push(`${safeColumn} IN (${values})`); } break; - case "not_in": - if (Array.isArray(value) && value.length > 0) { - const values = value + } + case "not_in": { + const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (notInArr.length > 0) { + const values = notInArr .map((v) => `'${String(v).replace(/'/g, "''")}'`) .join(", "); filterConditions.push(`${safeColumn} NOT IN (${values})`); } break; + } case "contains": filterConditions.push( `${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'` diff --git a/backend-node/src/utils/dataFilterUtil.ts b/backend-node/src/utils/dataFilterUtil.ts index a4e81fd6..0f472331 100644 --- a/backend-node/src/utils/dataFilterUtil.ts +++ b/backend-node/src/utils/dataFilterUtil.ts @@ -98,23 +98,27 @@ export function buildDataFilterWhereClause( paramIndex++; break; - case "in": - if (Array.isArray(value) && value.length > 0) { - const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + case "in": { + const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (inArr.length > 0) { + const placeholders = inArr.map((_, idx) => `$${paramIndex + idx}`).join(", "); conditions.push(`${columnRef} IN (${placeholders})`); - params.push(...value); - paramIndex += value.length; + params.push(...inArr); + paramIndex += inArr.length; } break; + } - case "not_in": - if (Array.isArray(value) && value.length > 0) { - const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + case "not_in": { + const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (notInArr.length > 0) { + const placeholders = notInArr.map((_, idx) => `$${paramIndex + idx}`).join(", "); conditions.push(`${columnRef} NOT IN (${placeholders})`); - params.push(...value); - paramIndex += value.length; + params.push(...notInArr); + paramIndex += notInArr.length; } break; + } case "contains": conditions.push(`${columnRef} LIKE $${paramIndex}`); diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 62472b96..9b5f1693 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -605,6 +605,23 @@ export const InteractiveDataTable: React.FC = ({ } }, [relatedButtonFilter]); + // TableOptionsContext 필터 변경 시 데이터 재조회 (TableSearchWidget 연동) + const filtersAppliedRef = useRef(false); + useEffect(() => { + // 초기 렌더 시 빈 배열은 무시 (불필요한 재조회 방지) + if (!filtersAppliedRef.current && filters.length === 0) return; + filtersAppliedRef.current = true; + + const filterSearchParams: Record = {}; + filters.forEach((f) => { + if (f.value !== "" && f.value !== undefined && f.value !== null) { + filterSearchParams[f.columnName] = f.value; + } + }); + loadData(1, { ...searchValues, ...filterSearchParams }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters]); + // 카테고리 타입 컬럼의 값 매핑 로드 useEffect(() => { const loadCategoryMappings = async () => { diff --git a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx index 48a7cbf9..3cffffff 100644 --- a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx @@ -541,8 +541,31 @@ export function DataFilterConfigPanel({ {/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */} {filter.valueType === "category" && categoryValues[filter.columnName] ? (