Files
invyone/frontend/lib/registry/components/search/InvSearchConfigPanel.tsx
T
DDD1542 a8ded6455d refactor: ConfigPanel Inv 네이밍 통합 + legacy 패널 분리 + input cp 마이그
11 패널 일괄 Inv* prefix 통일:
- 통합 (lib/registry/components/X/): button / container / divider / search /
  stats / table / title / input → Inv*ConfigPanel
- frontend/components/v2/config-panels/V2FieldConfigPanel → InvFieldConfigPanel
- 옛 v2-* hidden 호환 → InvLegacy{Divider,Text,Button}ConfigPanel

input 통합 컴포넌트 cp 톤 신규 작성 (InvInputConfigPanel):
- 277줄 옛 디자인 → CPVisualGrid 10칸 type 카드 + 타입별 옵션 + FeatureChipGrid

getComponentConfigPanel.tsx 버그 수정 (Codex 검토):
- "stats" key 중복 제거 (옛 StatsCardConfigPanel 이 통합 stats 덮던 silent bug)
- ALIAS 에서 v2-button-primary/v2-divider-line/v2-text-display 제외
  (옵션 B 일관성 — 옛 hidden 컴포넌트는 InvLegacy 패널 사용)
- MAP 의 해당 키를 InvLegacy* 로 직접 매핑

호출처 일괄 갱신:
- 각 통합 컴포넌트의 index.ts 7개 (import / config_panel / re-export)
- v2-input/v2-select/v2-divider-line/v2-text-display/v2-button-primary
  의 index.ts (config_panel 매핑)
- V2PropertiesPanel.tsx 의 require pattern (v2-input/v2-select)

검증: tsc 우리 영역 0건 / V2FieldConfigPanel 잔재 0건 / 기존 path 잔재 0건

다음 세션: useDbTables hook 추출 + 잔여 V2* cp 마이그 + dead code 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:57:57 +09:00

428 lines
14 KiB
TypeScript

