From 599b5a4426c67859b358644aebf097b3ed43fea7 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Mar 2026 16:56:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop):=20pop-card-list-v2=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=20=EA=B8=B0=EB=B0=98=20=EC=B9=B4=EB=93=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=8B=A0=EA=B7=9C=20+=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=EB=B2=94=EC=9A=A9?= =?UTF-8?q?=ED=99=94=20+=20=EC=95=A1=EC=85=98=20=EC=9D=B8=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=84=A4=EC=A0=95=20CSS=20Grid=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=8A=AC=EB=A1=AF=20=EA=B5=AC=EC=A1=B0=EC=9D=98=20?= =?UTF-8?q?pop-card-list-v2=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20pop-card-list=EC=9D=98=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=A1=9C=EB=94=A9/=ED=95=84=ED=84=B0=EB=A7=81/?= =?UTF-8?q?=EC=9E=A5=EB=B0=94=EA=B5=AC=EB=8B=88=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20=EC=9E=AC=ED=99=9C=EC=9A=A9=ED=95=98=EB=90=98,=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=82=B4=EB=B6=80=EB=8A=94=2012=EC=A2=85?= =?UTF-8?q?=20=EC=85=80=20=ED=83=80=EC=9E=85(text/field/image/badge/button?= =?UTF-8?q?/number-input/=20cart-button/package-summary/status-badge/timel?= =?UTF-8?q?ine/action-buttons/=20footer-status)=EC=9D=98=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=EC=9C=BC=EB=A1=9C=20=EC=9E=90=EC=9C=A0=EB=A1=AD?= =?UTF-8?q?=EA=B2=8C=20=EA=B5=AC=EC=84=B1=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8B=A4.=20[=EC=8B=A0=EA=B7=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8:=20pop-card-list-v2]=20-=20PopCardListV2Component:=20?= =?UTF-8?q?=EB=9F=B0=ED=83=80=EC=9E=84=20=EB=A0=8C=EB=8D=94=EB=A7=81=20(?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20+=20CSS=20Gr?= =?UTF-8?q?id=20=EC=B9=B4=EB=93=9C)=20-=20PopCardListV2Config:=203?= =?UTF-8?q?=ED=83=AD=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20(?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0/=EC=B9=B4=EB=93=9C=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8/=EB=8F=99=EC=9E=91)=20-=20PopCardListV2Previ?= =?UTF-8?q?ew:=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20-=20cell-renderers:=20=EC=85=80=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EB=B3=84=20=EB=8F=85=EB=A6=BD=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=9F=AC=2012=EC=A2=85=20-=20migrate:=20v1=20->=20v2?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=ED=95=A8=EC=88=98=20-=20index:=20PopCompo?= =?UTF-8?q?nentRegistry=20=EC=9E=90=EB=8F=99=20=EB=93=B1=EB=A1=9D=20[?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=86=8C=EC=8A=A4=20=EB=B2=94=EC=9A=A9=ED=99=94]?= =?UTF-8?q?=20-=20TimelineDataSource=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EB=A1=9C=20=EA=B3=B5=EC=A0=95=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94/FK/=EC=BB=AC=EB=9F=BC/=EC=83=81=ED=83=9C?= =?UTF-8?q?=EA=B0=92=20=EB=A7=A4=ED=95=91=20=EC=84=A4=EC=A0=95=20-=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9(work=5Forders+work=5Forder?= =?UTF-8?q?=5Fprocess)=20=EC=A0=9C=EA=B1=B0=20->=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=8F=99=EC=A0=81=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?-=20injectProcessFlow:=20=EC=84=A4=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EA=B3=B5=EC=A0=95=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20+=20=5F=5FprocessFlow=5F=5F=20=EA=B0=80?= =?UTF-8?q?=EC=83=81=20=EC=BB=AC=EB=9F=BC=20=EC=A3=BC=EC=9E=85=20-=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EA=B0=92=20=EC=A0=95=EA=B7=9C=ED=99=94(DB?= =?UTF-8?q?=EA=B0=92=20->=20waiting/accepted/in=5Fprogress/completed)=20[?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=EB=B2=84=ED=8A=BC=20=EC=9D=B8=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=84=A4=EC=A0=95]=20-=20actionRules=20=EB=82=B4?= =?UTF-8?q?=20updates=20=EB=B0=B0=EC=97=B4=EB=A1=9C=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98=20(=EB=B3=84=EB=8F=84=20DB=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=B6=88=ED=95=84=EC=9A=94)=20-=20execute?= =?UTF-8?q?-action=20API=20=EC=9E=AC=ED=99=9C=EC=9A=A9=20(targetTable/colu?= =?UTF-8?q?mn/valueType)=20-=20=EB=B0=B1=EC=97=94=EB=93=9C=20=5F=5FCURRENT?= =?UTF-8?q?=5FUSER=5F=5F/=5F=5FCURRENT=5FTIME=5F=5F=20=ED=8A=B9=EC=88=98?= =?UTF-8?q?=EA=B0=92=20=EC=B9=98=ED=99=98=20[=EB=94=94=EC=9E=90=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=ED=86=B5=ED=95=A9]=20-=20PopComponentType=EC=97=90?= =?UTF-8?q?=20"pop-card-list-v2"=20=EC=B6=94=EA=B0=80=20-=20ComponentEdito?= =?UTF-8?q?rPanel/ComponentPalette/PopRenderer=20=EB=93=B1=EB=A1=9D=20-=20?= =?UTF-8?q?PopDesigner=20loadLayout:=20components=20=EC=A1=B4=EC=9E=AC=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20null=20=EC=B2=B4=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20[=EA=B8=B0=ED=83=80]=20-=20.gitignore:=20.gradle/?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + backend-node/src/routes/popActionRoutes.ts | 9 +- .../components/pop/designer/PopDesigner.tsx | 2 +- .../designer/panels/ComponentEditorPanel.tsx | 1 + .../pop/designer/panels/ComponentPalette.tsx | 6 + .../pop/designer/renderers/PopRenderer.tsx | 4 +- .../pop/designer/types/pop-layout.ts | 3 +- frontend/lib/registry/pop-components/index.ts | 1 + .../PopCardListV2Component.tsx | 907 +++++++ .../pop-card-list-v2/PopCardListV2Config.tsx | 2125 +++++++++++++++++ .../pop-card-list-v2/PopCardListV2Preview.tsx | 104 + .../pop-card-list-v2/cell-renderers.tsx | 732 ++++++ .../pop-components/pop-card-list-v2/index.tsx | 60 + .../pop-card-list-v2/migrate.ts | 163 ++ frontend/lib/registry/pop-components/types.ts | 174 ++ 15 files changed, 4291 insertions(+), 4 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx create mode 100644 frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx create mode 100644 frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx create mode 100644 frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx create mode 100644 frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx create mode 100644 frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts diff --git a/.gitignore b/.gitignore index 08276481..5e66bd12 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ dist/ build/ build/Release +# Gradle +.gradle/ +**/backend/.gradle/ + # Cache .npm .eslintcache diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index b36bc39e..4ff425e3 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -353,7 +353,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp if (valSource === "linked") { value = item[task.sourceField ?? ""] ?? null; } else { - value = task.fixedValue ?? ""; + const raw = task.fixedValue ?? ""; + if (raw === "__CURRENT_USER__") { + value = userId; + } else if (raw === "__CURRENT_TIME__") { + value = new Date().toISOString(); + } else { + value = raw; + } } let setSql: string; diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 902eb9a9..36241817 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -150,7 +150,7 @@ export default function PopDesigner({ try { const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); - if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) { + if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { // v5 레이아웃 로드 // 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가 if (!loadedLayout.settings.gapPreset) { diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 12c21e4f..77fbe950 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -69,6 +69,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-icon": "아이콘", "pop-dashboard": "대시보드", "pop-card-list": "카드 목록", + "pop-card-list-v2": "카드 목록 V2", "pop-field": "필드", "pop-button": "버튼", "pop-string-list": "리스트 목록", diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 471db3fd..9744075f 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -45,6 +45,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: LayoutGrid, description: "테이블 데이터를 카드 형태로 표시", }, + { + type: "pop-card-list-v2", + label: "카드 목록 V2", + icon: LayoutGrid, + description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)", + }, { type: "pop-button", label: "버튼", diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 0fb99fc5..9ff1aeee 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -72,10 +72,12 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-icon": "아이콘", "pop-dashboard": "대시보드", "pop-card-list": "카드 목록", + "pop-card-list-v2": "카드 목록 V2", "pop-button": "버튼", "pop-string-list": "리스트 목록", "pop-search": "검색", "pop-field": "입력", + "pop-scanner": "스캐너", "pop-profile": "프로필", }; @@ -555,7 +557,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect if (ActualComp) { // 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용 // CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용 - const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list"; + const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list" || component.type === "pop-card-list-v2"; return (
; + +// cart_items 행 파싱 (pop-card-list에서 그대로 차용) +function parseCartRow(dbRow: Record): Record { + let rowData: Record = {}; + try { + const raw = dbRow.row_data; + if (typeof raw === "string" && raw.trim()) rowData = JSON.parse(raw); + else if (typeof raw === "object" && raw !== null) rowData = raw as Record; + } catch { rowData = {}; } + + return { + ...rowData, + __cart_id: dbRow.id, + __cart_quantity: Number(dbRow.quantity) || 0, + __cart_package_unit: dbRow.package_unit || "", + __cart_package_entries: dbRow.package_entries, + __cart_status: dbRow.status || "in_cart", + __cart_memo: dbRow.memo || "", + __cart_row_key: dbRow.row_key || "", + __cart_modified: false, + }; +} + +interface PopCardListV2ComponentProps { + config?: PopCardListV2Config; + className?: string; + screenId?: string; + componentId?: string; + currentRowSpan?: number; + currentColSpan?: number; + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; +} + +export function PopCardListV2Component({ + config, + className, + screenId, + componentId, + currentRowSpan, + currentColSpan, + onRequestResize, +}: PopCardListV2ComponentProps) { + const { subscribe, publish } = usePopEvent(screenId || "default"); + const router = useRouter(); + + const isCartListMode = config?.cartListMode?.enabled === true; + const [inheritedConfig, setInheritedConfig] = useState | null>(null); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + + const effectiveConfig = useMemo(() => { + if (!isCartListMode || !inheritedConfig) return config; + return { + ...config, + ...inheritedConfig, + cartListMode: config?.cartListMode, + dataSource: config?.dataSource, + } as PopCardListV2Config; + }, [config, inheritedConfig, isCartListMode]); + + const isHorizontalMode = (effectiveConfig?.scrollDirection || "vertical") === "horizontal"; + const maxGridColumns = effectiveConfig?.gridColumns || 2; + const configGridRows = effectiveConfig?.gridRows || 3; + const dataSource = effectiveConfig?.dataSource; + const cardGrid = effectiveConfig?.cardGrid; + + const sourceTableName = (!isCartListMode && dataSource?.tableName) || ""; + const cart = useCartSync(screenId || "", sourceTableName); + + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 외부 필터 + const [externalFilters, setExternalFilters] = useState< + Map + >(new Map()); + + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__filter_condition`, + (payload: unknown) => { + const data = payload as { + value?: { fieldName?: string; value?: unknown }; + filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string }; + _connectionId?: string; + }; + const connId = data?._connectionId || "default"; + setExternalFilters((prev) => { + const next = new Map(prev); + if (data?.value?.value) { + next.set(connId, { + fieldName: data.value.fieldName || "", + value: data.value.value, + filterConfig: data.filterConfig, + }); + } else { + next.delete(connId); + } + return next; + }); + }, + ); + return unsub; + }, [componentId, subscribe]); + + const cartRef = useRef(cart); + cartRef.current = cart; + + // 저장 요청 수신 + useEffect(() => { + if (!componentId || isCartListMode) return; + const unsub = subscribe( + `__comp_input__${componentId}__cart_save_trigger`, + async (payload: unknown) => { + const data = payload as { value?: { selectedColumns?: string[] } } | undefined; + const ok = await cartRef.current.saveToDb(data?.value?.selectedColumns); + publish(`__comp_output__${componentId}__cart_save_completed`, { success: ok }); + }, + ); + return unsub; + }, [componentId, subscribe, publish, isCartListMode]); + + // 초기 장바구니 상태 전달 + useEffect(() => { + if (!componentId || cart.loading || isCartListMode) return; + publish(`__comp_output__${componentId}__cart_updated`, { + count: cart.cartCount, + isDirty: cart.isDirty, + }); + }, [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 [isExpanded, setIsExpanded] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [originalRowSpan, setOriginalRowSpan] = useState(null); + + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + const baseContainerHeight = useRef(0); + + useEffect(() => { + if (!containerRef.current) return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const { width, height } = entry.contentRect; + if (width > 0) setContainerWidth(width); + if (height > 0) setContainerHeight(height); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + const cardSizeKey = effectiveConfig?.cardSize || "large"; + const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; + + const maxAllowedColumns = useMemo(() => { + if (!currentColSpan) return maxGridColumns; + if (currentColSpan >= 8) return maxGridColumns; + return 1; + }, [currentColSpan, maxGridColumns]); + + const minCardWidth = Math.round(spec.height * 1.6); + const autoColumns = containerWidth > 0 + ? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap))) + : maxGridColumns; + 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); + } + }); + }), + ); + }, [rows, externalFilters]); + + const overflowCfg = effectiveConfig?.overflow; + const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows; + const visibleCardCount = useMemo(() => Math.max(1, baseVisibleCount), [baseVisibleCount]); + const hasMoreCards = filteredRows.length > visibleCardCount; + const expandedCardsPerPage = useMemo(() => { + if (overflowCfg?.mode === "pagination" && overflowCfg.pageSize) return overflowCfg.pageSize; + if (overflowCfg?.mode === "loadMore" && overflowCfg.loadMoreCount) return overflowCfg.loadMoreCount + visibleCardCount; + return Math.max(1, visibleCardCount * 2 + gridColumns); + }, [visibleCardCount, gridColumns, overflowCfg]); + + const scrollAreaRef = useRef(null); + + const displayCards = useMemo(() => { + if (!isExpanded) return filteredRows.slice(0, visibleCardCount); + const start = (currentPage - 1) * expandedCardsPerPage; + return filteredRows.slice(start, start + expandedCardsPerPage); + }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); + + const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1; + const needsPagination = isExpanded && totalPages > 1; + + const toggleExpand = () => { + if (isExpanded) { + if (!isHorizontalMode && originalRowSpan !== null && componentId && onRequestResize) { + onRequestResize(componentId, originalRowSpan); + } + setCurrentPage(1); + setOriginalRowSpan(null); + baseContainerHeight.current = 0; + setIsExpanded(false); + } else { + baseContainerHeight.current = containerHeight; + if (!isHorizontalMode && componentId && onRequestResize && currentRowSpan !== undefined) { + setOriginalRowSpan(currentRowSpan); + onRequestResize(componentId, currentRowSpan * 2); + } + setIsExpanded(true); + } + }; + + useEffect(() => { + if (scrollAreaRef.current && isExpanded) { + scrollAreaRef.current.scrollTop = 0; + scrollAreaRef.current.scrollLeft = 0; + } + }, [currentPage, isExpanded]); + + // 데이터 조회 + const dataSourceKey = useMemo(() => JSON.stringify(dataSource || null), [dataSource]); + const cartListModeKey = useMemo(() => JSON.stringify(config?.cartListMode || null), [config?.cartListMode]); + + // 셀 설정에서 timelineSource 탐색 (timeline/status-badge/action-buttons 중 하나에 설정됨) + const timelineSource = useMemo(() => { + const cells = cardGrid?.cells || []; + for (const c of cells) { + if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons") && c.timelineSource?.processTable) { + return c.timelineSource; + } + } + return undefined; + }, [cardGrid?.cells]); + + // 공정 데이터 조회 + __processFlow__ 가상 컬럼 주입 + const injectProcessFlow = useCallback(async ( + fetchedRows: RowData[], + src: TimelineDataSource, + ): Promise => { + if (fetchedRows.length === 0) return fetchedRows; + 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"; + + const processResult = await dataApi.getTableData(src.processTable, { + page: 1, + size: 1000, + sortBy: src.seqColumn || "seq_no", + sortOrder: "asc", + }); + const allProcesses = processResult.data || []; + + const processMap = new Map(); + for (const p of allProcesses) { + const fkValue = String(p[src.foreignKey] || ""); + 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"; + + 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", + }); + } + + // isCurrent 보정: in_progress가 없으면 첫 waiting을 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) { + steps.forEach((s) => { s.isCurrent = false; }); + firstWaiting.isCurrent = true; + } + } + } + + return fetchedRows.map((row) => ({ + ...row, + __processFlow__: processMap.get(String(row.id)) || [], + })); + }, []); + + const fetchData = useCallback(async () => { + if (!dataSource?.tableName) { setLoading(false); setRows([]); return; } + + setLoading(true); + setError(null); + try { + const filters: Record = {}; + if (dataSource.filters?.length) { + dataSource.filters.forEach((f) => { + if (f.column && f.value && (!f.operator || f.operator === "=")) filters[f.column] = f.value; + }); + } + const sortArray = Array.isArray(dataSource.sort) + ? dataSource.sort + : dataSource.sort && typeof dataSource.sort === "object" + ? [dataSource.sort as { column: string; direction: "asc" | "desc" }] + : []; + const primarySort = sortArray[0]; + const size = dataSource.limit?.mode === "limited" && dataSource.limit?.count ? dataSource.limit.count : 100; + + const result = await dataApi.getTableData(dataSource.tableName, { + page: 1, + size, + sortBy: primarySort?.column || undefined, + sortOrder: primarySort?.direction, + filters: Object.keys(filters).length > 0 ? filters : undefined, + }); + + let fetchedRows = result.data || []; + const clientFilters = (dataSource.filters || []).filter( + (f) => f.column && f.value && f.operator && f.operator !== "=", + ); + if (clientFilters.length > 0) { + fetchedRows = fetchedRows.filter((row) => + clientFilters.every((f) => { + const cellVal = row[f.column]; + const filterVal = f.value; + switch (f.operator) { + case "!=": return String(cellVal ?? "") !== filterVal; + case ">": return Number(cellVal) > Number(filterVal); + case ">=": return Number(cellVal) >= Number(filterVal); + case "<": return Number(cellVal) < Number(filterVal); + case "<=": return Number(cellVal) <= Number(filterVal); + case "like": return String(cellVal ?? "").toLowerCase().includes(filterVal.toLowerCase()); + default: return true; + } + }), + ); + } + + // timelineSource 설정이 있으면 공정 데이터 조회하여 __processFlow__ 주입 + if (timelineSource) { + try { + fetchedRows = await injectProcessFlow(fetchedRows, timelineSource); + } catch { + // 공정 데이터 조회 실패 시 무시 (메인 데이터는 정상 표시) + } + } + + setRows(fetchedRows); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 조회 실패"); + setRows([]); + } finally { setLoading(false); } + }, [dataSource, timelineSource, injectProcessFlow]); + + useEffect(() => { + if (isCartListMode) { + const cartListMode = config!.cartListMode!; + if (!cartListMode.sourceScreenId) { setLoading(false); setRows([]); return; } + + const fetchCartData = async () => { + setLoading(true); + setError(null); + try { + try { + const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId!); + const componentsMap = layoutJson?.components || {}; + const componentList = Object.values(componentsMap) as any[]; + const matched = cartListMode.sourceComponentId + ? componentList.find((c: any) => c.id === cartListMode.sourceComponentId) + : componentList.find((c: any) => c.type === "pop-card-list-v2" || c.type === "pop-card-list"); + if (matched?.config) setInheritedConfig(matched.config); + } catch { /* 레이아웃 로드 실패 시 자체 config 폴백 */ } + + const cartFilters: Record = { status: cartListMode.statusFilter || "in_cart" }; + if (cartListMode.sourceScreenId) cartFilters.screen_id = String(cartListMode.sourceScreenId); + const result = await dataApi.getTableData("cart_items", { size: 500, filters: cartFilters }); + setRows((result.data || []).map(parseCartRow)); + } catch (err) { + setError(err instanceof Error ? err.message : "장바구니 데이터 조회 실패"); + setRows([]); + } finally { setLoading(false); } + }; + fetchCartData(); + return; + } + + fetchData(); + }, [dataSourceKey, isCartListMode, cartListModeKey, fetchData]); // eslint-disable-line react-hooks/exhaustive-deps + + // 장바구니 목록 모드 콜백 + const handleDeleteItem = useCallback((cartId: string) => { + setRows((prev) => prev.filter((r) => String(r.__cart_id) !== cartId)); + setSelectedKeys((prev) => { const next = new Set(prev); next.delete(cartId); return next; }); + }, []); + + const handleUpdateQuantity = useCallback((cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => { + setRows((prev) => prev.map((r) => { + if (String(r.__cart_id) !== cartId) return r; + return { ...r, __cart_quantity: quantity, __cart_package_unit: unit || r.__cart_package_unit, __cart_package_entries: entries || r.__cart_package_entries, __cart_modified: true }; + })); + }, []); + + // 데이터 수집 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__collect_data`, + (payload: unknown) => { + const request = (payload as Record)?.value as CollectDataRequest | undefined; + const selectedItems = isCartListMode + ? filteredRows.filter((r) => selectedKeys.has(String(r.__cart_id ?? ""))) + : rows; + const sm = config?.saveMapping; + const mapping = sm?.targetTable && sm.mappings.length > 0 + ? { targetTable: sm.targetTable, columnMapping: Object.fromEntries(sm.mappings.filter((m) => m.sourceField && m.targetColumn).map((m) => [m.sourceField, m.targetColumn])) } + : null; + const cartChanges = cart.isDirty ? cart.getChanges() : undefined; + const response: CollectedDataResponse = { + requestId: request?.requestId ?? "", + componentId: componentId, + componentType: "pop-card-list-v2", + data: { items: selectedItems, cartChanges } as any, + mapping, + }; + publish(`__comp_output__${componentId}__collected_data`, response); + }, + ); + return unsub; + }, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys, cart]); + + // 선택 항목 이벤트 + useEffect(() => { + if (!componentId || !isCartListMode) return; + const selectedItems = filteredRows.filter((r) => selectedKeys.has(String(r.__cart_id ?? ""))); + publish(`__comp_output__${componentId}__selected_items`, selectedItems); + }, [selectedKeys, filteredRows, componentId, isCartListMode, publish]); + + // 카드 영역 스타일 + const cardGap = effectiveConfig?.cardGap ?? spec.gap; + const cardMinHeight = spec.height; + const cardAreaStyle: React.CSSProperties = { + gap: `${cardGap}px`, + ...(isHorizontalMode + ? { + gridTemplateRows: `repeat(${gridRows}, minmax(${cardMinHeight}px, auto))`, + gridAutoFlow: "column", + gridAutoColumns: `${Math.round(cardMinHeight * 1.6)}px`, + } + : { + gridTemplateColumns: `repeat(${gridColumns}, 1fr)`, + gridAutoRows: `minmax(${cardMinHeight}px, auto)`, + }), + }; + + const scrollClassName = isHorizontalMode + ? "overflow-x-auto overflow-y-hidden" + : isExpanded + ? "overflow-y-auto overflow-x-hidden" + : "overflow-hidden"; + + return ( +
+ {isCartListMode && !config?.cartListMode?.sourceScreenId ? ( +
+

원본 화면을 선택해주세요.

+
+ ) : !isCartListMode && !dataSource?.tableName ? ( +
+

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

+
+ ) : loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : rows.length === 0 ? ( +
+

데이터가 없습니다.

+
+ ) : ( + <> + {isCartListMode && ( +
+ 0} + onChange={(e) => { + if (e.target.checked) { + setSelectedKeys(new Set(filteredRows.map((r) => String(r.__cart_id ?? "")))); + } else { + setSelectedKeys(new Set()); + } + }} + className="h-4 w-4 rounded border-input" + /> + + {selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"} + +
+ )} + +
+ {displayCards.map((row, index) => ( + { + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; + if (!cartId) return; + setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); + }} + onDeleteItem={handleDeleteItem} + onUpdateQuantity={handleUpdateQuantity} + onRefresh={fetchData} + /> + ))} +
+ + {hasMoreCards && ( +
+
+
+ + + {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; +}