3883031c0b
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>
2318 lines
86 KiB
TypeScript
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;
|