"use client";
/**
* InvSearchConfigPanel — 통합 "검색" (id: search) cp 톤 설정 패널
*
* 흐름:
* ① 테이블 연결 — 테이블 선택 + 검색 필드 자동 로드 버튼
* ② 입력 — placeholder + 배치 (CPSegment) + 검색 버튼 텍스트
* ③ 검색 필드 (자동 로드된 list — 조건부)
* ▾ 동작 — 초기화 / 자동검색 / 날짜범위 (FeatureChipGrid)
*
* 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 {
CPSection,
CPRow,
CPGroup,
CPText,
CPSelect,
CPSegment,
CPIconBtn,
FeatureChipGrid,
Hint,
SectionLabel,
} from "@/components/v2/config-panels/_shared/cp";
import type { SearchConfig } from "./types";
export interface InvSearchConfigPanelProps {
config?: SearchConfig;
onChange?: (config: SearchConfig) => void;
selectedComponent?: { id: string; config?: SearchConfig; [k: string]: any };
tables?: any[];
tableColumns?: any[];
screenTableName?: string;
onTableChange?: (tableName: string) => void;
}
interface SearchField {
key: string;
label: string;
type: "text" | "select" | "date" | "number";
}
const FIELD_TYPE_COLOR: Record<SearchField["type"], string> = {
text: "#3b82f6",
number: "#10b981",
date: "#f59e0b",
select: "#8b5cf6",
};
const FIELD_TYPE_LABEL: Record<SearchField["type"], string> = {
text: "텍스트",
number: "숫자",
date: "날짜",
select: "선택",
};
export const InvSearchConfigPanel: React.FC<InvSearchConfigPanelProps> = ({
config,
onChange,
selectedComponent,
tables,
tableColumns,
screenTableName,
onTableChange,
}) => {
const current: SearchConfig =
(config as SearchConfig) || (selectedComponent?.config as SearchConfig) || {};
const patch = useCallback(
(p: Partial<SearchConfig>) => onChange?.({ ...current, ...p }),
[current, onChange],
);
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 autoLoadFields = useCallback(() => {
if (!tableColumns?.length) return;
const newFields: SearchField[] = tableColumns
.filter((col: any) => {
const name = (col.columnName || col.column_name || "").toLowerCase();
return !["objid", "created_at", "updated_at", "created_by", "updated_by"].includes(
name,
);
})
.slice(0, 8)
.map((col: any) => {
const colName = col.columnName || col.column_name;
const dataType = (col.dataType || col.data_type || "").toLowerCase();
let fieldType: SearchField["type"] = "text";
if (
dataType.includes("int") ||
dataType.includes("numeric") ||
dataType.includes("decimal")
)
fieldType = "number";
if (dataType.includes("date") || dataType.includes("time")) fieldType = "date";
if (col.codeCategory || col.code_category) fieldType = "select";
return {
key: colName,
label: col.columnLabel || col.column_label || col.displayName || colName,
type: fieldType,
};
});
patch({ fields: newFields } as any);
}, [tableColumns, patch]);
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>
{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>
)}
{!connectedTable && <Hint> .</Hint>}
</CPSection>
{/* ── ② 입력 ─────────────────────────── */}
<CPSection title="② 입력" desc="검색창 외형">
<CPRow label="placeholder">
<CPText
value={current.placeholder || ""}
onChange={(v) => patch({ placeholder: v })}
placeholder="검색어를 입력하세요"
/>
</CPRow>
<CPRow label="배치">
<CPSegment
value={current.layout || "inline"}
onChange={(v) => patch({ layout: v as SearchConfig["layout"] })}
options={[
{ value: "inline", label: "가로" },
{ value: "stacked", label: "세로" },
]}
/>
</CPRow>
<CPRow label="검색 버튼">
<CPText
value={current.searchButtonText || ""}
onChange={(v) => patch({ searchButtonText: v })}
placeholder="검색"
/>
</CPRow>
</CPSection>
{/* ── ③ 검색 필드 (자동 로드된 dense list) ─────────────────────────── */}
{fields.length > 0 && (
<CPSection title="③ 검색 필드" desc={`${fields.length}개 자동 로드됨`}>
<div
style={{
border: "1px solid var(--cp-border-subtle)",
borderRadius: 5,
overflow: "hidden",
background: "var(--cp-bg-subtle)",
}}
>
{fields.map((f, i) => (
<SearchFieldRow
key={`${f.key}-${i}`}
index={i}
field={f}
isLast={i === fields.length - 1}
onRemove={() => {
const next = fields.filter((_, idx) => idx !== i);
patch({ fields: next } as any);
}}
/>
))}
</div>
<Hint>
list. [ ] .
</Hint>
</CPSection>
)}
{/* ── ▾ 동작 ─────────────────────────── */}
<CPGroup title="동작" defaultOpen>
<FeatureChipGrid
items={[
{
key: "showResetButton",
label: "초기화",
default: true,
desc: "검색창 우측에 [초기화] 버튼이 표시됩니다.\n클릭 시 모든 검색 필드 값을 비우고 결과를 리셋합니다.",
},
{
key: "autoSearch",
label: "자동 검색",
desc: "사용자가 입력하는 즉시 (300ms 디바운스) 자동으로 검색이 실행됩니다.\n[검색] 버튼을 누를 필요 없어 빠르지만 백엔드 호출이 늘어요.",
},
{
key: "dateRangeEnabled",
label: "날짜 범위",
desc: "날짜 필드에 시작일/종료일 두 input 이 표시되어 범위 검색 가능.\nOFF 일 때는 단일 날짜만 검색합니다.",
},
]}
source={current as any}
onToggle={(k, v) => patch({ [k]: v } as Partial<SearchConfig>)}
/>
</CPGroup>
</div>
);
};
InvSearchConfigPanel.displayName = "InvSearchConfigPanel";
// ───────────────────────────────────────────────────────
// SearchFieldRow — Dense list 한 줄 (높이 ~24px, 카드 박스 폐기)
// 좌측 type dot · 라벨 · key (모노 회색) · type 작은 텍스트 · 호버 시 ×
// ───────────────────────────────────────────────────────
function SearchFieldRow({
index,
field,
isLast,
onRemove,
}: {
index: number;
field: SearchField;
isLast: boolean;
onRemove: () => void;
}) {
const [hover, setHover] = useState(false);
const typeColor = FIELD_TYPE_COLOR[field.type];
return (
<div
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: "grid",
gridTemplateColumns: "16px 8px minmax(0, auto) minmax(0, 1fr) auto 22px",
alignItems: "center",
columnGap: 6,
padding: "4px 8px",
minHeight: 24,
borderBottom: isLast ? "none" : "1px solid var(--cp-border-subtle)",
background: hover ? "var(--cp-surface-hover, var(--cp-surface))" : "transparent",
transition: "background .12s ease",
fontFamily: "var(--v5-font-sans)",
}}
>
{/* 번호 */}
<span
style={{
fontSize: 9,
color: "var(--cp-text-muted)",
fontFamily: "var(--v5-font-mono)",
textAlign: "right",
}}
>
{index + 1}
</span>
{/* type 색 dot */}
<span
aria-hidden
style={{
width: 6,
height: 6,
borderRadius: 999,
background: typeColor,
boxShadow: `0 0 4px ${typeColor}55`,
}}
/>
{/* 라벨 */}
<span
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--cp-text)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
letterSpacing: "-0.005em",
}}
>
{field.label}
</span>
{/* key (모노, 옅은) */}
<span
style={{
fontSize: 9.5,
color: "var(--cp-text-muted)",
fontFamily: "var(--v5-font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
opacity: 0.75,
}}
>
{field.key}
</span>
{/* type 작은 텍스트 */}
<span
style={{
fontSize: 9,
fontWeight: 600,
color: typeColor,
letterSpacing: "0.02em",
textTransform: "uppercase",
flexShrink: 0,
}}
>
{FIELD_TYPE_LABEL[field.type]}
</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")
}
>
<Trash2 size={10} />
</button>
</div>
);
}
export default InvSearchConfigPanel;