From e347a7595346ab8dbf54c56c41bd53dcc27fa66b Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 29 Apr 2026 11:25:06 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20ConfigPanel=20hook/helper=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20+=20IconPicker=20cp+Portal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useDbTables: search/table/stats 의 DB 테이블 로드 hook (3 패널 중복 제거) - TableConnectSection + AutoLoadButton: search/table 의 테이블 연결 섹션 + 자동 로드 버튼 - row-helpers: RowNumberBadge / RowExpandChevron / RowDeleteBtn (4 패널 dense list helper) - IconPicker: shadcn 톤 -> cp 톤 (28px 트리거, focus glow, cp 변수) - IconPicker popover: React Portal + position:fixed (부모 overflow:hidden 우회) - input X버튼은 hoverBg={false} 로 silent visual change 원복 Codex (GPT-5.5) 와 매 단계 교차검증 후 진행 미완 후속 사항 (auto-flip / 외부 클릭 닫기 / z-index 표준화 등) 노트에 기록 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../registry/components/common/IconPicker.tsx | 213 ++++++++++++++++-- .../components/common/TableConnectSection.tsx | 107 +++++++++ .../components/common/row-helpers.tsx | 137 +++++++++++ .../registry/components/common/useDbTables.ts | 73 ++++++ .../components/input/InvInputConfigPanel.tsx | 21 +- .../search/InvSearchConfigPanel.tsx | 147 ++---------- .../components/stats/InvStatsConfigPanel.tsx | 100 ++------ .../components/table/InvTableConfigPanel.tsx | 172 +++----------- notes/gbpark/2026-04-28-next-session-todos.md | 119 +++++++++- 9 files changed, 687 insertions(+), 402 deletions(-) create mode 100644 frontend/lib/registry/components/common/TableConnectSection.tsx create mode 100644 frontend/lib/registry/components/common/row-helpers.tsx create mode 100644 frontend/lib/registry/components/common/useDbTables.ts diff --git a/frontend/lib/registry/components/common/IconPicker.tsx b/frontend/lib/registry/components/common/IconPicker.tsx index 7c3222f6..e637120f 100644 --- a/frontend/lib/registry/components/common/IconPicker.tsx +++ b/frontend/lib/registry/components/common/IconPicker.tsx @@ -1,6 +1,16 @@ "use client"; -import React, { useState, useMemo } from "react"; +/** + * IconPicker — cp 톤 아이콘 picker + * + * Codex 검토 (2026-04-29 Y4) 권고: stats/button 두 cp 패널 공용 UI 일관성 확보. + * 외부 사용처 (layout/admin/dash) 는 별개 `MenuIconPicker` 사용 → 영향 없음. + * + * 시그니처 / 동작 (외부 클릭 닫기 없음 등) 은 이전과 동일. 시각 톤만 cp 변수 + 28px 표준 사이즈로 통일. + */ + +import React, { useState, useMemo, useRef, useEffect } from "react"; +import { createPortal } from "react-dom"; import * as LucideIcons from "lucide-react"; // lucide-react에서 실제 아이콘만 추출 (유틸/타입 제외) @@ -32,10 +42,42 @@ interface IconPickerProps { export const IconPicker: React.FC = ({ value, onChange, className }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); + const [focused, setFocused] = useState(false); + const triggerRef = useRef(null); + const [popoverPos, setPopoverPos] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); + + // 트리거 위치 잡고 popover 열기 (Portal 사용 — 부모 overflow:hidden 영향 회피) + const handleToggle = () => { + if (!open && triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setPopoverPos({ + top: rect.bottom + 2, + left: rect.left, + width: rect.width, + }); + } + setOpen((v) => !v); + }; + + // popover 떠 있는 동안 외부 scroll / resize 발생 시 닫기 + // (좌표 따라가는 대신 닫음 — 사용자가 다시 열면 새 좌표) + useEffect(() => { + if (!open) return; + const close = () => setOpen(false); + window.addEventListener("scroll", close, true); + window.addEventListener("resize", close); + return () => { + window.removeEventListener("scroll", close, true); + window.removeEventListener("resize", close); + }; + }, [open]); const filtered = useMemo(() => { if (!search.trim()) { - // 인기 아이콘 + 나머지 (최대 80개) const popularSet = new Set(POPULAR); const popular = ICON_ENTRIES.filter(([n]) => popularSet.has(n)); const rest = ICON_ENTRIES.filter(([n]) => !popularSet.has(n)).slice(0, 80 - popular.length); @@ -45,60 +87,179 @@ export const IconPicker: React.FC = ({ value, onChange, classNa return ICON_ENTRIES.filter(([name]) => name.toLowerCase().includes(q)).slice(0, 80); }, [search]); - // 현재 선택된 아이콘 렌더 const SelectedIcon = value ? (LucideIcons as any)[value] : null; return ( -
+
- {open && ( -
-
+ {open && popoverPos && createPortal( +
+ {/* 검색 */} +
setSearch(e.target.value)} placeholder="아이콘 검색..." - className="border-border bg-background w-full rounded border px-2 py-1 text-[0.65rem]" autoFocus + style={{ + width: "100%", + height: 24, + padding: "0 8px", + fontSize: 11, + background: "var(--cp-bg-subtle)", + border: "1px solid var(--cp-border)", + borderRadius: 4, + color: "var(--cp-text)", + outline: "none", + fontFamily: "var(--v5-font-sans)", + }} />
-
+ + {/* 그리드 */} +
{/* 선택 해제 */} {filtered.map(([name, Icon]) => { const IconComp = Icon as React.FC<{ size?: number }>; + const active = name === value; return ( @@ -106,11 +267,19 @@ export const IconPicker: React.FC = ({ value, onChange, classNa })}
{filtered.length === 0 && ( -
+
“{search}” 결과 없음
)} -
+
, + document.body, )}
); diff --git a/frontend/lib/registry/components/common/TableConnectSection.tsx b/frontend/lib/registry/components/common/TableConnectSection.tsx new file mode 100644 index 00000000..7fda067c --- /dev/null +++ b/frontend/lib/registry/components/common/TableConnectSection.tsx @@ -0,0 +1,107 @@ +"use client"; + +/** + * TableConnectSection — "① 테이블 연결" CPSection + CPRow "테이블" + CPSelect 공통화 + * + * Codex 검토 (2026-04-29) 가이드: + * - 추출 경계 = "테이블 select + 버튼 슬롯" 에 한정 + * - autoload 로직 (callback / 조건 / 컬럼 fetch) 은 절대 안에 넣지 말 것 (silent breakage 위험) + * - 호출처가 children 으로 자동 로드 버튼 + Hint 등을 직접 주입 + * + * 사용처: search / table InvXxxConfigPanel (2 패널) + * + * AutoLoadButton 도 동시 export — 38줄 동일 inline 스타일 중복 제거. + * - autoload 로직은 onClick prop 으로만 받고, 안에 넣지 않음 + */ + +import React from "react"; +import { Wand2 } from "lucide-react"; +import { CPSection, CPRow, CPSelect } from "@/components/v2/config-panels/_shared/cp"; + +export interface TableConnectSectionProps { + value: string; + onChange: (v: string) => void; + options: { value: string; label: string }[]; + title?: string; + desc?: string; + rowLabel?: string; + placeholder?: string; + children?: React.ReactNode; +} + +export const TableConnectSection: React.FC = ({ + value, + onChange, + options, + title = "① 테이블 연결", + desc, + rowLabel = "테이블", + placeholder = "선택...", + children, +}) => ( + + + + + {options.map((t) => ( + + ))} + + + {children} + +); + +export interface AutoLoadButtonProps { + label: string; + onClick: () => void; + iconSize?: number; +} + +export const AutoLoadButton: React.FC = ({ + label, + onClick, + iconSize = 11, +}) => ( +
+ +
+); diff --git a/frontend/lib/registry/components/common/row-helpers.tsx b/frontend/lib/registry/components/common/row-helpers.tsx new file mode 100644 index 00000000..059e7af4 --- /dev/null +++ b/frontend/lib/registry/components/common/row-helpers.tsx @@ -0,0 +1,137 @@ +"use client"; + +/** + * Row helpers — 4 패널 (search/table/stats/input) 의 dense list row 에서 반복되는 + * 작은 visual elements 만 추출. row 뼈대 자체는 호출처에 그대로 둠. + * + * Codex 검토 (2026-04-29) 가이드: + * - helper-only 추출 (작은 cp 컴포넌트 후보) + * - DenseListRow / ExpandableRow 같은 wrapper 는 ★ silent breakage 위험으로 보류 + * - 호출처별 row prop 폭증 + expanded state 위치 / hover 표시 / row padding 회귀 위험 + * + * 적용 패턴: + * - RowNumberBadge: search/table/stats 의 #번호 (4 패널 중 3) + * - RowExpandChevron: table/stats 의 ▸/▾ 펼침 버튼 (2 패널) + * - RowDeleteBtn: 4 패널 모두 사용 — visible/size/아이콘 prop 으로 미세 차이 흡수 + * + * silent visual change 알림: + * - input 의 옵션 ×버튼은 원래 hover bg 없었음 → helper 적용 시 hover 시 옅은 red bg 추가됨 + * - 통일 차원에서 의도적 + */ + +import React from "react"; + +export interface RowNumberBadgeProps { + n: number; +} + +export const RowNumberBadge: React.FC = ({ n }) => ( + + {n} + +); + +export interface RowExpandChevronProps { + expanded: boolean; + onToggle: () => void; + size?: number; +} + +export const RowExpandChevron: React.FC = ({ + expanded, + onToggle, + size = 18, +}) => ( + +); + +export interface RowDeleteBtnProps { + onClick: () => void; + /** + * 부모 hover state — false 면 transparent (자리 차지). 기본 true (항상 visible). + * + * ★ search/table/stats 같은 dense list row 에서는 반드시 `visible={hover}` 명시할 것. + * 생략하면 hover 와 무관하게 항상 빨간 ×버튼 노출 → UX 어긋남. + */ + visible?: boolean; + size?: number; + /** 아이콘 (기본 "×" 텍스트) */ + children?: React.ReactNode; + title?: string; + /** hover 시 옅은 red bg 표시 (기본 true). false 면 hover bg 없이 색만 강조. */ + hoverBg?: boolean; +} + +export const RowDeleteBtn: React.FC = ({ + onClick, + visible = true, + size = 22, + children = "×", + title = "제거", + hoverBg = true, +}) => ( + +); diff --git a/frontend/lib/registry/components/common/useDbTables.ts b/frontend/lib/registry/components/common/useDbTables.ts new file mode 100644 index 00000000..20075cf9 --- /dev/null +++ b/frontend/lib/registry/components/common/useDbTables.ts @@ -0,0 +1,73 @@ +"use client"; + +/** + * useDbTables — DB 테이블 목록을 mount 시 한 번 로드하고, CPSelect 옵션 형식으로 제공 + * + * Codex 검토 (2026-04-28) 권장: + * - normalize 기본값은 hook 안에 둠 (snake/camel 모두 흡수) + * - 호출처가 다른 우선순위 / 라벨을 원하면 opts.normalize 로 override + * + * 사용처: search / table / stats InvXxxConfigPanel (3 패널) + * - 모두 같은 useEffect + useMemo 패턴 중복이었음 → hook 으로 통합 + */ + +import { useState, useEffect, useMemo } from "react"; + +export interface UseDbTablesOptions { + fallback?: any[]; + normalize?: (t: any) => { value: string; label: string }; +} + +export interface UseDbTablesResult { + tables: any[]; + options: { value: string; label: string }[]; + loading: boolean; +} + +const defaultNormalize = (t: any): { value: string; label: string } => ({ + value: t.tableName || t.table_name || "", + label: + t.display_name || + t.tableLabel || + t.table_label || + t.tableName || + t.table_name || + "", +}); + +export function useDbTables(opts: UseDbTablesOptions = {}): UseDbTablesResult { + const { fallback, normalize = defaultNormalize } = opts; + + const [allDbTables, setAllDbTables] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const res = await tableManagementApi.getTableList(); + if (!cancelled && res.success && res.data) setAllDbTables(res.data); + } catch { + /* ignore */ + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const tables = useMemo( + () => (allDbTables.length > 0 ? allDbTables : fallback || []), + [allDbTables, fallback], + ); + + const options = useMemo( + () => tables.map(normalize).filter((o) => o.value), + [tables, normalize], + ); + + return { tables, options, loading }; +} diff --git a/frontend/lib/registry/components/input/InvInputConfigPanel.tsx b/frontend/lib/registry/components/input/InvInputConfigPanel.tsx index 40883330..1c679255 100644 --- a/frontend/lib/registry/components/input/InvInputConfigPanel.tsx +++ b/frontend/lib/registry/components/input/InvInputConfigPanel.tsx @@ -37,6 +37,7 @@ import { FeatureChipGrid, Hint, } from "@/components/v2/config-panels/_shared/cp"; +import { RowDeleteBtn } from "../common/row-helpers"; import type { InputConfig, InputFieldType } from "./types"; export interface InvInputConfigPanelProps { @@ -273,25 +274,9 @@ export const InvInputConfigPanel: React.FC = ({ fontFamily: "var(--v5-font-sans)", }} /> - +
))}
diff --git a/frontend/lib/registry/components/search/InvSearchConfigPanel.tsx b/frontend/lib/registry/components/search/InvSearchConfigPanel.tsx index 5e1a044a..e53f605c 100644 --- a/frontend/lib/registry/components/search/InvSearchConfigPanel.tsx +++ b/frontend/lib/registry/components/search/InvSearchConfigPanel.tsx @@ -12,8 +12,8 @@ * Reference: notes/gbpark/2026-04-28-cp-panel-standard.md */ -import React, { useCallback, useMemo, useState, useEffect } from "react"; -import { Trash2, Wand2 } from "lucide-react"; +import React, { useCallback, useMemo, useState } from "react"; +import { Trash2 } from "lucide-react"; import { CPSection, CPRow, @@ -21,11 +21,12 @@ import { CPText, CPSelect, CPSegment, - CPIconBtn, FeatureChipGrid, Hint, - SectionLabel, } from "@/components/v2/config-panels/_shared/cp"; +import { useDbTables } from "../common/useDbTables"; +import { TableConnectSection, AutoLoadButton } from "../common/TableConnectSection"; +import { RowNumberBadge, RowDeleteBtn } from "../common/row-helpers"; import type { SearchConfig } from "./types"; export interface InvSearchConfigPanelProps { @@ -78,32 +79,7 @@ export const InvSearchConfigPanel: React.FC = ({ const fields: SearchField[] = (current as any).fields ?? []; const connectedTable = (current as any).selectedTable || screenTableName; - // 전체 DB 테이블 목록 로드 - const [allDbTables, setAllDbTables] = useState([]); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const { tableManagementApi } = await import("@/lib/api/tableManagement"); - const res = await tableManagementApi.getTableList(); - if (!cancelled && res.success && res.data) setAllDbTables(res.data); - } catch { - /* ignore */ - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const tableOptions = useMemo(() => { - const src = allDbTables.length > 0 ? allDbTables : tables || []; - return src.map((t: any) => ({ - value: t.tableName || t.table_name, - label: - t.display_name || t.tableLabel || t.table_label || t.tableName || t.table_name, - })); - }, [allDbTables, tables]); + const { options: tableOptions } = useDbTables({ fallback: tables }); const autoLoadFields = useCallback(() => { if (!tableColumns?.length) return; @@ -139,66 +115,23 @@ export const InvSearchConfigPanel: React.FC = ({ return (
{/* ── ① 테이블 연결 ─────────────────────────── */} - - - { - onTableChange?.(v); - patch({ selectedTable: v } as any); - }} - > - - {tableOptions.map((t) => ( - - ))} - - + { + onTableChange?.(v); + patch({ selectedTable: v } as any); + }} + options={tableOptions} + desc="검색 대상 테이블" + > {connectedTable && tableColumns && tableColumns.length > 0 && ( -
- -
+ )} {!connectedTable && 테이블 선택 시 자동 로드 버튼이 활성화됩니다.} -
+ {/* ── ② 입력 ─────────────────────────── */} @@ -324,16 +257,7 @@ function SearchFieldRow({ }} > {/* 번호 */} - - {index + 1} - + {/* type 색 dot */} {/* 호버 시 × (자리는 항상 차지 — layout shift 방지) */} - +
); } diff --git a/frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx b/frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx index 622cc4fb..ee8ed74f 100644 --- a/frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx +++ b/frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx @@ -13,7 +13,7 @@ * Reference: notes/gbpark/2026-04-28-cp-panel-standard.md */ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState } from "react"; import { Plus, TrendingUp, TrendingDown, Minus } from "lucide-react"; import { CPSection, @@ -25,9 +25,14 @@ import { CPColor, CPVisualGrid, Hint, - SectionLabel, } from "@/components/v2/config-panels/_shared/cp"; import { IconPicker } from "../common/IconPicker"; +import { useDbTables } from "../common/useDbTables"; +import { + RowNumberBadge, + RowExpandChevron, + RowDeleteBtn, +} from "../common/row-helpers"; import type { StatsConfig, StatsItem } from "./types"; const COLOR_PRESETS = [ @@ -79,32 +84,7 @@ export const InvStatsConfigPanel: React.FC = ({ patch({ items: items.filter((_, i) => i !== idx) }); }; - // 데이터 소스 테이블 목록 로드 - const [allTables, setAllTables] = useState([]); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const { tableManagementApi } = await import("@/lib/api/tableManagement"); - const res = await tableManagementApi.getTableList(); - if (!cancelled && res.success && res.data) setAllTables(res.data); - } catch { - /* ignore */ - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const tableOptions = useMemo( - () => - allTables.map((t: any) => ({ - value: t.table_name || t.tableName, - label: t.display_name || t.tableLabel || t.table_label || t.table_name || t.tableName, - })), - [allTables], - ); + const { options: tableOptions } = useDbTables(); const orientation = current.orientation || "horizontal"; const styleMode = current.style || "card"; @@ -441,16 +421,7 @@ function ItemEditRow({ minHeight: 28, }} > - - {index + 1} - + - - + setExpanded((x) => !x)} + /> +
{expanded && ( diff --git a/frontend/lib/registry/components/table/InvTableConfigPanel.tsx b/frontend/lib/registry/components/table/InvTableConfigPanel.tsx index 1621d715..1af59952 100644 --- a/frontend/lib/registry/components/table/InvTableConfigPanel.tsx +++ b/frontend/lib/registry/components/table/InvTableConfigPanel.tsx @@ -22,7 +22,6 @@ import { Grid3x3, LayoutGrid, Plus, - Wand2, AlignLeft, AlignCenter, AlignRight, @@ -39,8 +38,14 @@ import { CPVisualGrid, FeatureChipGrid, Hint, - InlineLoader, } from "@/components/v2/config-panels/_shared/cp"; +import { useDbTables } from "../common/useDbTables"; +import { TableConnectSection, AutoLoadButton } from "../common/TableConnectSection"; +import { + RowNumberBadge, + RowExpandChevron, + RowDeleteBtn, +} from "../common/row-helpers"; import type { TableConfig, TableColumn } from "./types"; export interface InvTableConfigPanelProps { @@ -73,32 +78,7 @@ export const InvTableConfigPanel: React.FC = ({ const columns: TableColumn[] = current.columns ?? []; const connectedTable = (current as any).selectedTable || screenTableName; - // ── 전체 DB 테이블 직접 로드 ── - const [allDbTables, setAllDbTables] = useState([]); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const { tableManagementApi } = await import("@/lib/api/tableManagement"); - const res = await tableManagementApi.getTableList(); - if (!cancelled && res.success && res.data) setAllDbTables(res.data); - } catch { - /* ignore */ - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const tableOptions = useMemo(() => { - const src = allDbTables.length > 0 ? allDbTables : tables || []; - return src.map((t: any) => ({ - value: t.tableName || t.table_name, - label: - t.display_name || t.tableLabel || t.table_label || t.tableName || t.table_name, - })); - }, [allDbTables, tables]); + const { options: tableOptions } = useDbTables({ fallback: tables }); // ── 연결된 테이블의 컬럼 로드 (자동 로드 button 용) ── const [connectedTableColumns, setConnectedTableColumns] = useState([]); @@ -193,24 +173,15 @@ export const InvTableConfigPanel: React.FC = ({ return (
{/* ── ① 테이블 연결 ─────────────────────────── */} - - - { - onTableChange?.(v); - patch({ selectedTable: v, columns: [] } as any); - }} - > - - {tableOptions.map((t) => ( - - ))} - - - + { + onTableChange?.(v); + patch({ selectedTable: v, columns: [] } as any); + }} + options={tableOptions} + desc="DB 테이블 매핑" + > {connectedTable && ( {loadingConnectedColumns @@ -222,47 +193,12 @@ export const InvTableConfigPanel: React.FC = ({ )} {connectedTable && effectiveTableColumns.length > 0 && ( -
- -
+ )} -
+ {/* ── ② 표시 모드 ─────────────────────────── */} @@ -513,16 +449,7 @@ function ColumnEditRow({ minHeight: 28, }} > - - {index + 1} - + - - + setExpanded((x) => !x)} + /> +
{/* 펼친 옵션: 너비 / 정렬 / 정렬 가능 */} diff --git a/notes/gbpark/2026-04-28-next-session-todos.md b/notes/gbpark/2026-04-28-next-session-todos.md index f794a9da..ffde655d 100644 --- a/notes/gbpark/2026-04-28-next-session-todos.md +++ b/notes/gbpark/2026-04-28-next-session-todos.md @@ -7,7 +7,7 @@ ## 1순위: 리팩토링 본체 (Codex 권장) -### 1.1 useDbTables() hook 추출 ★ 가장 안전 +### 1.1 useDbTables() hook 추출 ★ 가장 안전 — ✅ 완료 (2026-04-29) **현재 중복 위치**: - `lib/registry/components/search/InvSearchConfigPanel.tsx` @@ -49,7 +49,15 @@ export function useDbTables(opts?: { - `tableName/table_name`, `display_name/tableLabel/table_label` normalization 을 hook 안에 고정 - table 의 connected column loading / autoload 로직은 **hook 에 섞지 말 것** (별개) -### 1.2 TableConnectSection 추출 (1.1 다음) +**적용 결과 (2026-04-29)**: +- 신규 파일: `frontend/lib/registry/components/common/useDbTables.ts` +- 적용 패널: search / table / stats (3개) +- input 은 패턴 없어서 적용 안 함 +- normalize 는 hook 안 default + `opts.normalize` override 가능 (Codex 권장 그대로) +- stats 만 value 우선순위 살짝 달랐으나 (`table_name || tableName`) functional 동일이라 default 통일 +- TS 검증: 변경 4 파일 새 에러 0건 + +### 1.2 TableConnectSection 추출 (1.1 다음) — ✅ 완료 (2026-04-29) **현재 중복**: - search / table 의 "테이블 연결 + 자동 로드 버튼" 섹션 @@ -60,7 +68,18 @@ export function useDbTables(opts?: { - table: `[자동 로드]` = 컬럼 N개 자동 추출 - → autoload 콜백을 prop 으로 받음. label 도 prop. -### 1.3 DenseListRow / ExpandableRow 추출 (1.1, 1.2 다음) +**적용 결과 (2026-04-29)**: +- 신규 파일: `frontend/lib/registry/components/common/TableConnectSection.tsx` +- 두 컴포넌트 export: + 1. `TableConnectSection` — CPSection + CPRow "테이블" + CPSelect (children 슬롯) + 2. `AutoLoadButton` — 38줄 동일 inline 스타일 버튼 (label / onClick / iconSize prop) +- Codex 의 ★ 가이드 그대로: autoload 로직 (callback / 조건 / 컬럼 fetch) 호출처에 그대로 둠 +- search 호출처는 `tableColumns.length > 0 && ` + `!connectedTable && ` 직접 children 으로 주입 +- table 호출처는 `` (loading 상태) + `` 직접 children +- TS 검증: 변경 파일 새 에러 0건 +- 변경 stat: 4 files, +81/-199 (호출처 순감 118줄, 신규 모듈 ~186줄, 총 net +68 — 가독성/유지보수 큰 이득) + +### 1.3 DenseListRow / ExpandableRow 추출 (1.1, 1.2 다음) — ✅ helper-only 완료 (2026-04-29) **현재 중복**: - search 의 SearchFieldRow @@ -70,6 +89,20 @@ export function useDbTables(opts?: { → 패턴 안정화 후 추출. 너무 일찍 하면 over-abstraction. +**적용 결과 (2026-04-29) — Codex 가이드 따라 helper-only 좁게 추출**: + +- 신규 파일: `frontend/lib/registry/components/common/row-helpers.tsx` +- 3 helper export: + 1. `RowNumberBadge` (n) — search/table/stats 의 #번호 (4 패널 중 3) + 2. `RowExpandChevron` (expanded, onToggle, size?) — table/stats 의 ▸/▾ 펼침 버튼 (2 패널) + 3. `RowDeleteBtn` (onClick, visible?, size?, children?, title?) — 4 패널 모두 사용 +- ★ wrapper (DenseListRow / ExpandableRow) 는 **보류** — Codex 판단: row 별 expanded state 위치 / hover 표시 / row padding 회귀 위험 중간 이상 +- silent visual change 1건: input 의 옵션 ×버튼 hover bg 추가됨 (이전 X) — Codex Y2 권고로 `hoverBg?: boolean` 옵션 추가 (기본 true), input 만 `hoverBg={false}` 로 원복 +- Y1 ★ 경고 주석 반영: `RowDeleteBtn` 의 `visible` 기본값 true 위험 (호출처가 prop 생략 시 항상 노출) — 주석에 "dense list row 는 반드시 `visible={hover}` 명시" 명시 +- 적용 패널: search (RowNumberBadge + RowDeleteBtn) / table + stats (3 helper 다) / input (RowDeleteBtn 만) +- TS 검증: 새 에러 0건 +- 변경 stat 누적 (1.1+1.2+1.3): 5 files, +123 / -376 (호출처 -253 / 신규 3 모듈 +296 / net +43 — 가독성·유지보수 이득) + --- ## 2순위: 잔여 V2* cp 마이그 (좌측 팔레트 외) @@ -95,7 +128,7 @@ export function useDbTables(opts?: { --- -## 4순위: IconPicker 공용 cp 톤 마이그 (사용자 지적) +## 4순위: IconPicker 공용 cp 톤 마이그 (사용자 지적) — ✅ 완료 (2026-04-29) **문제**: button / stats 등 패널의 "아이콘 선택" 셀렉트가 다른 select 와 사이즈 다름 (Image #9 참고). IconPicker 컴포넌트 자체 디자인이 cp 톤 아님. @@ -103,6 +136,44 @@ export function useDbTables(opts?: { **한 번 마이그하면 button / stats / 기타 IconPicker 사용 패널 일관성 회복.** +**적용 결과 (2026-04-29) — Codex Y4 권고 따라**: + +- 위치: `frontend/lib/registry/components/common/IconPicker.tsx` +- 시그니처 / 동작 (외부 클릭 닫기 없음 등) **완전 동일**, 시각 톤만 마이그 +- 트리거 버튼: 28px height, 12px font, cp-surface bg, cp-border + focus glow (primary 0.5/0.12), 6px radius +- 팝오버: cp-surface bg, cp-border, 6px radius, shadow +- 검색 input: cp-bg-subtle, cp-border, 4px radius, 11px font +- 그리드 버튼: cp hover (cp-surface-hover), primary 활성 tint (rgba(primary-rgb, 0.10) bg + primary text) +- shadcn 클래스 (`border-border bg-background text-xs` 등) 0건 → cp 변수 + inline style 전환 + +**영향 범위 검증**: +- 사용처 grep 결과 — IconPicker 본체는 button/stats 2 cp 패널만 사용 +- 외부 7곳 (layout/admin/dash) 은 별개 `MenuIconPicker` 사용 → **영향 없음** + +**검증**: +- TS 새 에러 0건 +- diff stat: 1 file, +152 / -18 (cp inline style verbosity 로 size 증가, 시각 일관성 큰 이득) + +**E3 후속 — popover Portal 도입 (2026-04-29)**: +- Codex 검증 시 ★ 발견: stats 의 list wrapper `overflow: hidden` (cp 표준 디자인) 안에서 IconPicker popover (absolute) 가 잘릴 위험 +- search/table 도 동일 hidden 패턴 — cp 표준 깨면 일관성 깨짐 +- 해결: IconPicker 의 popover 만 `React.createPortal(document.body)` + `position: fixed` + 좌표 (트리거 `getBoundingClientRect()`) 로 변경 +- 부모 overflow 와 무관하게 항상 화면 위에 표시 +- scroll/resize 발생 시 popover 자동 닫음 (좌표 안 따라감 — 단순) +- z-index 9999 로 최상위 +- 다른 cp 패널 일관성 유지 (wrapper hidden 그대로) + +**IconPicker 미완 후속 사항 (다음에 손볼 때 한꺼번에)** ★: + +1. **viewport 하단 잘림 보정 (auto-flip)** — 트리거 아래 공간 부족 시 popover 를 트리거 위로 flip. 좌표 계산 시 `viewport.height - rect.bottom` 비교 후 결정. +2. **외부 클릭 닫기** — 현재 트리거 재클릭 / 그리드 선택 / 검색 후 선택 시만 닫힘. cp 표준 CPSelect 처럼 `mousedown` capture + Escape 키 처리 추가 (단 portal 안에서 처리 시 popover 자체 클릭은 닫지 않게 ref 비교). +3. **z-index 9999 표준화** — 향후 앱 모달/오버레이 (예: shadcn `Dialog`, `Sheet` 등) 와 충돌 가능. cp 표준 z-index 토큰 (`--cp-z-popover`) 정의 후 사용. +4. **viewport 우측 잘림 보정** — popover width 가 트리거 right 보다 클 때 좌측으로 shift. 현재 trigger.width 그대로 사용해 우측 잘림 가능성 낮지만, 동적 width 일 때 대비. +5. **키보드 네비** — ↑↓ 화살표 / Enter / Esc 등. 현재 마우스만 지원. +6. **카테고리 분류 / 더보기** — 현재 80개 cap. 검색 시에만 전체 매치. 카테고리 (액션/내비/IO 등) 추가 후보. + +위 1~6 은 즉시 깨지는 결함은 아님. 다음에 IconPicker 본체 손볼 때 (예: button/stats 외 다른 패널 추가 또는 신규 기능 추가) 함께 처리. + --- ## 안전 가이드 @@ -123,12 +194,44 @@ export function useDbTables(opts?: { - ✅ getComponentConfigPanel.tsx stats key 중복 버그 수정 - ✅ ALIAS 충돌 수정 (옛 hidden 컴포넌트 → InvLegacy) - ✅ input 통합 cp 톤 신규 (InvInputConfigPanel) +- ✅ **useDbTables hook 추출 (2026-04-29)** +- ✅ **TableConnectSection + AutoLoadButton 추출 (2026-04-29)** +- ✅ **Row helpers 추출 (RowNumberBadge / RowExpandChevron / RowDeleteBtn) — 2026-04-29** +- ✅ **IconPicker cp 톤 마이그 (2026-04-29)** — Codex Y4 권고 ### 미완 (위 1~4 순위) -- ❌ useDbTables hook 추출 -- ❌ TableConnectSection 추출 -- ❌ DenseListRow 추출 - ❌ V2DateConfigPanel / V2BomTree / V2BomItemEditor cp - ❌ InvDataConfigPanel 의 list/table 분기 cp (옛 화면 호환용) - ❌ V2* 33개 dead code 정리 -- ❌ IconPicker 공용 cp 톤 + +--- + +## Codex 검토 (2026-04-29) 핵심 반영 + +| 질문 | 판정 | 시급 액션 | +|---|---|---| +| Q1 todos 우선순위 | 수정필요 | useDbTables 만 먼저 (✅ 완료) + dead-code 스캔 기준 보강 | +| Q2 InvRepeater 신규 | 수정필요 | `import` type 보류, resolver/writer + 보존 키 목록 확정 | +| Q3 InvData 통합 | 수정필요 | taxonomy 하위 축 추가 + round-trip DoD | +| Q4 Inv* 네이밍 | OK | 옵션 B 유지, legacy 사용량 0 검증 전 삭제 금지 | + +### ★ silent breakage 위험 (Codex 경고) + +1. **TableConnectSection 추출 시** — search/table 의 자동 로드 의미 다름 (search: 검색 필드 8개 / table: 컬럼 N개). UI 만 공통화 + autoload 는 prop callback +2. **DenseListRow 추출 시** — row 별 key/펼침/onChange shape 다름. 너무 빨리 공통화하면 UI 는 떠도 저장 config 틀어질 수 있음 +3. **dead code 33개 식별** — `rg "V2XxxConfigPanel"` 만으로 부족 + - `getComponentConfigPanel.tsx` 의 `CONFIG_PANEL_MAP` + `CONFIG_PANEL_ALIAS` (alias 가 먼저 해석됨, 예: `"v2-table-list": "table"`) + - `V2PropertiesPanel.tsx:215` hardcoded require + - dynamic import map 확인 필수 +4. **InvRepeater config 보존 키** + - `source_detail_config` (table_name/foreign_key/parent_key 외 use_entity_join, column_mapping, additional_join_columns 도 보존) + - `column_mappings[]`, `calculation_rules[]`, `entity_joins[]` (modal 전용처럼 보여도 보존) + - `no_duplicate_pick` (현재 코드 L1335) 호환 체크 추가 +5. **V2DateConfigPanel** — V2FieldConfigPanel 의 `input.date.*` 와 분리 결정 필요 +6. **excludeFilter / linkedFilters[] / source_detail_config** — 통합 InvData 시 read/write round-trip 테스트 필수 (참조 테이블 변경 시 별도 컬럼 API 호출) + +### 추가 권장사항 + +- **InvData 통합 패널 분리**: 3760줄 단일 파일 비추천. orchestrator / resolveAxis-applyAxis / read sections / write sections / API hooks / column editors 로 경계 +- **InvRepeater resolver/writer 도입**: V2Field 의 resolveTriple/applyTriple 패턴 그대로 (InvRepeaterConfigPanel L124, L141 이미 같은 형태 사용 중) +- **InvRepeater type=import 보류**: 별도 reserved 또는 후속 컴포넌트 후보로