Files
invyone/frontend/components/v2/config-panels/InvFieldConfigPanel.tsx
T
DDD1542 3883031c0b feat(studio): Phase G — KPI stats / chart / cardList / groupedTable + canonical container tabs
INV Studio 데이터 뷰 시리즈. 솔루션 개발 단계라 backward-compat alias 없이 깔끔하게.

Backend:
- TableManagementController + Service: /aggregate, /aggregate-group, /select-rows endpoint 추가
  sanitize + hasColumn 검증 + buildAggregateWhere 공유 헬퍼

Frontend canonical view components (신규):
- stats: DB-first KPI editor (CPSegment 메타 chip, 컬럼 dropdown, 디자인 모드 debounce 350ms preview)
- chart: recharts (bar / horizontalBar / line / donut)
- card-list: title/subtitles/metrics 카드 카탈로그 (list / grid 레이아웃)
- grouped-table: 클라이언트 측 groupBy + 그룹 헤더 row

Canonical container (Phase G.2 / G.2.5 / G.2.6):
- containerType='tabs' 활성 탭만 mount, ChildSlot 으로 자식 렌더
- ScreenDesigner.handleComponentDrop 가 canonical container tabs 도 인식
- 우측 V2PropertiesPanel 4-way 분기: tab child / panel child / selected / empty
  nested path update + saveToHistory, delete handler 동기화

Shared utilities:
- useDbColumns hook (모듈 캐시), ColumnPicker (CPSelect 기반)
- OptionFilterRow 자연어 카드 형식 (컬럼 dropdown / 조건 select / 값 입력)
- _shared/use-table-rows.ts (cardList + groupedTable 공용 fetch)
- IconPicker: 한글 키워드 80+ alias, 휠 스크롤 fix, 360px 상한, 결과 80→300

stats DB-first UX (Phase G.4.x):
- DB / 정적 모드 이분법 제거 — 항상 dataSource 시작
- collapsed: 라벨 input + KpiMetaSegment chip (테이블 · 집계 · 컬럼 · 필터수)
- expanded: 데이터 / 필터 / 외형 / 고급 flat CP rows
- useSlideToggle hook 으로 펼침/닫힘 양방향 애니메이션
- 변화량 (delta) 수동 입력 UI 제거 — 향후 DB 자동 계산 영역
- 카드 fetch state 명시: loading / error / 대기 중 / 테이블 미설정

기타:
- ScreenDesigner.tsx → InvyoneStudio.tsx rename (활성 빌더 파일)
- 모든 hardcoded #6c5ce7 fallback 제거, hsl(var(--primary)) 토큰만 사용 (light/dark/테마 자동 적응)
- StatsDefinition default_config 도 DB-first placeholder (value: 0 박지 않음)

Docs:
- notes/gbpark/2026-05-14-studio-data-view-roadmap.md (G.0 ~ G.4.2 진행 기록)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:41:50 +09:00

2318 lines
86 KiB
TypeScript

