2348800e68
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
카테고리/캐스케이딩 시스템 (B/C/D) 전부 폐기:
- BE: mapper/Service/Controller 9세트 삭제 (cascading*, categoryTree, tableCategoryValue, categoryValueCascading, codeMerge)
- FE: 페이지 3 + API 8 + hooks 2 + 폐기 컴포넌트 6 삭제, 14곳 의존성 정리
- DB: 12 테이블 DROP, TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename
신설 commonCode 마스터-디테일:
- code_info: 1레벨 그룹 마스터
- code_detail: 2~∞ depth 재귀 트리 (parent_detail_id self-FK, depth 자동 계산)
- API: /api/common-codes/{info,detail}
- CodeCategoryFormModal/Panel → CodeInfoFormModal/Panel rename
- code_category 컬럼명 전부 code_info 로 치환 (mapper/Java/FE)
- 옛 commonCode API URL (/categories/...) → getCodeOptions 어댑터 + /detail?code_info=... 전환
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
327 lines
10 KiB
TypeScript
327 lines
10 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 } from "react";
|
|
import { Trash2 } from "lucide-react";
|
|
import {
|
|
CPSection,
|
|
CPRow,
|
|
CPGroup,
|
|
CPText,
|
|
CPSelect,
|
|
CPSegment,
|
|
FeatureChipGrid,
|
|
Hint,
|
|
} 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 {
|
|
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;
|
|
|
|
const { options: tableOptions } = useDbTables({ fallback: 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.codeInfo || col.code_info) 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" }}>
|
|
{/* ── ① 테이블 연결 ─────────────────────────── */}
|
|
<TableConnectSection
|
|
value={connectedTable || ""}
|
|
onChange={(v) => {
|
|
onTableChange?.(v);
|
|
patch({ selectedTable: v } as any);
|
|
}}
|
|
options={tableOptions}
|
|
desc="검색 대상 테이블"
|
|
>
|
|
{connectedTable && tableColumns && tableColumns.length > 0 && (
|
|
<AutoLoadButton
|
|
label="검색 필드 자동 로드 (최대 8개)"
|
|
onClick={autoLoadFields}
|
|
/>
|
|
)}
|
|
{!connectedTable && <Hint>테이블 선택 시 자동 로드 버튼이 활성화됩니다.</Hint>}
|
|
</TableConnectSection>
|
|
|
|
{/* ── ② 입력 ─────────────────────────── */}
|
|
<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)",
|
|
}}
|
|
>
|
|
{/* 번호 */}
|
|
<RowNumberBadge n={index + 1} />
|
|
|
|
{/* 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 방지) */}
|
|
<RowDeleteBtn onClick={onRemove} visible={hover} size={20}>
|
|
<Trash2 size={10} />
|
|
</RowDeleteBtn>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default InvSearchConfigPanel;
|