refactor: ConfigPanel hook/helper 추출 + IconPicker cp+Portal
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m32s
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m32s
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<IconPickerProps> = ({ value, onChange, className }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [focused, setFocused] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(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<IconPickerProps> = ({ 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 (
|
||||
<div className={`relative ${className || ""}`}>
|
||||
<div style={{ position: "relative" }} className={className}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="border-border bg-background flex w-full items-center gap-2 rounded border px-2 py-1 text-xs"
|
||||
onClick={handleToggle}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 28,
|
||||
padding: "0 8px",
|
||||
fontSize: 12,
|
||||
background: "var(--cp-surface)",
|
||||
border: `1px solid ${
|
||||
focused
|
||||
? "rgba(var(--v5-primary-rgb), 0.5)"
|
||||
: "var(--cp-border)"
|
||||
}`,
|
||||
borderRadius: 6,
|
||||
color: "var(--cp-text)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontFamily: "var(--v5-font-sans)",
|
||||
boxShadow: focused
|
||||
? "0 0 0 3px rgba(var(--v5-primary-rgb), 0.12)"
|
||||
: undefined,
|
||||
transition: "border-color .14s ease, box-shadow .14s ease",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{SelectedIcon ? (
|
||||
<>
|
||||
<SelectedIcon size={14} />
|
||||
<span className="flex-1 truncate text-left font-mono">{value}</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontFamily: "var(--v5-font-mono)",
|
||||
fontSize: 11,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground flex-1 text-left">아이콘 선택...</span>
|
||||
<span style={{ flex: 1, color: "var(--cp-text-muted)" }}>
|
||||
아이콘 선택...
|
||||
</span>
|
||||
)}
|
||||
<LucideIcons.ChevronDown size={12} className="text-muted-foreground shrink-0" />
|
||||
<LucideIcons.ChevronDown
|
||||
size={12}
|
||||
style={{ color: "var(--cp-text-muted)", flexShrink: 0 }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-border bg-card absolute top-full right-0 left-0 z-50 mt-1 rounded border shadow-lg">
|
||||
<div className="border-border border-b p-1.5">
|
||||
{open && popoverPos && createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: popoverPos.top,
|
||||
left: popoverPos.left,
|
||||
width: popoverPos.width,
|
||||
background: "var(--cp-surface)",
|
||||
border: "1px solid var(--cp-border)",
|
||||
borderRadius: 6,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.12)",
|
||||
zIndex: 9999,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* 검색 */}
|
||||
<div
|
||||
style={{
|
||||
padding: 6,
|
||||
borderBottom: "1px solid var(--cp-border-subtle)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid max-h-48 grid-cols-6 gap-0.5 overflow-y-auto p-1.5">
|
||||
|
||||
{/* 그리드 */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(6, 1fr)",
|
||||
gap: 2,
|
||||
padding: 6,
|
||||
maxHeight: 200,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{/* 선택 해제 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange(""); setOpen(false); }}
|
||||
className="hover:bg-accent text-muted-foreground flex h-7 items-center justify-center rounded text-[0.55rem]"
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
title="없음"
|
||||
style={{
|
||||
height: 28,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text-muted)",
|
||||
fontSize: 9,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
--
|
||||
</button>
|
||||
{filtered.map(([name, Icon]) => {
|
||||
const IconComp = Icon as React.FC<{ size?: number }>;
|
||||
const active = name === value;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => { onChange(name); setOpen(false); setSearch(""); }}
|
||||
className={`flex h-7 items-center justify-center rounded transition-colors ${
|
||||
name === value ? "bg-primary/10 text-primary" : "hover:bg-accent text-foreground"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onChange(name);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
title={name}
|
||||
style={{
|
||||
height: 28,
|
||||
background: active
|
||||
? "rgba(var(--v5-primary-rgb), 0.10)"
|
||||
: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: active ? "var(--v5-primary)" : "var(--cp-text)",
|
||||
borderRadius: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "background .12s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active)
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"var(--cp-surface-hover, rgba(0,0,0,0.04))";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active)
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"transparent";
|
||||
}}
|
||||
>
|
||||
<IconComp size={14} />
|
||||
</button>
|
||||
@@ -106,11 +267,19 @@ export const IconPicker: React.FC<IconPickerProps> = ({ value, onChange, classNa
|
||||
})}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-muted-foreground p-3 text-center text-[0.6rem]">
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
textAlign: "center",
|
||||
color: "var(--cp-text-muted)",
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
“{search}” 결과 없음
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<TableConnectSectionProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
title = "① 테이블 연결",
|
||||
desc,
|
||||
rowLabel = "테이블",
|
||||
placeholder = "선택...",
|
||||
children,
|
||||
}) => (
|
||||
<CPSection title={title} desc={desc}>
|
||||
<CPRow label={rowLabel}>
|
||||
<CPSelect value={value} onChange={onChange}>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
{children}
|
||||
</CPSection>
|
||||
);
|
||||
|
||||
export interface AutoLoadButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
export const AutoLoadButton: React.FC<AutoLoadButtonProps> = ({
|
||||
label,
|
||||
onClick,
|
||||
iconSize = 11,
|
||||
}) => (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
fontSize: 10.5,
|
||||
background: "rgba(var(--v5-primary-rgb), 0.08)",
|
||||
border: "1px solid rgba(var(--v5-primary-rgb), 0.32)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
color: "var(--v5-primary, #6c5ce7)",
|
||||
fontFamily: "var(--v5-font-sans)",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.005em",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
transition: "background .14s ease, border-color .14s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(var(--v5-primary-rgb), 0.14)";
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||
"rgba(var(--v5-primary-rgb), 0.5)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(var(--v5-primary-rgb), 0.08)";
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||
"rgba(var(--v5-primary-rgb), 0.32)";
|
||||
}}
|
||||
>
|
||||
<Wand2 size={iconSize} />
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -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<RowNumberBadgeProps> = ({ n }) => (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: "var(--cp-text-muted)",
|
||||
fontFamily: "var(--v5-font-mono)",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
);
|
||||
|
||||
export interface RowExpandChevronProps {
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const RowExpandChevron: React.FC<RowExpandChevronProps> = ({
|
||||
expanded,
|
||||
onToggle,
|
||||
size = 18,
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
title={expanded ? "접기" : "펼침"}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
padding: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{expanded ? "▾" : "▸"}
|
||||
</button>
|
||||
);
|
||||
|
||||
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<RowDeleteBtnProps> = ({
|
||||
onClick,
|
||||
visible = true,
|
||||
size = 22,
|
||||
children = "×",
|
||||
title = "제거",
|
||||
hoverBg = true,
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
padding: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: visible ? "var(--v5-red, #ef4444)" : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
...(hoverBg && {
|
||||
transition: "color .12s ease, background .12s ease",
|
||||
}),
|
||||
}}
|
||||
onMouseEnter={
|
||||
hoverBg
|
||||
? (e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(239, 68, 68, 0.10)";
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={
|
||||
hoverBg
|
||||
? (e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -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<any[]>([]);
|
||||
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<any[]>(
|
||||
() => (allDbTables.length > 0 ? allDbTables : fallback || []),
|
||||
[allDbTables, fallback],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => tables.map(normalize).filter((o) => o.value),
|
||||
[tables, normalize],
|
||||
);
|
||||
|
||||
return { tables, options, loading };
|
||||
}
|
||||
@@ -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<InvInputConfigPanelProps> = ({
|
||||
fontFamily: "var(--v5-font-sans)",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(i)}
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
padding: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--v5-red, #ef4444)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<RowDeleteBtn onClick={() => removeOption(i)} hoverBg={false}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</RowDeleteBtn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<InvSearchConfigPanelProps> = ({
|
||||
const fields: SearchField[] = (current as any).fields ?? [];
|
||||
const connectedTable = (current as any).selectedTable || screenTableName;
|
||||
|
||||
// 전체 DB 테이블 목록 로드
|
||||
const [allDbTables, setAllDbTables] = useState<any[]>([]);
|
||||
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<InvSearchConfigPanelProps> = ({
|
||||
return (
|
||||
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
|
||||
{/* ── ① 테이블 연결 ─────────────────────────── */}
|
||||
<CPSection title="① 테이블 연결" desc="검색 대상 테이블">
|
||||
<CPRow label="테이블">
|
||||
<CPSelect
|
||||
value={connectedTable || ""}
|
||||
onChange={(v) => {
|
||||
onTableChange?.(v);
|
||||
patch({ selectedTable: v } as any);
|
||||
}}
|
||||
>
|
||||
<option value="">선택...</option>
|
||||
{tableOptions.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
<TableConnectSection
|
||||
value={connectedTable || ""}
|
||||
onChange={(v) => {
|
||||
onTableChange?.(v);
|
||||
patch({ selectedTable: v } as any);
|
||||
}}
|
||||
options={tableOptions}
|
||||
desc="검색 대상 테이블"
|
||||
>
|
||||
{connectedTable && tableColumns && tableColumns.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={autoLoadFields}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
fontSize: 10.5,
|
||||
background: "rgba(var(--v5-primary-rgb), 0.08)",
|
||||
border: "1px solid rgba(var(--v5-primary-rgb), 0.32)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
color: "var(--v5-primary, #6c5ce7)",
|
||||
fontFamily: "var(--v5-font-sans)",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.005em",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
transition: "background .14s ease, border-color .14s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(var(--v5-primary-rgb), 0.14)";
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||
"rgba(var(--v5-primary-rgb), 0.5)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(var(--v5-primary-rgb), 0.08)";
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||
"rgba(var(--v5-primary-rgb), 0.32)";
|
||||
}}
|
||||
>
|
||||
<Wand2 size={11} />
|
||||
검색 필드 자동 로드 (최대 8개)
|
||||
</button>
|
||||
</div>
|
||||
<AutoLoadButton
|
||||
label="검색 필드 자동 로드 (최대 8개)"
|
||||
onClick={autoLoadFields}
|
||||
/>
|
||||
)}
|
||||
{!connectedTable && <Hint>테이블 선택 시 자동 로드 버튼이 활성화됩니다.</Hint>}
|
||||
</CPSection>
|
||||
</TableConnectSection>
|
||||
|
||||
{/* ── ② 입력 ─────────────────────────── */}
|
||||
<CPSection title="② 입력" desc="검색창 외형">
|
||||
@@ -324,16 +257,7 @@ function SearchFieldRow({
|
||||
}}
|
||||
>
|
||||
{/* 번호 */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: "var(--cp-text-muted)",
|
||||
fontFamily: "var(--v5-font-mono)",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<RowNumberBadge n={index + 1} />
|
||||
|
||||
{/* type 색 dot */}
|
||||
<span
|
||||
@@ -392,34 +316,9 @@ function SearchFieldRow({
|
||||
</span>
|
||||
|
||||
{/* 호버 시 × (자리는 항상 차지 — layout shift 방지) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="제거"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
padding: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: hover ? "var(--v5-red, #ef4444)" : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
transition: "color .12s ease, background .12s ease",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
((e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(239, 68, 68, 0.10)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
((e.currentTarget as HTMLButtonElement).style.background = "transparent")
|
||||
}
|
||||
>
|
||||
<RowDeleteBtn onClick={onRemove} visible={hover} size={20}>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</RowDeleteBtn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<InvStatsConfigPanelProps> = ({
|
||||
patch({ items: items.filter((_, i) => i !== idx) });
|
||||
};
|
||||
|
||||
// 데이터 소스 테이블 목록 로드
|
||||
const [allTables, setAllTables] = useState<any[]>([]);
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: "var(--cp-text-muted)",
|
||||
fontFamily: "var(--v5-font-mono)",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<RowNumberBadge n={index + 1} />
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
@@ -478,54 +449,11 @@ function ItemEditRow({
|
||||
placeholder="값"
|
||||
style={inputStyle({ mono: true })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((x) => !x)}
|
||||
title={expanded ? "접기" : "펼침"}
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
padding: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{expanded ? "▾" : "▸"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="제거"
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
padding: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: hover ? "var(--v5-red, #ef4444)" : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
transition: "color .12s ease, background .12s ease",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
((e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(239, 68, 68, 0.10)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
((e.currentTarget as HTMLButtonElement).style.background = "transparent")
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<RowExpandChevron
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded((x) => !x)}
|
||||
/>
|
||||
<RowDeleteBtn onClick={onRemove} visible={hover} />
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
|
||||
@@ -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<InvTableConfigPanelProps> = ({
|
||||
const columns: TableColumn[] = current.columns ?? [];
|
||||
const connectedTable = (current as any).selectedTable || screenTableName;
|
||||
|
||||
// ── 전체 DB 테이블 직접 로드 ──
|
||||
const [allDbTables, setAllDbTables] = useState<any[]>([]);
|
||||
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<any[]>([]);
|
||||
@@ -193,24 +173,15 @@ export const InvTableConfigPanel: React.FC<InvTableConfigPanelProps> = ({
|
||||
return (
|
||||
<div style={{ fontFamily: "var(--v5-font-sans)", color: "var(--cp-text)", padding: "0 12px" }}>
|
||||
{/* ── ① 테이블 연결 ─────────────────────────── */}
|
||||
<CPSection title="① 테이블 연결" desc="DB 테이블 매핑">
|
||||
<CPRow label="테이블">
|
||||
<CPSelect
|
||||
value={connectedTable || ""}
|
||||
onChange={(v) => {
|
||||
onTableChange?.(v);
|
||||
patch({ selectedTable: v, columns: [] } as any);
|
||||
}}
|
||||
>
|
||||
<option value="">선택...</option>
|
||||
{tableOptions.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</CPSelect>
|
||||
</CPRow>
|
||||
|
||||
<TableConnectSection
|
||||
value={connectedTable || ""}
|
||||
onChange={(v) => {
|
||||
onTableChange?.(v);
|
||||
patch({ selectedTable: v, columns: [] } as any);
|
||||
}}
|
||||
options={tableOptions}
|
||||
desc="DB 테이블 매핑"
|
||||
>
|
||||
{connectedTable && (
|
||||
<Hint>
|
||||
{loadingConnectedColumns
|
||||
@@ -222,47 +193,12 @@ export const InvTableConfigPanel: React.FC<InvTableConfigPanelProps> = ({
|
||||
)}
|
||||
|
||||
{connectedTable && effectiveTableColumns.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={autoLoadColumns}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
fontSize: 10.5,
|
||||
background: "rgba(var(--v5-primary-rgb), 0.08)",
|
||||
border: "1px solid rgba(var(--v5-primary-rgb), 0.32)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
color: "var(--v5-primary, #6c5ce7)",
|
||||
fontFamily: "var(--v5-font-sans)",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.005em",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
transition: "background .14s ease, border-color .14s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(var(--v5-primary-rgb), 0.14)";
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||
"rgba(var(--v5-primary-rgb), 0.5)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(var(--v5-primary-rgb), 0.08)";
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||
"rgba(var(--v5-primary-rgb), 0.32)";
|
||||
}}
|
||||
>
|
||||
<Wand2 size={11} />
|
||||
DB 컬럼에서 자동 로드 ({effectiveTableColumns.length}개)
|
||||
</button>
|
||||
</div>
|
||||
<AutoLoadButton
|
||||
label={`DB 컬럼에서 자동 로드 (${effectiveTableColumns.length}개)`}
|
||||
onClick={autoLoadColumns}
|
||||
/>
|
||||
)}
|
||||
</CPSection>
|
||||
</TableConnectSection>
|
||||
|
||||
{/* ── ② 표시 모드 ─────────────────────────── */}
|
||||
<CPSection title="② 표시 모드" desc="테이블 형태 변형">
|
||||
@@ -513,16 +449,7 @@ function ColumnEditRow({
|
||||
minHeight: 28,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: "var(--cp-text-muted)",
|
||||
fontFamily: "var(--v5-font-mono)",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<RowNumberBadge n={index + 1} />
|
||||
<input
|
||||
type="text"
|
||||
value={col.label}
|
||||
@@ -537,54 +464,11 @@ function ColumnEditRow({
|
||||
placeholder="컬럼 key"
|
||||
style={inputStyle({ mono: true })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((x) => !x)}
|
||||
title={expanded ? "접기" : "펼침"}
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
padding: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--cp-text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{expanded ? "▾" : "▸"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="제거"
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
padding: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: hover ? "var(--v5-red, #ef4444)" : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
transition: "color .12s ease, background .12s ease",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
((e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(239, 68, 68, 0.10)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
((e.currentTarget as HTMLButtonElement).style.background = "transparent")
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<RowExpandChevron
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded((x) => !x)}
|
||||
/>
|
||||
<RowDeleteBtn onClick={onRemove} visible={hover} />
|
||||
</div>
|
||||
|
||||
{/* 펼친 옵션: 너비 / 정렬 / 정렬 가능 */}
|
||||
|
||||
@@ -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 && <AutoLoadButton ... />` + `!connectedTable && <Hint>` 직접 children 으로 주입
|
||||
- table 호출처는 `<Hint>` (loading 상태) + `<AutoLoadButton ... />` 직접 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 또는 후속 컴포넌트 후보로
|
||||
|
||||
Reference in New Issue
Block a user