"use client";
/**
* V2 통합 필드 설정 패널 (INVYONE Studio 컨셉 적용)
*
* 분류 체계 (시안 panel-input-new 컨셉)
* kind = 입력 / 선택·태그 (type 의 group 으로 자동 결정)
* type
* 입력 : 글자(text) · 숫자(number) · 여러 줄(textarea) · 채번(numbering)
* 선택·태그 : 단일 선택(single) · 다중값(multi: 목록/코드/엔티티/태그)
* format = text 안에서만 자유/이메일/전화/URL/사업자번호/통화
*
* 화면 구조
* CPCrumb (입력|선택 ▸ type ▾)
* 데이터 소속 (primary/reference/child) — 본 프로젝트 핵심 개념
* 유형별 설정 (CPSection + 형식별 옵션)
* 필터 (entity/category)
* 고급 설정 (CPGroup)
*
* 데이터 모델은 기존 config 구조 100% 호환:
* fieldType, source, dataRole, sourceTable, sourceColumn,
* placeholder, format, mask, min/max/step, rows, options, defaultValue,
* entityTable, entityValueColumn, entityLabelColumn, categoryTable, categoryColumn,
* autoGeneration, mode, multiple, maxSelect, searchable, allowClear, filters
*/
import "./_shared/cp/cp.css";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import { AutoGenerationType } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
import { NumberingRuleCreateDialog } from "@/components/numbering-rule/NumberingRuleCreateDialog";
import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader";
import {
CP_ICONS,
CPSection,
CPRow,
CPText,
CPSelect,
CPSwitch,
CPNumber,
CPSegment,
CPColor,
CPIconBtn,
CPCrumb,
CPFormatTrigger,
CPGroup,
} from "./_shared/cp";
import type { CPCrumbType, CPFormatItem } from "./_shared/cp";
// ── 분류 체계 (HTML panel-input-new.jsx V4 와 1:1 매칭) ───────
type Kind = "input" | "choice" | "auto" | "attach";
type Type =
| "text" | "number" | "money" | "date" // 입력
| "single" | "multi" // 선택/태그
| "autonum" | "formula" | "audit" // 자동
| "file"; // 첨부
const KINDS: { id: Kind; name: string; icon: React.ReactNode }[] = [
{ id: "input", name: "입력", icon: <span style={{ fontSize: 11 }}></span> },
{ id: "choice", name: "선택·태그", icon: <span style={{ fontSize: 11 }}></span> },
{ id: "auto", name: "자동", icon: <span style={{ fontSize: 11 }}></span> },
{ id: "attach", name: "첨부", icon: <span style={{ fontSize: 11 }}>📎</span> },
];
const TYPES_BY_KIND: Record<Kind, (CPCrumbType & { id: Type })[]> = {
input: [
{ id: "text", name: "글자", desc: "모든 텍스트", icon: "Aa", col: "VARCHAR" },
{ id: "number", name: "숫자", desc: "정수·소수", icon: "#", col: "NUMBER" },
{ id: "money", name: "금액", desc: "통화·환율", icon: "₩", col: "NUMBER" },
{ id: "date", name: "날짜", desc: "날짜·시간", icon: "📅", col: "TIMESTAMP" },
],
choice: [
{ id: "single", name: "단일 선택", desc: "목록에서 하나", icon: "◉", col: "VARCHAR" },
{ id: "multi", name: "다중값", desc: "태그·여러 항목", icon: "☷", col: "JSON" },
],
auto: [
{ id: "autonum", name: "채번", desc: "자동 일련번호", icon: "№", col: "VARCHAR" },
{ id: "formula", name: "계산", desc: "수식·다른 필드", icon: "ƒx", col: "VARIES" },
{ id: "audit", name: "감사", desc: "작성자·작성일", icon: "◉", col: "TIMESTAMP" },
],
attach: [
{ id: "file", name: "파일", desc: "업로드", icon: "📎", col: "VARCHAR" },
],
};
const FORMATS_BY_TYPE: Record<Type, CPFormatItem[]> = {
text: [
{ id: "free", name: "자유", desc: "제한 없음 · 줄 수 조절", icon: "Aa" },
{ id: "email", name: "이메일", desc: "@ 검증", icon: "@" },
{ id: "phone", name: "전화번호", desc: "국가·마스킹", icon: "☎" },
{ id: "rrn", name: "주민·사업자", desc: "체크섬·암호화", icon: "ID" },
{ id: "address", name: "주소", desc: "우편번호·지도", icon: "📍" },
{ id: "card", name: "카드번호", desc: "PCI 마스킹", icon: "💳" },
{ id: "url", name: "URL", desc: "링크", icon: "🔗" },
{ id: "color", name: "색상", desc: "#RRGGBB", icon: "🎨" },
{ id: "mask", name: "커스텀 마스킹", desc: "###-####", icon: "⌨" },
],
number: [
{ id: "int", name: "정수", desc: "0, 1, 2…", icon: "#" },
{ id: "decimal", name: "소수", desc: "1.23", icon: "0.0" },
{ id: "percent", name: "퍼센트", desc: "0~100%", icon: "%" },
{ id: "slider", name: "슬라이더", desc: "min~max", icon: "⇄" },
],
money: [
{ id: "krw", name: "원", desc: "KRW", icon: "₩" },
{ id: "foreign", name: "외화", desc: "USD/EUR…", icon: "$" },
],
date: [
{ id: "date", name: "날짜", desc: "2025-01-31", icon: "📅" },
{ id: "datetime", name: "날짜+시간", desc: "2025-01-31 14:30", icon: "🕒" },
{ id: "time", name: "시간", desc: "14:30", icon: "⏱" },
{ id: "range", name: "기간", desc: "시작 ~ 끝", icon: "↔" },
],
single: [
{ id: "list", name: "고정 목록", desc: "여기서 정의", icon: "LS" },
{ id: "code", name: "공통코드", desc: "코드 테이블", icon: "CD" },
{ id: "entity", name: "엔티티", desc: "다른 테이블 (FK)", icon: "FK" },
{ id: "status", name: "상태", desc: "색·라벨", icon: "●" },
{ id: "boolean", name: "예/아니오", desc: "둘 중 하나", icon: "◐" },
],
multi: [
{ id: "tags", name: "태그 입력", desc: "직접 입력", icon: "TG" },
{ id: "list", name: "고정 목록", desc: "여러 항목 선택", icon: "CB" },
{ id: "code", name: "공통코드", desc: "여러 코드 선택", icon: "CD" },
{ id: "entity", name: "엔티티", desc: "여러 참조 선택", icon: "FK" },
],
autonum: [{ id: "autonum", name: "채번", desc: "ORD-2025-####", icon: "№" }],
formula: [{ id: "formula", name: "계산", desc: "qty * price", icon: "ƒx" }],
audit: [{ id: "audit", name: "감사", desc: "created_at", icon: "◉" }],
file: [
{ id: "image", name: "이미지", desc: "png, jpg…", icon: "🖼" },
{ id: "doc", name: "문서", desc: "pdf, docx…", icon: "📄" },
{ id: "any", name: "모든파일", desc: "제한 없음", icon: "📎" },
],
};
const KIND_OF_TYPE: Record<Type, Kind> = {
text: "input", number: "input", money: "input", date: "input",
single: "choice", multi: "choice",
autonum: "auto", formula: "auto", audit: "auto",
file: "attach",
};
const defaultFormatFor = (type: Type): string => FORMATS_BY_TYPE[type][0].id;
const OPERATOR_OPTIONS = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "다름 (!=)" },
{ value: ">", label: "초과 (>)" },
{ value: "<", label: "미만 (<)" },
{ value: ">=", label: "이상 (>=)" },
{ value: "<=", label: "이하 (<=)" },
{ value: "in", label: "포함 (IN)" },
{ value: "notIn", label: "미포함 (NOT IN)" },
{ value: "like", label: "유사 (LIKE)" },
{ value: "isNull", label: "NULL" },
{ value: "isNotNull", label: "NOT NULL" },
] as const;
const VALUE_TYPE_OPTIONS = [
{ value: "static", label: "고정값" },
{ value: "field", label: "폼 필드 참조" },
{ value: "user", label: "로그인 사용자" },
] as const;
const USER_FIELD_OPTIONS = [
{ value: "companyCode", label: "회사코드" },
{ value: "userId", label: "사용자ID" },
{ value: "deptCode", label: "부서코드" },
{ value: "userName", label: "사용자명" },
] as const;
interface ColumnOption {
columnName: string;
columnLabel: string;
}
type TableSelectOption = {
tableName?: string;
table_name?: string;
displayName?: string;
display_name?: string;
tableComment?: string;
table_comment?: string;
tableLabel?: string;
table_label?: string;
description?: string;
};
function normalizeTableSelectOptions(tables: TableSelectOption[] = []) {
return tables
.map((table) => {
const tableName = table.tableName || table.table_name;
if (!tableName) return null;
const label =
table.displayName ||
table.display_name ||
table.tableComment ||
table.table_comment ||
table.tableLabel ||
table.table_label ||
table.description;
return {
tableName,
label: label ? `${label} (${tableName})` : tableName,
};
})
.filter((table): table is { tableName: string; label: string } => table !== null);
}
interface CategoryValueOption {
valueCode: string;
valueLabel: string;
}
// ── 하위 호환: 기존 config → (kind, type, format) 추론 ──────────
function resolveTriple(
config: Record<string, any>,
componentType?: string,
metaInputType?: string,
): { kind: Kind; type: Type; format: string } {
// 0. config 에 새 형식이 명시돼 있으면 그대로
if (config.kind && config.type) {
return {
kind: config.kind as Kind,
type: config.type as Type,
format: config.format || defaultFormatFor(config.type as Type),
};
}
// 1. autoGeneration 으로 자동 kind 판정
const ag = config.autoGeneration;
if (config.fieldType === "numbering" || ag?.type === "numbering_rule") {
return { kind: "auto", type: "autonum", format: "autonum" };
}
if (ag?.enabled && (ag?.type === "current_time" || ag?.type === "current_user")) {
return { kind: "auto", type: "audit", format: "audit" };
}
if (ag?.enabled && ag?.type === "formula") {
return { kind: "auto", type: "formula", format: "formula" };
}
// 2. 컴포넌트 ID 가 명시적으로 별도면 그쪽으로
if (config.fieldType === "file") {
const a = config.accept || "";
if (a.startsWith("image/")) return { kind: "attach", type: "file", format: "image" };
if (a.includes("pdf") || a.includes("docx") || a.includes(".doc"))
return { kind: "attach", type: "file", format: "doc" };
return { kind: "attach", type: "file", format: "any" };
}
// 2.5 옛 입력 6종 (text-input/number-input/date-input/select-basic/checkbox-basic/
// textarea-basic) 은 Phase E 에서 canonical input 으로 흡수 — 레거시 매핑 제거.
// 3. 선택 (source / multiple)
const isMulti = !!config.multiple;
const src = config.source || (config.fieldType === "select" ? "static" : config.fieldType);
if (src === "static" || src === "select") {
return { kind: "choice", type: isMulti ? "multi" : "single", format: isMulti ? "list" : "list" };
}
if (src === "category" || src === "code") {
return { kind: "choice", type: isMulti ? "multi" : "single", format: "code" };
}
if (src === "entity") {
return { kind: "choice", type: isMulti ? "multi" : "single", format: "entity" };
}
if (config.fieldType === "checkbox" || (config.fieldType as any) === "boolean") {
return { kind: "choice", type: "single", format: "boolean" };
}
if (config.fieldType === "tags") {
return { kind: "choice", type: "multi", format: "tags" };
}
if (config.fieldType === "status") {
return { kind: "choice", type: "single", format: "status" };
}
// 4. 입력 - money / number / date / text
const it = (config.inputType || config.fieldType || metaInputType || "").toString();
if (it === "currency" || config.format === "currency") {
return { kind: "input", type: "money", format: "krw" };
}
if (it === "decimal") return { kind: "input", type: "number", format: "decimal" };
if (it === "percentage" || it === "percent") return { kind: "input", type: "number", format: "percent" };
if (it === "number") return { kind: "input", type: "number", format: "int" };
if (it === "date") return { kind: "input", type: "date", format: "date" };
if (it === "datetime") return { kind: "input", type: "date", format: "datetime" };
if (it === "time") return { kind: "input", type: "date", format: "time" };
// 5. text (textarea 는 text + free + rows>1 로 흡수)
if (it === "textarea") return { kind: "input", type: "text", format: "free" };
// 6. text formats (config.format 우선)
const fmt = config.format;
if (fmt) {
const validTextFormats = FORMATS_BY_TYPE.text.map((f) => f.id);
// 기존 호환: tel → phone, biz_no → rrn, none → free
const remap: Record<string, string> = { tel: "phone", biz_no: "rrn", none: "free", currency: "free" };
const mapped = remap[fmt] || fmt;
if (validTextFormats.includes(mapped)) {
return { kind: "input", type: "text", format: mapped };
}
}
// 7. webType 기반
if (it === "email") return { kind: "input", type: "text", format: "email" };
if (it === "tel") return { kind: "input", type: "text", format: "phone" };
if (it === "url") return { kind: "input", type: "text", format: "url" };
if (it === "password") return { kind: "input", type: "text", format: "free" };
// default
return { kind: "input", type: "text", format: "free" };
}
// 모든 type 분기 사이 공유될 수 있는 잔재 필드.
// 분기 진입 시 일괄 reset 후 자기 분기에 필요한 필드만 set → 분기 간 잔재 0.
const TYPE_VOLATILE_FIELDS = [
"fieldType", "inputType", "source", "multiple",
"step", "min", "max", "rows", "tags", "accept",
"unit", "thousands", "mode", "mask",
"boolStyle", "trueLabel", "falseLabel",
"autoGeneration", "computed", "readonly",
// type 별 옵션 (select options / text length / date 옵션)
"options", "minLength", "maxLength",
"dateFormat", "minDate", "maxDate", "showToday", "maxRangeDays",
// multi 의 maxSelect (mode 는 이미 위 라인에 포함)
"maxSelect",
// defaultValue — type 마다 의미 다르므로 잔재 방지 (예: text "저는 김민호" → entity 로 변경 시 부적합)
"defaultValue",
] as const;
function clearVolatileFields(next: Record<string, any>) {
// spread merge ({...currentConfig, ...newConfig}) 에서 키 제거 (delete) 만 하면 currentConfig 의
// 같은 키가 살아남음. = undefined 로 명시해야 spread 가 덮어씀 → 잔재 완전 제거
for (const f of TYPE_VOLATILE_FIELDS) next[f] = undefined;
}
// 새 (kind, type, format) 으로 config 재작성. 사용자 입력 필드 (label/placeholder/helperText/
// required/editable/disabled 등) 는 보존, defaultValue 와 type 별 잔재 필드는 일괄 reset.
function applyTriple(
prev: Record<string, any>,
kind: Kind,
type: Type,
format: string,
ctx: { numberingTableName?: string },
): Record<string, any> {
const next: Record<string, any> = { ...prev, kind, type, format };
clearVolatileFields(next); // ★ 분기 진입 전 잔재 일괄 정리
if (kind === "input") {
if (type === "text") {
next.fieldType = "text";
next.inputType = format === "free" ? "text" : format;
// free 는 줄 수 옵션, 나머지는 단일 라인 (clearVolatileFields 로 이미 reset 됨)
if (format === "email") next.inputType = "email";
if (format === "phone") next.inputType = "tel";
if (format === "url") next.inputType = "url";
return next;
}
if (type === "number") {
next.fieldType = "number";
next.step = format === "decimal" ? 0.01 : 1;
next.unit = format === "percent" ? "%" : undefined;
next.inputType = format === "decimal" ? "decimal" : format === "percent" ? "percentage" : "number";
return next;
}
if (type === "money") {
next.fieldType = "number";
next.thousands = true;
next.unit = format === "krw" ? "원" : (prev.currency || "USD");
next.inputType = "currency";
return next;
}
if (type === "date") {
next.fieldType = "date";
next.inputType = format === "range" ? "daterange" : format;
return next;
}
}
if (kind === "choice") {
const isMulti = type === "multi";
next.multiple = isMulti;
if (format === "list" || format === "tags") {
next.fieldType = "select";
next.source = "static";
if (format === "list") next.mode = isMulti ? "check" : "dropdown";
if (format === "tags") {
next.tags = true;
next.mode = "tag";
}
} else if (format === "code") {
next.fieldType = "category";
next.source = "category";
} else if (format === "entity") {
next.fieldType = "entity";
next.source = "entity";
} else if (format === "status") {
next.fieldType = "status";
next.source = "category";
} else if (format === "boolean") {
next.fieldType = "checkbox";
next.multiple = false;
next.mode = "toggle";
next.boolStyle = "switch";
}
return next;
}
if (kind === "auto") {
if (type === "autonum") {
next.fieldType = "numbering";
next.inputType = "numbering";
next.autoGeneration = {
...prev.autoGeneration,
enabled: true,
type: "numbering_rule",
tableName: ctx.numberingTableName,
};
next.readonly = prev.readonly ?? true;
return next;
}
if (type === "formula") {
next.fieldType = "text";
next.computed = prev.computed || "";
next.autoGeneration = { enabled: true, type: "formula" };
next.readonly = true;
return next;
}
if (type === "audit") {
next.fieldType = "datetime";
next.autoGeneration = { enabled: true, type: "current_time" };
next.readonly = true;
return next;
}
}
if (kind === "attach" && type === "file") {
next.fieldType = "file";
next.accept =
format === "image" ? "image/*" : format === "doc" ? ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" : "*/*";
return next;
}
return next;
}
// ─────────────────────────────────────────────────────────
interface InvFieldConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
tableName?: string;
columnName?: string;
tables?: TableSelectOption[];
/** 전체 테이블 목록 (entity 참조 테이블 dropdown 용). 없으면 tables 로 폴백. */
allTables?: TableSelectOption[];
menuObjid?: number;
screenTableName?: string;
inputType?: string;
componentType?: string;
}
export const InvFieldConfigPanel: React.FC<InvFieldConfigPanelProps> = ({
config,
onChange,
tableName,
columnName,
tables = [],
allTables,
screenTableName,
inputType: metaInputType,
componentType,
}) => {
const triple = resolveTriple(config, componentType, metaInputType);
const { kind: currentKind, type: fieldType, format: currentFormat } = triple;
const isSelectGroup = currentKind === "choice";
const primaryTableName = screenTableName || tableName || "";
const dataRole = (config.dataRole || "primary") as "primary" | "reference" | "child";
const selectedSourceTable = dataRole === "primary" ? primaryTableName : config.sourceTable || "";
const hasReferenceSource = dataRole !== "primary";
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
const [rulesRefreshKey, setRulesRefreshKey] = useState(0);
const numberingTableName = primaryTableName;
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [sourceColumns, setSourceColumns] = useState<ColumnOption[]>([]);
const [loadingSourceColumns, setLoadingSourceColumns] = useState(false);
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
const updateConfig = (field: string, value: any) => onChange({ ...config, [field]: value });
const loadColumnsForTable = useCallback(async (tblName: string): Promise<ColumnOption[]> => {
if (!tblName) return [];
const resp = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
const data = resp.data.data || resp.data;
const cols = data.columns || data || [];
return cols.map((col: any) => ({
columnName: col.columnName || col.column_name || col.name,
columnLabel:
col.displayName ||
col.display_name ||
col.columnLabel ||
col.column_label ||
col.columnName ||
col.column_name ||
col.name,
}));
}, []);
const setTriple = (nextKind: Kind, nextType: Type, nextFormat: string) => {
const next = applyTriple(config, nextKind, nextType, nextFormat, { numberingTableName });
onChange(next);
const syncTableName = screenTableName || tableName;
const syncColumnName = columnName || config.columnName || config.fieldName;
if (syncTableName && syncColumnName) {
apiClient
.put(`/table-management/tables/${syncTableName}/columns/${syncColumnName}/input-type`, {
inputType: next.inputType || next.fieldType,
})
.then(() => {
window.dispatchEvent(new CustomEvent("table-columns-refresh"));
})
.catch(() => {});
}
};
const handleKindChange = (nextKind: Kind) => {
const firstType = TYPES_BY_KIND[nextKind][0]?.id;
if (!firstType) return;
setTriple(nextKind, firstType, defaultFormatFor(firstType));
};
const handleTypeChange = (nextType: Type) => {
const nextKind = KIND_OF_TYPE[nextType];
setTriple(nextKind, nextType, defaultFormatFor(nextType));
};
const handleFormatChange = (nextFormat: string) => {
setTriple(currentKind, fieldType, nextFormat);
};
const handleDataRoleChange = (nextRole: "primary" | "reference" | "child") => {
const nextConfig: Record<string, any> = { ...config, dataRole: nextRole };
if (nextRole === "primary") {
nextConfig.sourceTable = primaryTableName || "";
nextConfig.sourceColumn = config.sourceColumn || columnName || config.columnName || "";
} else {
nextConfig.sourceTable = config.sourceTable || "";
nextConfig.sourceColumn = config.sourceColumn || "";
}
onChange(nextConfig);
};
// 채번 규칙 로드
useEffect(() => {
if (!(currentKind === "auto" && fieldType === "autonum")) return;
if (!numberingTableName) {
setNumberingRules([]);
return;
}
let cancelled = false;
setLoadingRules(true);
(async () => {
try {
const resp = await getAvailableNumberingRulesForScreen(numberingTableName);
if (!cancelled) setNumberingRules(resp.success && resp.data ? resp.data : []);
} catch {
if (!cancelled) setNumberingRules([]);
} finally {
if (!cancelled) setLoadingRules(false);
}
})();
return () => {
cancelled = true;
};
}, [numberingTableName, currentKind, fieldType, rulesRefreshKey]);
// 엔티티 컬럼 로드
const loadEntityColumns = useCallback(
async (tblName: string) => {
if (!tblName) {
setEntityColumns([]);
return;
}
setLoadingColumns(true);
try {
setEntityColumns(await loadColumnsForTable(tblName));
} catch {
setEntityColumns([]);
} finally {
setLoadingColumns(false);
}
},
[loadColumnsForTable],
);
useEffect(() => {
if (currentKind === "choice" && currentFormat === "entity" && config.entityTable) {
loadEntityColumns(config.entityTable);
}
}, [currentKind, currentFormat, config.entityTable, loadEntityColumns]);
// 데이터 컬럼 로드 (참조/하위 일 때)
useEffect(() => {
if (!selectedSourceTable) {
setSourceColumns([]);
return;
}
let cancelled = false;
setLoadingSourceColumns(true);
(async () => {
try {
if (!cancelled) setSourceColumns(await loadColumnsForTable(selectedSourceTable));
} catch {
if (!cancelled) setSourceColumns([]);
} finally {
if (!cancelled) setLoadingSourceColumns(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedSourceTable, loadColumnsForTable]);
// 카테고리 값 로드
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
if (!catTable || !catColumn) {
setCategoryValues([]);
return;
}
setLoadingCategoryValues(true);
try {
const resp = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
if (resp.data.success && resp.data.data) {
const flattenTree = (items: any[], depth = 0): CategoryValueOption[] => {
const result: CategoryValueOption[] = [];
for (const item of items) {
result.push({
valueCode: item.valueCode,
valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel,
});
if (item.children?.length) result.push(...flattenTree(item.children, depth + 1));
}
return result;
};
setCategoryValues(flattenTree(resp.data.data));
}
} catch {
setCategoryValues([]);
} finally {
setLoadingCategoryValues(false);
}
}, []);
useEffect(() => {
if (currentKind === "choice" && currentFormat === "code") {
const catTable = config.categoryTable || tableName;
const catColumn = config.categoryColumn || columnName;
if (catTable && catColumn) loadCategoryValues(catTable, catColumn);
}
}, [currentKind, currentFormat, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]);
// 필터 컬럼 로드 (entity / code 일 때만)
const filterTargetTable = useMemo(() => {
if (currentKind !== "choice") return null;
if (currentFormat === "entity") return config.entityTable;
if (currentFormat === "code" || currentFormat === "status") return config.categoryTable || tableName;
return null;
}, [currentKind, currentFormat, config.entityTable, config.categoryTable, tableName]);
useEffect(() => {
if (!filterTargetTable) {
setFilterColumns([]);
return;
}
let cancelled = false;
setLoadingFilterColumns(true);
(async () => {
try {
if (!cancelled) setFilterColumns(await loadColumnsForTable(filterTargetTable));
} catch {
if (!cancelled) setFilterColumns([]);
} finally {
if (!cancelled) setLoadingFilterColumns(false);
}
})();
return () => {
cancelled = true;
};
}, [filterTargetTable, loadColumnsForTable]);
// ── 렌더 ────────────────────────────────────────────
const visibleTypes = TYPES_BY_KIND[currentKind] || [];
const visibleFormats = FORMATS_BY_TYPE[fieldType] || [];
const showFormatTrigger = visibleFormats.length > 1;
const tableOptions = normalizeTableSelectOptions(
allTables && allTables.length > 0 ? allTables : tables,
);
return (
<div
style={{
fontFamily: "var(--v5-font-sans)",
color: "var(--cp-text)",
}}
>
<CPCrumb
kinds={KINDS}
currentKind={currentKind}
onChangeKind={(k) => handleKindChange(k as Kind)}
types={visibleTypes}
value={fieldType}
onChange={(v) => handleTypeChange(v as Type)}
/>
<div style={{ padding: "12px" }}>
{/* ── 데이터 소속 ─────────────────────────── */}
<CPSection title="① 데이터 소속" desc="이 필드의 값이 어디 컬럼에 저장되는가">
<CPRow label="역할">
<CPSegment
value={dataRole}
onChange={(v) => handleDataRoleChange(v as "primary" | "reference" | "child")}
options={[
{ value: "primary", label: "주" },
{ value: "reference", label: "참조" },
{ value: "child", label: "하위" },
]}
/>
</CPRow>
<CPRow label="테이블" required={hasReferenceSource}>
{hasReferenceSource ? (
<CPSelect
value={config.sourceTable || ""}
onChange={(v) => onChange({ ...config, dataRole, sourceTable: v, sourceColumn: "" })}
>
<option value=""> </option>
{tableOptions.map((table) => (
<option key={table.tableName} value={table.tableName}>
{table.label}
</option>
))}
</CPSelect>
) : (
<DimText value={primaryTableName || "메인 테이블 미연결"} muted={!primaryTableName} />
)}
</CPRow>
<CPRow label="컬럼" required={hasReferenceSource}>
{hasReferenceSource ? (
loadingSourceColumns ? (
<InlineLoader text="컬럼 로딩 중..." />
) : (
<CPSelect
value={config.sourceColumn || ""}
onChange={(v) => updateConfig("sourceColumn", v)}
disabled={!selectedSourceTable}
>
<option value="">{!selectedSourceTable ? "먼저 테이블 선택" : "컬럼 선택"}</option>
{sourceColumns.map((column) => (
<option key={column.columnName} value={column.columnName}>
{column.columnLabel}
</option>
))}
</CPSelect>
)
) : (
<DimText value={columnName || config.columnName || config.fieldKey || "-"} mono />
)}
</CPRow>
{hasReferenceSource && selectedSourceTable && !loadingSourceColumns && sourceColumns.length === 0 && (
<Hint tone="warn"> .</Hint>
)}
</CPSection>
{/* ── 유형별 설정 (format trigger + 본문) ──────────────── */}
<CPSection title="② 유형별 설정">
{showFormatTrigger && (
<CPFormatTrigger
formats={visibleFormats}
value={currentFormat}
onChange={handleFormatChange}
/>
)}
<FormatBody
kind={currentKind}
type={fieldType}
format={currentFormat}
config={config}
updateConfig={updateConfig}
onChange={onChange}
tableName={tableName}
columnName={columnName}
tables={tables}
allTables={allTables}
loadingColumns={loadingColumns}
entityColumns={entityColumns}
categoryValues={categoryValues}
loadingCategoryValues={loadingCategoryValues}
numberingRules={numberingRules}
loadingRules={loadingRules}
numberingTableName={numberingTableName}
onRulesRefresh={() => setRulesRefreshKey((k) => k + 1)}
/>
</CPSection>
{/* ── 데이터 필터 (code/entity/status 일 때만) ── */}
{filterTargetTable && (
<CPSection title="③ 데이터 필터" desc={`${filterTargetTable} 테이블에서 옵션을 불러올 때 적용`}>
<FilterConditionsSection
filters={(config.filters as OptionFilter[]) || []}
columns={filterColumns}
loadingColumns={loadingFilterColumns}
onFiltersChange={(filters) => updateConfig("filters", filters)}
/>
</CPSection>
)}
{/* ── 고급 설정 ─────────────────────────── */}
{currentKind !== "auto" && (
<CPGroup title="고급 설정" defaultOpen={false}>
{isSelectGroup ? (
<SelectAdvancedOptions config={config} updateConfig={updateConfig} multi={fieldType === "multi"} />
) : (
<InputAdvancedOptions config={config} updateConfig={updateConfig} />
)}
</CPGroup>
)}
</div>
</div>
);
};
InvFieldConfigPanel.displayName = "InvFieldConfigPanel";
// ───────────────────────────────────────────────────────
// 유틸 컴포넌트
// ───────────────────────────────────────────────────────
function DimText({ value, mono, muted }: { value: React.ReactNode; mono?: boolean; muted?: boolean }) {
return (
<div
style={{
height: 28,
padding: "0 8px",
display: "flex",
alignItems: "center",
fontSize: 12,
fontFamily: mono ? "var(--v5-font-mono)" : "var(--v5-font-sans)",
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border)",
borderRadius: 6,
color: muted ? "var(--cp-text-muted)" : "var(--cp-text)",
}}
>
{value}
</div>
);
}
function InlineLoader({ text }: { text: string }) {
return (
<span
style={{
fontSize: 11,
color: "var(--cp-text-muted)",
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
<Loader2 size={11} className="animate-spin" /> {text}
</span>
);
}
function Hint({ children, tone = "default" }: { children: React.ReactNode; tone?: "default" | "warn" }) {
return (
<div
style={{
fontSize: 10.5,
color: tone === "warn" ? "var(--v5-amber)" : "var(--cp-text-muted)",
marginTop: 4,
lineHeight: 1.4,
}}
>
{children}
</div>
);
}
// ───────────────────────────────────────────────────────
// 유형별 본문 디스패처
// ───────────────────────────────────────────────────────
type FormatBodyProps = {
kind: Kind;
type: Type;
format: string;
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
onChange: (config: Record<string, any>) => void;
tableName?: string;
columnName?: string;
tables: TableSelectOption[];
/** 전체 테이블 목록 (entity 참조 테이블 dropdown 용). 없으면 tables 폴백. */
allTables?: TableSelectOption[];
loadingColumns: boolean;
entityColumns: ColumnOption[];
categoryValues: CategoryValueOption[];
loadingCategoryValues: boolean;
numberingRules: NumberingRuleConfig[];
loadingRules: boolean;
numberingTableName: string;
onRulesRefresh: () => void;
};
function FormatBody(p: FormatBodyProps) {
const { kind, type, format, config, updateConfig, onChange } = p;
// 입력
if (kind === "input" && type === "text") return <TextOptions format={format} config={config} updateConfig={updateConfig} />;
if (kind === "input" && type === "number") return <NumberOptions format={format} config={config} updateConfig={updateConfig} />;
if (kind === "input" && type === "money") return <MoneyOptions format={format} config={config} updateConfig={updateConfig} />;
if (kind === "input" && type === "date") return <DateOptions format={format} config={config} updateConfig={updateConfig} />;
// 선택
if (kind === "choice") {
const isMulti = type === "multi";
if (format === "list") return <SelectOptions multi={isMulti} config={config} updateConfig={updateConfig} />;
if (format === "code") return (
<CategoryOptions
config={config}
tableName={p.tableName}
columnName={p.columnName}
loading={p.loadingCategoryValues}
values={p.categoryValues}
updateConfig={updateConfig}
/>
);
if (format === "entity") return (
<EntityOptions
config={config}
tables={(p.allTables && p.allTables.length > 0) ? p.allTables : p.tables}
loadingColumns={p.loadingColumns}
entityColumns={p.entityColumns}
onChange={onChange}
updateConfig={updateConfig}
multi={isMulti}
/>
);
if (format === "status") return <StatusOptions config={config} updateConfig={updateConfig} />;
if (format === "boolean") return <BooleanOptions config={config} updateConfig={updateConfig} />;
if (format === "tags") return <TagsOptions config={config} updateConfig={updateConfig} />;
}
// 자동
if (kind === "auto" && type === "autonum") return (
<NumberingOptions
config={config}
numberingTableName={p.numberingTableName}
columnName={p.columnName}
loading={p.loadingRules}
rules={p.numberingRules}
onChange={onChange}
updateConfig={updateConfig}
onRulesRefresh={p.onRulesRefresh}
/>
);
if (kind === "auto" && type === "formula") return <FormulaOptions config={config} updateConfig={updateConfig} />;
if (kind === "auto" && type === "audit") return <AuditOptions config={config} updateConfig={updateConfig} />;
// 첨부
if (kind === "attach" && type === "file") return <FileOptions format={format} config={config} updateConfig={updateConfig} />;
return <Hint> .</Hint>;
}
// ───────────────────────────────────────────────────────
// 입력 — 글자
// ───────────────────────────────────────────────────────
function TextOptions({
format,
config,
updateConfig,
}: {
format: string;
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
return (
<>
<CPRow label="안내 텍스트">
<CPText value={config.placeholder ?? ""} onChange={(v) => updateConfig("placeholder", v)} placeholder="입력 안내" />
</CPRow>
<CPRow label="기본값">
<CPText value={config.defaultValue ?? ""} onChange={(v) => updateConfig("defaultValue", v)} placeholder="비움" />
</CPRow>
{format === "free" && (
<>
<CPRow label="줄 수">
<CPSegment
value={String(config.rows ?? 1)}
onChange={(v) => updateConfig("rows", Number(v))}
options={[{ value: "1", label: "한 줄" }, { value: "4", label: "여러 줄" }]}
/>
</CPRow>
<CPRow label="최소/최대">
<div style={{ display: "flex", gap: 5 }}>
<CPNumber value={config.minLength ?? ""} onChange={(v) => updateConfig("minLength", v)} placeholder="최소" suffix="자" />
<CPNumber value={config.maxLength ?? ""} onChange={(v) => updateConfig("maxLength", v)} placeholder="제한 없음" suffix="자" />
</div>
</CPRow>
<CPRow label="허용 문자">
<CPSelect value={config.allow || "any"} onChange={(v) => updateConfig("allow", v)}>
<option value="any"> </option>
<option value="alphanum">+</option>
<option value="kor"></option>
<option value="num"></option>
</CPSelect>
</CPRow>
<CPRow label="글자수 카운터">
<CPSwitch value={!!config.showCounter} onChange={(v) => updateConfig("showCounter", v)} />
</CPRow>
</>
)}
{format === "email" && (
<>
<CPRow label="도메인 제한">
<CPText mono value={config.domain ?? ""} onChange={(v) => updateConfig("domain", v)} placeholder="예: @company.com" />
</CPRow>
<CPRow label="중복 검사">
<CPSwitch value={config.uniqueCheck ?? false} onChange={(v) => updateConfig("uniqueCheck", v)} />
</CPRow>
<CPRow label="대소문자">
<CPSegment
value={config.caseRule || "lower"}
onChange={(v) => updateConfig("caseRule", v)}
options={[{ value: "keep", label: "유지" }, { value: "lower", label: "소문자" }]}
/>
</CPRow>
</>
)}
{format === "phone" && (
<>
<CPRow label="국가">
<CPSegment
value={config.country || "kr"}
onChange={(v) => updateConfig("country", v)}
options={[{ value: "kr", label: "국내" }, { value: "intl", label: "국제" }]}
/>
</CPRow>
<CPRow label="형식 마스크">
<CPText mono value={config.mask ?? "010-####-####"} onChange={(v) => updateConfig("mask", v)} />
</CPRow>
<CPRow label="휴대폰만">
<CPSwitch value={!!config.mobileOnly} onChange={(v) => updateConfig("mobileOnly", v)} />
</CPRow>
<CPRow label="SMS 인증">
<CPSwitch value={!!config.smsVerify} onChange={(v) => updateConfig("smsVerify", v)} />
</CPRow>
</>
)}
{format === "rrn" && (
<>
<CPRow label="종류">
<CPSegment
value={config.rrnKind || "biz"}
onChange={(v) => updateConfig("rrnKind", v)}
options={[
{ value: "rrn", label: "주민" },
{ value: "biz", label: "사업자" },
{ value: "foreign", label: "외국인" },
]}
/>
</CPRow>
<CPRow label="형식 마스크">
<CPText
mono
value={config.mask ?? (config.rrnKind === "biz" ? "###-##-#####" : "######-#######")}
onChange={(v) => updateConfig("mask", v)}
/>
</CPRow>
<CPRow label="마스킹 표시">
<CPSwitch value={config.maskDisplay ?? true} onChange={(v) => updateConfig("maskDisplay", v)} />
</CPRow>
<CPRow label="암호화 저장">
<CPSwitch value={config.encrypt ?? true} onChange={(v) => updateConfig("encrypt", v)} />
</CPRow>
<CPRow label="체크섬 검증">
<CPSwitch value={config.checksum ?? true} onChange={(v) => updateConfig("checksum", v)} />
</CPRow>
</>
)}
{format === "address" && (
<>
<Hint> API (juso.go.kr / ) . .</Hint>
<CPRow label="저장 형태">
<CPSegment
value={config.addressShape || "single"}
onChange={(v) => updateConfig("addressShape", v)}
options={[
{ value: "single", label: "한 덩어리" },
{ value: "split", label: "분리" },
{ value: "json", label: "JSON" },
]}
/>
</CPRow>
<CPRow label="검색 API">
<CPSelect value={config.addressApi || "juso"} onChange={(v) => updateConfig("addressApi", v)}>
<option value="juso"> API</option>
<option value="kakao"></option>
<option value="none"> </option>
</CPSelect>
</CPRow>
<CPRow label="국내만">
<CPSwitch value={config.koreaOnly ?? true} onChange={(v) => updateConfig("koreaOnly", v)} />
</CPRow>
</>
)}
{format === "card" && (
<>
<Hint> Luhn / PCI .</Hint>
<CPRow label="형식 마스크">
<CPText mono value={config.mask ?? "####-****-****-####"} onChange={(v) => updateConfig("mask", v)} />
</CPRow>
<CPRow label="암호화 저장">
<CPSwitch value={config.encrypt ?? true} onChange={(v) => updateConfig("encrypt", v)} />
</CPRow>
<CPRow label="Luhn 검증">
<CPSwitch value={config.luhn ?? true} onChange={(v) => updateConfig("luhn", v)} />
</CPRow>
</>
)}
{format === "url" && (
<>
<CPRow label="프로토콜">
<CPSegment
value={config.urlProto || "any"}
onChange={(v) => updateConfig("urlProto", v)}
options={[{ value: "any", label: "모두" }, { value: "https", label: "HTTPS만" }]}
/>
</CPRow>
<CPRow label="도메인 화이트리스트">
<CPText mono value={config.urlWhitelist ?? ""} onChange={(v) => updateConfig("urlWhitelist", v)} placeholder="invyone.com, *.co.kr" />
</CPRow>
</>
)}
{format === "mask" && (
<>
<CPRow label="마스킹 패턴" required>
<CPText mono value={config.mask ?? ""} onChange={(v) => updateConfig("mask", v)} placeholder="예: ###-####" />
</CPRow>
<CPRow label="허용 문자">
<CPSelect value={config.allow || "any"} onChange={(v) => updateConfig("allow", v)}>
<option value="any"> </option>
<option value="num"></option>
<option value="alphanum">+</option>
</CPSelect>
</CPRow>
</>
)}
</>
);
}
// ───────────────────────────────────────────────────────
// 입력 — 숫자
// ───────────────────────────────────────────────────────
function NumberOptions({
format,
config,
updateConfig,
}: {
format: string;
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
return (
<>
<CPRow label="안내 텍스트">
<CPText value={config.placeholder ?? ""} onChange={(v) => updateConfig("placeholder", v)} placeholder="입력 안내" />
</CPRow>
<CPRow label="값 범위">
<div style={{ display: "flex", gap: 5 }}>
<CPNumber value={config.min ?? ""} onChange={(v) => updateConfig("min", v)} placeholder={format === "percent" ? "0" : "최소"} />
<CPNumber value={config.max ?? ""} onChange={(v) => updateConfig("max", v)} placeholder={format === "percent" ? "100" : "최대"} />
</div>
</CPRow>
<CPRow label="단계">
<CPNumber
value={config.step ?? ""}
onChange={(v) => updateConfig("step", v)}
placeholder={format === "decimal" ? "0.01" : "1"}
/>
</CPRow>
{format === "decimal" && (
<CPRow label="소수 자리">
<CPNumber value={config.decimalPlaces ?? 2} onChange={(v) => updateConfig("decimalPlaces", v ?? 2)} min={0} max={10} />
</CPRow>
)}
{format === "percent" && (
<CPRow label="기호 표시">
<CPSwitch value={config.showPercent ?? true} onChange={(v) => updateConfig("showPercent", v)} />
</CPRow>
)}
<CPRow label="천 단위 구분">
<CPSwitch value={config.thousands ?? false} onChange={(v) => updateConfig("thousands", v)} />
</CPRow>
<CPRow label="단위">
<CPText value={config.unit ?? (format === "percent" ? "%" : "")} onChange={(v) => updateConfig("unit", v)} placeholder="예: 개, kg" />
</CPRow>
</>
);
}
// ───────────────────────────────────────────────────────
// 입력 — 금액
// ───────────────────────────────────────────────────────
function MoneyOptions({
format,
config,
updateConfig,
}: {
format: string;
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
return (
<>
<CPRow label="안내 텍스트">
<CPText value={config.placeholder ?? ""} onChange={(v) => updateConfig("placeholder", v)} placeholder="0" />
</CPRow>
{format === "krw" ? (
<>
<CPRow label="기호">
<CPSegment
value={config.symbolPos || "prefix"}
onChange={(v) => updateConfig("symbolPos", v)}
options={[{ value: "prefix", label: "₩ 앞" }, { value: "suffix", label: "원 뒤" }, { value: "none", label: "없음" }]}
/>
</CPRow>
<CPRow label="천 단위 구분">
<CPSwitch value={config.thousands ?? true} onChange={(v) => updateConfig("thousands", v)} />
</CPRow>
<CPRow label="음수 허용">
<CPSwitch value={config.allowNegative ?? false} onChange={(v) => updateConfig("allowNegative", v)} />
</CPRow>
</>
) : (
<>
<Hint> . .</Hint>
<CPRow label="통화">
<CPSelect value={config.currency || "USD"} onChange={(v) => updateConfig("currency", v)}>
<option value="USD">USD ()</option>
<option value="EUR">EUR ()</option>
<option value="JPY">JPY ()</option>
<option value="CNY">CNY ()</option>
<option value="GBP">GBP ()</option>
</CPSelect>
</CPRow>
<CPRow label="환율 자동 조회">
<CPSwitch value={!!config.autoFx} onChange={(v) => updateConfig("autoFx", v)} />
</CPRow>
<CPRow label="기준 통화">
<CPText mono value={config.baseCurrency ?? "KRW"} onChange={(v) => updateConfig("baseCurrency", v)} />
</CPRow>
</>
)}
</>
);
}
// ───────────────────────────────────────────────────────
// 입력 — 날짜
// ───────────────────────────────────────────────────────
function DateOptions({
format,
config,
updateConfig,
}: {
format: string;
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
const dateFormatFallback =
format === "datetime" ? "YYYY-MM-DD HH:mm" : format === "time" ? "HH:mm" : "YYYY-MM-DD";
return (
<>
<CPRow label="기본값">
<CPSegment
value={config.dateDefault || "none"}
onChange={(v) => updateConfig("dateDefault", v)}
options={[
{ value: "none", label: "비움" },
{ value: "today", label: "오늘" },
{ value: "now", label: "지금" },
]}
/>
</CPRow>
{format === "range" ? (
<>
<CPRow label="범위 프리셋">
<CPSwitch value={config.rangePresets ?? true} onChange={(v) => updateConfig("rangePresets", v)} />
</CPRow>
<CPRow label="최대 기간(일)">
<CPNumber value={config.maxRangeDays ?? ""} onChange={(v) => updateConfig("maxRangeDays", v)} placeholder="제한 없음" />
</CPRow>
</>
) : (
<CPRow label="형식 표시">
<CPText
mono
value={config.dateFormat ?? dateFormatFallback}
onChange={(v) => updateConfig("dateFormat", v)}
/>
</CPRow>
)}
<CPRow label="제한">
<div style={{ display: "flex", gap: 5 }}>
<CPText mono value={config.minDate ?? ""} onChange={(v) => updateConfig("minDate", v)} placeholder="최소 (YYYY-MM-DD)" />
<CPText mono value={config.maxDate ?? ""} onChange={(v) => updateConfig("maxDate", v)} placeholder="최대" />
</div>
</CPRow>
<CPRow label="오늘 버튼">
<CPSwitch value={config.showToday ?? false} onChange={(v) => updateConfig("showToday", v)} />
</CPRow>
<CPRow label="달력 시작 요일">
<CPSegment
value={config.weekStart || "sun"}
onChange={(v) => updateConfig("weekStart", v)}
options={[{ value: "sun", label: "일" }, { value: "mon", label: "월" }]}
/>
</CPRow>
</>
);
}
// ───────────────────────────────────────────────────────
// 선택형 옵션
// ───────────────────────────────────────────────────────
function SelectOptions({
config,
updateConfig,
multi = false,
}: {
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
multi?: boolean;
}) {
const options = (config.options as { value: string; label?: string }[]) || [];
const addOption = () => updateConfig("options", [...options, { value: "", label: "" }]);
const updateOptionValue = (index: number, value: string) => {
const next = [...options];
next[index] = { ...next[index], value, label: value };
updateConfig("options", next);
};
const removeOption = (index: number) => updateConfig("options", options.filter((_, i) => i !== index));
return (
<>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingBottom: 6,
marginBottom: 4,
}}
>
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--cp-text-sec)" }}>
{multi ? "선택 가능 옵션" : "옵션 목록"} ({options.length})
</span>
<button
type="button"
onClick={addOption}
style={{
fontSize: 11,
fontWeight: 600,
padding: "4px 9px",
border: "1px solid var(--cp-border)",
background: "var(--cp-surface)",
color: "var(--cp-text-sec)",
borderRadius: 5,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{CP_ICONS.plus}
</button>
</div>
{options.length === 0 ? (
<div
style={{
fontSize: 11,
color: "var(--cp-text-muted)",
padding: "16px 8px",
textAlign: "center",
border: "1px dashed var(--cp-border)",
borderRadius: 6,
}}
>
. .
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
maxHeight: 220,
overflowY: "auto",
}}
>
{options.map((option, index) => (
<div
key={index}
style={{
display: "grid",
gridTemplateColumns: "10px 1fr 24px",
gap: 6,
alignItems: "center",
padding: 4,
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border)",
borderRadius: 4,
}}
>
<span style={{ color: "var(--cp-text-muted)" }}>{CP_ICONS.grip}</span>
<CPText value={option.value || ""} onChange={(v) => updateOptionValue(index, v)} placeholder={`옵션 ${index + 1}`} />
<CPIconBtn tone="danger" size={20} onClick={() => removeOption(index)}>
{CP_ICONS.trash}
</CPIconBtn>
</div>
))}
</div>
)}
{options.length > 0 && (
<CPRow label="기본 선택값">
<CPSelect value={config.defaultValue || ""} onChange={(v) => updateConfig("defaultValue", v)}>
<option value=""> </option>
{options.map((opt, i) => (
<option key={`d-${i}`} value={opt.value || `_idx_${i}`}>
{opt.label || opt.value || `옵션 ${i + 1}`}
</option>
))}
</CPSelect>
</CPRow>
)}
</>
);
}
function CategoryOptions({
config,
tableName,
columnName,
loading,
values,
updateConfig,
}: {
config: Record<string, any>;
tableName?: string;
columnName?: string;
loading: boolean;
values: CategoryValueOption[];
updateConfig: (k: string, v: any) => void;
}) {
return (
<>
<CPRow label="테이블">
<DimText value={config.categoryTable || tableName || "-"} mono />
</CPRow>
<CPRow label="컬럼">
<DimText value={config.categoryColumn || columnName || "-"} mono />
</CPRow>
{config.source === "code" && config.codeGroup && (
<CPRow label="코드 그룹">
<DimText value={config.codeGroup} mono />
</CPRow>
)}
{loading ? (
<div style={{ padding: "8px 0" }}>
<InlineLoader text="카테고리 값 로딩 중..." />
</div>
) : values.length > 0 ? (
<>
<div
style={{
fontSize: 10.5,
color: "var(--cp-text-muted)",
padding: "8px 0 4px",
}}
>
{values.length}
</div>
<div
style={{
maxHeight: 120,
overflowY: "auto",
border: "1px solid var(--cp-border)",
borderRadius: 6,
background: "var(--cp-surface)",
}}
>
{values.map((cv) => (
<div
key={cv.valueCode}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "3px 8px",
borderBottom: "1px solid var(--cp-border-subtle)",
fontSize: 11,
}}
>
<span
style={{
fontFamily: "var(--v5-font-mono)",
fontSize: 10,
color: "var(--cp-text-muted)",
flexShrink: 0,
minWidth: 36,
}}
>
{cv.valueCode}
</span>
<span style={{ color: "var(--cp-text)" }}>{cv.valueLabel}</span>
</div>
))}
</div>
<CPRow label="기본 선택값">
<CPSelect value={config.defaultValue || ""} onChange={(v) => updateConfig("defaultValue", v)}>
<option value=""> </option>
{values.map((cv) => (
<option key={cv.valueCode} value={cv.valueCode}>
{cv.valueLabel}
</option>
))}
</CPSelect>
</CPRow>
</>
) : (
<Hint tone="warn"> . .</Hint>
)}
</>
);
}
function EntityOptions({
config,
tables,
loadingColumns,
entityColumns,
onChange,
updateConfig,
multi = false,
}: {
config: Record<string, any>;
tables: TableSelectOption[];
loadingColumns: boolean;
entityColumns: ColumnOption[];
onChange: (config: Record<string, any>) => void;
updateConfig: (k: string, v: any) => void;
multi?: boolean;
}) {
const tableOptions = normalizeTableSelectOptions(tables);
return (
<>
<CPRow label="참조 테이블" required>
<CPSelect
value={config.entityTable || ""}
onChange={(v) =>
onChange({ ...config, entityTable: v, entityValueColumn: "", entityLabelColumn: "" })
}
>
<option value=""> </option>
{tableOptions.map((table) => (
<option key={table.tableName} value={table.tableName}>
{table.label}
</option>
))}
</CPSelect>
</CPRow>
{loadingColumns ? (
<div style={{ padding: "4px 0" }}>
<InlineLoader text="컬럼 목록 로딩 중..." />
</div>
) : entityColumns.length > 0 ? (
<>
<CPRow label="저장 값" required help="DB 에 실제 저장되는 컬럼">
<CPSelect value={config.entityValueColumn || ""} onChange={(v) => updateConfig("entityValueColumn", v)}>
<option value=""> </option>
{entityColumns.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.columnLabel}
</option>
))}
</CPSelect>
</CPRow>
<CPRow label="표시 라벨" required help="사용자 화면에 보이는 컬럼">
<CPSelect value={config.entityLabelColumn || ""} onChange={(v) => updateConfig("entityLabelColumn", v)}>
<option value=""> </option>
{entityColumns.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.columnLabel}
</option>
))}
</CPSelect>
</CPRow>
</>
) : config.entityTable ? (
<Hint tone="warn"> .</Hint>
) : null}
{multi && <Hint> () . JSON .</Hint>}
</>
);
}
// ───────────────────────────────────────────────────────
// 선택 — 상태 (color label)
// ───────────────────────────────────────────────────────
function StatusOptions({
config,
updateConfig,
}: {
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
const states: Array<{ value: string; label: string; color?: string }> = config.statusList || [];
const update = (next: any[]) => updateConfig("statusList", next);
return (
<>
<Hint> .</Hint>
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginTop: 4 }}>
{states.map((s, i) => (
<div
key={i}
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 36px 24px",
gap: 4,
alignItems: "center",
padding: 4,
border: "1px solid var(--cp-border)",
borderRadius: 4,
background: "var(--cp-bg-subtle)",
}}
>
<CPText value={s.value} placeholder="값" onChange={(v) => update(states.map((x, j) => j === i ? { ...x, value: v } : x))} />
<CPText value={s.label} placeholder="라벨" onChange={(v) => update(states.map((x, j) => j === i ? { ...x, label: v } : x))} />
<CPColor value={s.color || "#9998ad"} onChange={(v) => update(states.map((x, j) => j === i ? { ...x, color: v } : x))} />
<CPIconBtn tone="danger" size={20} onClick={() => update(states.filter((_, j) => j !== i))}>{CP_ICONS.trash}</CPIconBtn>
</div>
))}
</div>
<button
type="button"
onClick={() => update([...states, { value: "", label: "", color: "#9998ad" }])}
style={{
marginTop: 6,
fontSize: 11, fontWeight: 600,
padding: "4px 9px",
border: "1px solid var(--cp-border)",
background: "var(--cp-surface)",
color: "var(--cp-text-sec)",
borderRadius: 5,
cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 4,
}}
>
{CP_ICONS.plus}
</button>
</>
);
}
// ───────────────────────────────────────────────────────
// 선택 — 예/아니오
// ───────────────────────────────────────────────────────
function BooleanOptions({
config,
updateConfig,
}: {
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
return (
<>
<Hint>canonical input . .</Hint>
<CPRow label="표시 형태">
<CPSegment
value={config.boolStyle || "switch"}
onChange={(v) => updateConfig("boolStyle", v)}
options={[
{ value: "checkbox", label: "체크박스" },
{ value: "switch", label: "스위치" },
{ value: "yesno", label: "버튼 Y/N" },
]}
/>
</CPRow>
<CPRow label="True 라벨">
<CPText value={config.trueLabel ?? "예"} onChange={(v) => updateConfig("trueLabel", v)} placeholder="예" />
</CPRow>
<CPRow label="False 라벨">
<CPText value={config.falseLabel ?? "아니오"} onChange={(v) => updateConfig("falseLabel", v)} placeholder="아니오" />
</CPRow>
<CPRow label="기본값">
<CPSegment
value={String(config.defaultValue ?? "false")}
onChange={(v) => updateConfig("defaultValue", v === "true")}
options={[
{ value: "true", label: "예" },
{ value: "false", label: "아니오" },
]}
/>
</CPRow>
</>
);
}
// ───────────────────────────────────────────────────────
// 선택 — 태그 (자유 다중)
// ───────────────────────────────────────────────────────
function TagsOptions({
config,
updateConfig,
}: {
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
return (
<>
<CPRow label="자유 입력">
<CPSwitch value={config.allowFreeTags ?? true} onChange={(v) => updateConfig("allowFreeTags", v)} />
</CPRow>
<CPRow label="구분자">
<CPSegment
value={config.tagSeparator || ","}
onChange={(v) => updateConfig("tagSeparator", v)}
options={[{ value: ",", label: "," }, { value: " ", label: "공백" }, { value: "enter", label: "엔터" }]}
/>
</CPRow>
<CPRow label="최대 개수">
<CPNumber value={config.maxTags ?? ""} onChange={(v) => updateConfig("maxTags", v)} placeholder="제한 없음" />
</CPRow>
<CPRow label="추천 목록">
<CPText value={config.tagSuggestions ?? ""} onChange={(v) => updateConfig("tagSuggestions", v)} placeholder="태그1, 태그2 ..." />
</CPRow>
</>
);
}
// ───────────────────────────────────────────────────────
// 자동 — 계산 (formula)
// ───────────────────────────────────────────────────────
function FormulaOptions({
config,
updateConfig,
}: {
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
return (
<>
<Hint tone="warn"> (`lib/formula-parser`) . UI .</Hint>
<CPRow label="수식" required>
<CPText
mono
value={config.computed ?? ""}
onChange={(v) => updateConfig("computed", v)}
placeholder="예: qty * price"
/>
</CPRow>
<CPRow label="결과 형식">
<CPSegment
value={config.formulaResult || "number"}
onChange={(v) => updateConfig("formulaResult", v)}
options={[
{ value: "number", label: "숫자" },
{ value: "currency", label: "금액" },
{ value: "text", label: "글자" },
]}
/>
</CPRow>
<CPRow label="재계산 시점">
<CPSegment
value={config.recalcOn || "change"}
onChange={(v) => updateConfig("recalcOn", v)}
options={[
{ value: "change", label: "변경 시" },
{ value: "save", label: "저장 시" },
{ value: "load", label: "조회 시" },
]}
/>
</CPRow>
</>
);
}
// ───────────────────────────────────────────────────────
// 자동 — 감사 (audit)
// ───────────────────────────────────────────────────────
function AuditOptions({
config,
updateConfig,
}: {
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
const ag = config.autoGeneration || {};
const auditEvent = config.auditEvent || (ag.type === "current_user" ? "user" : "time");
return (
<>
<CPRow label="이벤트">
<CPSelect value={auditEvent} onChange={(v) => updateConfig("auditEvent", v)}>
<option value="created_at"> (created_at)</option>
<option value="updated_at"> (updated_at)</option>
<option value="created_by"> (created_by)</option>
<option value="updated_by"> (updated_by)</option>
</CPSelect>
</CPRow>
<CPRow label="값 채우기">
<CPSelect
value={ag.type || "current_time"}
onChange={(v) => updateConfig("autoGeneration", { ...ag, enabled: true, type: v })}
>
<option value="current_time"> </option>
<option value="current_user"> </option>
</CPSelect>
</CPRow>
<CPRow label="읽기 전용">
<CPSwitch value={config.readonly !== false} onChange={(v) => updateConfig("readonly", v)} />
</CPRow>
</>
);
}
// ───────────────────────────────────────────────────────
// 첨부 — 파일
// ───────────────────────────────────────────────────────
function FileOptions({
format,
config,
updateConfig,
}: {
format: string;
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
return (
<>
<Hint>canonical input . .</Hint>
<CPRow label="허용 형식">
<CPText
mono
value={config.accept ?? (format === "image" ? "image/*" : format === "doc" ? ".pdf,.doc,.docx,.xls,.xlsx" : "*/*")}
onChange={(v) => updateConfig("accept", v)}
placeholder="image/* 또는 .pdf,.docx"
/>
</CPRow>
<CPRow label="최대 크기">
<CPNumber value={config.maxFileSize ?? ""} onChange={(v) => updateConfig("maxFileSize", v)} placeholder="제한 없음" suffix="MB" />
</CPRow>
<CPRow label="복수 파일">
<CPSwitch value={!!config.multipleFiles} onChange={(v) => updateConfig("multipleFiles", v)} />
</CPRow>
{config.multipleFiles && (
<CPRow label="최대 개수">
<CPNumber value={config.maxFiles ?? ""} onChange={(v) => updateConfig("maxFiles", v)} placeholder="제한 없음" />
</CPRow>
)}
{format === "image" && (
<>
<CPRow label="썸네일 생성">
<CPSwitch value={config.thumbnail ?? true} onChange={(v) => updateConfig("thumbnail", v)} />
</CPRow>
<CPRow label="최대 해상도">
<div style={{ display: "flex", gap: 5 }}>
<CPNumber value={config.maxWidth ?? ""} onChange={(v) => updateConfig("maxWidth", v)} placeholder="가로 px" />
<CPNumber value={config.maxHeight ?? ""} onChange={(v) => updateConfig("maxHeight", v)} placeholder="세로 px" />
</div>
</CPRow>
</>
)}
</>
);
}
function NumberingOptions({
config,
numberingTableName,
columnName,
loading,
rules,
onChange,
updateConfig,
onRulesRefresh,
}: {
config: Record<string, any>;
numberingTableName: string;
columnName?: string;
loading: boolean;
rules: NumberingRuleConfig[];
onChange: (config: Record<string, any>) => void;
updateConfig: (k: string, v: any) => void;
onRulesRefresh: () => void;
}) {
const [createOpen, setCreateOpen] = useState(false);
const applyRuleId = (ruleId: string) => {
// canonical 저장 위치: autoGeneration.options.numberingRuleId
// (autoGeneration.ts.generateValue 의 numbering_rule case + InputComponent 의 NumberingPicker prop 과 일치)
onChange({
...config,
autoGeneration: {
...config.autoGeneration,
enabled: true,
type: "numbering_rule" as AutoGenerationType,
tableName: numberingTableName,
options: {
...(config.autoGeneration?.options ?? {}),
numberingRuleId: ruleId,
},
},
});
};
return (
<>
<CPRow label="대상 테이블">
{numberingTableName ? (
<DimText value={numberingTableName} mono />
) : (
<Hint tone="warn"> .</Hint>
)}
</CPRow>
{numberingTableName && (
<>
<CPRow
label="채번 규칙"
required
help={
!loading && rules.length === 0
? "이 테이블에 등록된 채번 규칙이 없어요. 아래 버튼으로 새로 만드세요."
: undefined
}
>
{loading ? (
<InlineLoader text="채번 규칙 로딩 중..." />
) : (
<CPSelect
value={config.autoGeneration?.options?.numberingRuleId || ""}
onChange={(v) => applyRuleId(v)}
disabled={rules.length === 0}
>
<option value="">{rules.length === 0 ? "등록된 규칙 없음" : "채번 규칙 선택"}</option>
{rules.map((rule) => (
<option key={rule.rule_id} value={String(rule.rule_id)}>
{rule.rule_name} ({rule.separator || "-"}
{"{번호}"})
</option>
))}
</CPSelect>
)}
</CPRow>
<CPRow label="">
<button
type="button"
onClick={() => setCreateOpen(true)}
style={{
alignSelf: "flex-start",
padding: "4px 10px",
fontSize: 11,
fontWeight: 600,
color: "hsl(var(--primary))",
background: "hsl(var(--primary) / 0.08)",
border: "1px solid hsl(var(--primary) / 0.3)",
borderRadius: 4,
cursor: "pointer",
}}
>
+
</button>
</CPRow>
</>
)}
<CPRow label="읽기 전용" help="채번 필드는 자동 생성되므로 읽기전용 권장">
<div style={{ display: "flex", alignItems: "center", minHeight: 20 }}>
<CPSwitch value={config.readonly !== false} onChange={(v) => updateConfig("readonly", v)} />
</div>
</CPRow>
{numberingTableName && (
<NumberingRuleCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
tableName={numberingTableName}
columnName={columnName}
onCreated={(rule) => {
// 저장 직후: 새 ruleId 자동 선택 + 목록 refresh
if (rule.rule_id) applyRuleId(String(rule.rule_id));
onRulesRefresh();
}}
/>
)}
</>
);
}
// ───────────────────────────────────────────────────────
// 고급 설정
// ───────────────────────────────────────────────────────
function SelectAdvancedOptions({
config,
updateConfig,
multi = false,
}: {
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
multi?: boolean;
}) {
const isTagsFormat = multi && config.format === "tags";
return (
<>
{!isTagsFormat && (
<CPRow label="선택 방식">
<CPSelect value={config.mode || "dropdown"} onChange={(v) => updateConfig("mode", v)}>
<option value="dropdown"></option>
<option value="combobox"> </option>
{!multi && <option value="radio"> </option>}
{multi && <option value="check"></option>}
{multi && <option value="swap">( )</option>}
{!multi && <option value="toggle"></option>}
</CPSelect>
</CPRow>
)}
{multi && (
<CPRow label={isTagsFormat ? "최대 태그 개수" : "최대 개수"}>
<CPNumber
value={config.maxSelect ?? ""}
onChange={(v) => updateConfig("maxSelect", v)}
placeholder="제한 없음"
min={1}
/>
</CPRow>
)}
{!isTagsFormat && (
<>
<CPRow label="검색 기능">
<CPSwitch value={!!config.searchable} onChange={(v) => updateConfig("searchable", v)} />
</CPRow>
<CPRow label="선택 초기화">
<CPSwitch value={config.allowClear !== false} onChange={(v) => updateConfig("allowClear", v)} />
</CPRow>
</>
)}
</>
);
}
function InputAdvancedOptions({
config,
updateConfig,
}: {
config: Record<string, any>;
updateConfig: (k: string, v: any) => void;
}) {
const ag = config.autoGeneration ?? {};
return (
<>
<CPRow label="자동 생성" help="값이 자동으로 채워져요">
<CPSwitch
value={!!ag.enabled}
onChange={(v) =>
updateConfig("autoGeneration", {
...ag,
enabled: v,
type: ag.type ?? "none",
})
}
/>
</CPRow>
{ag.enabled && (
<>
<CPRow label="생성 방식">
<CPSelect
value={ag.type ?? "none"}
onChange={(v) => updateConfig("autoGeneration", { ...ag, type: v as AutoGenerationType })}
>
<option value="none"> </option>
<option value="uuid">UUID </option>
<option value="current_user"> ID</option>
<option value="current_time"> </option>
<option value="sequence"> </option>
<option value="company_code"> </option>
<option value="department"> </option>
</CPSelect>
</CPRow>
{ag.type && ag.type !== "none" && (
<Hint>{AutoGenerationUtils.getTypeDescription(ag.type as AutoGenerationType)}</Hint>
)}
</>
)}
<CPRow label="입력 마스크" help="# = 숫자, A = 문자, * = 모두">
<CPText
mono
value={config.mask ?? ""}
onChange={(v) => updateConfig("mask", v)}
placeholder="###-####-####"
/>
</CPRow>
</>
);
}
// ───────────────────────────────────────────────────────
// 필터 조건 (선택형)
// ───────────────────────────────────────────────────────
const FilterConditionsSection: React.FC<{
filters: OptionFilter[];
columns: ColumnOption[];
loadingColumns: boolean;
onFiltersChange: (filters: OptionFilter[]) => void;
}> = ({ filters, columns, loadingColumns, onFiltersChange }) => {
const addFilter = () =>
onFiltersChange([...filters, { column: "", operator: "=", value_type: "static", value: "" }]);
const updateFilter = (index: number, patch: Partial<OptionFilter>) => {
const updated = [...filters];
updated[index] = { ...updated[index], ...patch };
if (patch.value_type) {
if (patch.value_type === "static") {
updated[index].field_ref = undefined;
updated[index].user_field = undefined;
} else if (patch.value_type === "field") {
updated[index].value = undefined;
updated[index].user_field = undefined;
} else if (patch.value_type === "user") {
updated[index].value = undefined;
updated[index].field_ref = undefined;
}
}
if (patch.operator === "isNull" || patch.operator === "isNotNull") {
updated[index].value = undefined;
updated[index].field_ref = undefined;
updated[index].user_field = undefined;
updated[index].value_type = "static";
}
onFiltersChange(updated);
};
const removeFilter = (index: number) => onFiltersChange(filters.filter((_, i) => i !== index));
const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull";
return (
<div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 6,
}}
>
<span style={{ fontSize: 11, color: "var(--cp-text-muted)" }}>{filters.length} </span>
<button
type="button"
onClick={addFilter}
style={{
fontSize: 11,
fontWeight: 600,
padding: "3px 8px",
border: "1px solid var(--cp-border)",
background: "var(--cp-surface)",
color: "var(--cp-text-sec)",
borderRadius: 5,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{CP_ICONS.plus}
</button>
</div>
{loadingColumns && <InlineLoader text="컬럼 목록 로딩 중..." />}
{filters.length === 0 && !loadingColumns && (
<div
style={{
fontSize: 11,
color: "var(--cp-text-muted)",
padding: "12px 8px",
textAlign: "center",
border: "1px dashed var(--cp-border)",
borderRadius: 6,
}}
>
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{filters.map((filter, index) => (
<div
key={index}
style={{
border: "1px solid var(--cp-border)",
borderRadius: 6,
padding: 7,
background: "var(--cp-bg-subtle)",
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 5 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<CPSelect value={filter.column || ""} onChange={(v) => updateFilter(index, { column: v })}>
<option value=""></option>
{columns.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.columnLabel}
</option>
))}
</CPSelect>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<CPSelect
value={filter.operator || "="}
onChange={(v) => updateFilter(index, { operator: v as OptionFilter["operator"] })}
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</CPSelect>
</div>
<CPIconBtn tone="danger" size={24} onClick={() => removeFilter(index)}>
{CP_ICONS.trash}
</CPIconBtn>
</div>
{needsValue(filter.operator) && (
<div style={{ display: "flex", alignItems: "center", gap: 5 }}>
<div style={{ width: 100, flexShrink: 0 }}>
<CPSelect
value={filter.value_type || "static"}
onChange={(v) => updateFilter(index, { value_type: v as OptionFilter["value_type"] })}
>
{VALUE_TYPE_OPTIONS.map((vt) => (
<option key={vt.value} value={vt.value}>
{vt.label}
</option>
))}
</CPSelect>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{(filter.value_type || "static") === "static" && (
<CPText
value={String(filter.value ?? "")}
onChange={(v) => updateFilter(index, { value: v })}
placeholder={
filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"
}
/>
)}
{filter.value_type === "field" && (
<CPText
value={filter.field_ref || ""}
onChange={(v) => updateFilter(index, { field_ref: v })}
placeholder="참조 필드명"
/>
)}
{filter.value_type === "user" && (
<CPSelect
value={filter.user_field || ""}
onChange={(v) => updateFilter(index, { user_field: v as OptionFilter["user_field"] })}
>
<option value=""> </option>
{USER_FIELD_OPTIONS.map((uf) => (
<option key={uf.value} value={uf.value}>
{uf.label}
</option>
))}
</CPSelect>
)}
</div>
</div>
)}
</div>
))}
</div>
</div>
);
};
export default InvFieldConfigPanel;