Files
invyone/frontend/components/v2/config-panels/V2InputConfigPanel.tsx
T
gbpark 29682e5b63 INVYONE Studio Config Panel — CP 프리미티브 신설 + V2FieldConfigPanel HTML V4 스펙 풀 매칭
- _shared/cp 프리미티브 14종 (CPSection/CPRow/CPSelect 커스텀 popover/CPSegment 평면/CPCrumb 통합 팝오버 등)
- V2FieldConfigPanel: 4 kinds × 10 types × format별 옵션 패널로 재작성 (panel-input-new.jsx 매칭)
  - resolveTriple/applyTriple 양방향 매핑 — 기존 fieldType/source/autoGeneration 호환
  - 미구현(formula/audit/금액 외화/주민·주소·카드/다중 entity) 영역은 Hint 로 명시
- V2PropertiesPanel cp 톤 + 중복 Separator 정리, ScreenDesigner 우측 패널 너비 드래그
- 다크 luminance 분리 / 섹션 헤더 gradient underline / brumb 좌·우 transparent 영역

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

1134 lines
37 KiB
TypeScript

"use client";
/**
* V2Input 설정 패널 (INVYONE Studio 컨셉 적용)
*
* 분류 체계
* kind = "입력" 고정 (선택/자동/첨부 는 별도 V2 패널로 분리되어 있음)
* type ▸ format
* 글자(text) : 자유 / 여러줄 / 이메일 / 전화 / URL / 사업자번호 / 마스킹
* 숫자(number) : 정수 / 소수 / 슬라이더 / 통화
* 비밀번호(password): 마스킹
* 색상(color) : HEX
* 자동채번(autonum): 채번규칙 / 자동생성(UUID·시간·랜덤)
*
* 화면 구조
* CPHeader → CPCrumb (입력 ▸ type) → CPTabs (설정/바인딩/고급)
* 본문 ① 라벨·안내 ② 형식별 옵션 (CPFormatTrigger 로 format 변경)
* 푸터 kind.type.format
*
* 데이터 모델 호환
* 기존 config { inputType, format, mask, rows, autoGeneration, ... } 그대로 유지.
* deriveTypeFormat / applyTypeFormat 로 양방향 매핑.
*/
import "./_shared/cp/cp.css";
import React, { useState, useEffect, useMemo } from "react";
import { Loader2, Type as TypeIcon } from "lucide-react";
import type { AutoGenerationType } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
import type { NumberingRuleConfig } from "@/types/numbering-rule";
import {
CP_ICONS,
CPSection,
CPRow,
CPText,
CPSelect,
CPTextarea,
CPSwitch,
CPNumber,
CPColor,
CPSegment,
CPHeader,
CPTabs,
CPCrumb,
CPFormatTrigger,
CPBindChip,
} from "./_shared/cp";
import type { CPCrumbType, CPFormatItem } from "./_shared/cp";
// ── 분류 체계 ─────────────────────────────────────────────
type InputType = "text" | "number" | "password" | "color" | "autonum";
const INPUT_TYPES: (CPCrumbType & { id: InputType })[] = [
{ id: "text", name: "글자", desc: "일반 텍스트", icon: "Aa", col: "VARCHAR" },
{ id: "number", name: "숫자", desc: "정수·소수·슬라이더", icon: "#", col: "NUMBER" },
{ id: "password", name: "비밀번호", desc: "마스킹 처리", icon: "••", col: "VARCHAR" },
{ id: "color", name: "색상", desc: "색상 선택기", icon: "◆", col: "VARCHAR" },
{ id: "autonum", name: "자동채번", desc: "자동 번호 생성", icon: "№", col: "VARCHAR" },
];
const INPUT_FORMATS: Record<InputType, CPFormatItem[]> = {
text: [
{ id: "free", name: "자유", desc: "한 줄 텍스트", icon: "Aa" },
{ id: "longtext", name: "여러 줄", desc: "textarea", icon: "¶" },
{ id: "email", name: "이메일", desc: "@ 검증", icon: "@" },
{ id: "tel", name: "전화번호", desc: "마스킹", icon: "☎" },
{ id: "url", name: "URL", desc: "링크", icon: "URL" },
{ id: "biz_no", name: "사업자번호", desc: "###-##-#####", icon: "ID" },
{ id: "mask", name: "커스텀 마스킹", desc: "###-####", icon: "⌨" },
],
number: [
{ id: "int", name: "정수", desc: "0, 1, 2…", icon: "#" },
{ id: "decimal", name: "소수", desc: "1.23", icon: "0.0" },
{ id: "slider", name: "슬라이더", desc: "범위 선택", icon: "⇿" },
{ id: "currency", name: "통화", desc: "천 단위", icon: "₩" },
],
password: [{ id: "masked", name: "마스킹", desc: "입력값 숨김", icon: "••" }],
color: [{ id: "hex", name: "HEX", desc: "#RRGGBB", icon: "#" }],
autonum: [
{ id: "rule", name: "채번규칙", desc: "메뉴별 규칙 적용", icon: "№" },
{ id: "auto", name: "자동생성", desc: "UUID·시간·랜덤", icon: "ƒx" },
],
};
// ── 매핑: 저장 config ↔ (type, format) ────────────────────
function deriveTypeFormat(config: Record<string, any>): { type: InputType; format: string } {
const inputType = config.inputType ?? config.type ?? "text";
if (inputType === "numbering") {
const ag = config.autoGeneration?.type;
if (ag === "numbering_rule" || !ag || ag === "none") return { type: "autonum", format: "rule" };
return { type: "autonum", format: "auto" };
}
if (inputType === "color") return { type: "color", format: "hex" };
if (inputType === "password") return { type: "password", format: "masked" };
if (inputType === "slider") return { type: "number", format: "slider" };
if (inputType === "textarea") return { type: "text", format: "longtext" };
if (inputType === "number") {
if (config.format === "currency") return { type: "number", format: "currency" };
if (config.format === "decimal") return { type: "number", format: "decimal" };
return { type: "number", format: "int" };
}
// text
const f = config.format;
if (f && ["email", "tel", "url", "biz_no"].includes(f)) return { type: "text", format: f };
if (config.mask) return { type: "text", format: "mask" };
return { type: "text", format: "free" };
}
function applyTypeFormat(
config: Record<string, any>,
type: InputType,
format: string,
menuObjid?: number,
): Record<string, any> {
const next: Record<string, any> = { ...config };
if (!(type === "text" && format === "mask")) delete next.mask;
if (type === "autonum") {
next.inputType = "numbering";
next.readonly = config.readonly ?? true;
delete next.format;
if (format === "rule") {
next.autoGeneration = {
...(config.autoGeneration ?? {}),
type: "numbering_rule" as AutoGenerationType,
enabled: true,
selectedMenuObjid: config.autoGeneration?.selectedMenuObjid ?? menuObjid,
};
} else {
const prev = config.autoGeneration?.type;
const nextType: AutoGenerationType =
prev && prev !== "numbering_rule" && prev !== "none" ? (prev as AutoGenerationType) : ("uuid" as AutoGenerationType);
next.autoGeneration = { ...(config.autoGeneration ?? {}), type: nextType, enabled: true };
}
return next;
}
if (type === "color") {
next.inputType = "color";
delete next.format;
return next;
}
if (type === "password") {
next.inputType = "password";
delete next.format;
return next;
}
if (type === "number") {
if (format === "slider") {
next.inputType = "slider";
delete next.format;
} else {
next.inputType = "number";
next.format = format === "currency" ? "currency" : format === "decimal" ? "decimal" : "none";
}
return next;
}
// type === "text"
if (format === "longtext") {
next.inputType = "textarea";
delete next.format;
return next;
}
next.inputType = "text";
if (format === "free") {
delete next.format;
return next;
}
if (format === "mask") {
next.format = "none";
return next;
}
next.format = format;
return next;
}
// ─────────────────────────────────────────────────────────
interface V2InputConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
menuObjid?: number;
allComponents?: any[];
}
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({
config,
onChange,
menuObjid,
allComponents = [],
}) => {
const { type, format } = useMemo(() => deriveTypeFormat(config), [config]);
const [tab, setTab] = useState<"config" | "bind" | "adv">("config");
const updateConfig = (patch: Record<string, any>) => onChange({ ...config, ...patch });
const pickType = (next: InputType) => {
if (next === type) return;
const firstFormat = INPUT_FORMATS[next][0]?.id ?? "free";
onChange(applyTypeFormat(config, next, firstFormat, menuObjid));
};
const pickFormat = (nextFormat: string) => {
if (nextFormat === format) return;
onChange(applyTypeFormat(config, type, nextFormat, menuObjid));
};
return (
<div
style={{
background: "var(--cp-surface)",
display: "flex",
flexDirection: "column",
fontFamily: "var(--v5-font-sans)",
color: "var(--cp-text)",
borderRadius: 8,
border: "1px solid var(--cp-border)",
overflow: "hidden",
}}
>
<CPHeader
icon={<TypeIcon size={14} />}
title="필드 설정"
subtitle={`input.${type}.${format}`}
/>
<CPCrumb
kinds={[{ id: "input", name: "입력", icon: <span style={{ fontSize: 11 }}></span> }]}
currentKind="input"
onChangeKind={() => {}}
types={INPUT_TYPES}
value={type}
onChange={(v) => pickType(v as InputType)}
/>
<CPTabs
value={tab}
onChange={(v) => setTab(v as typeof tab)}
tabs={[
{ id: "config", label: "설정", dot: true },
{ id: "bind", label: "바인딩" },
{ id: "adv", label: "고급" },
]}
/>
<div style={{ padding: "10px 14px" }}>
{tab === "config" && (
<>
<LabelSection config={config} updateConfig={updateConfig} />
<FormatSection
type={type}
format={format}
config={config}
menuObjid={menuObjid}
onChangeFormat={pickFormat}
updateConfig={updateConfig}
onChangeAll={onChange}
/>
</>
)}
{tab === "bind" && <DataBindingSection config={config} onChange={onChange} allComponents={allComponents} />}
{tab === "adv" && (
<AdvancedSection config={config} updateConfig={updateConfig} type={type} />
)}
</div>
<div className="cp-foot">
<span className="cp-foot-id">input.{type}.{format}</span>
</div>
</div>
);
};
V2InputConfigPanel.displayName = "V2InputConfigPanel";
// ── 라벨 섹션 ────────────────────────────────────────────
function LabelSection({
config,
updateConfig,
}: {
config: Record<string, any>;
updateConfig: (patch: Record<string, any>) => void;
}) {
return (
<CPSection title="① 라벨 · 안내">
<CPRow label="필드 라벨">
<CPText
value={config.label ?? ""}
onChange={(v) => updateConfig({ label: v })}
placeholder="예: 주문번호"
/>
</CPRow>
<CPRow label="안내 글자">
<CPText
value={config.placeholder ?? ""}
onChange={(v) => updateConfig({ placeholder: v })}
placeholder="입력 안내"
/>
</CPRow>
<CPRow label="도움말">
<CPText
value={config.helpText ?? ""}
onChange={(v) => updateConfig({ helpText: v })}
placeholder="? 아이콘에 붙는 설명"
/>
</CPRow>
</CPSection>
);
}
// ── 형식 섹션 (타입별 동적 옵션) ──────────────────────────
function FormatSection({
type,
format,
config,
menuObjid,
onChangeFormat,
updateConfig,
onChangeAll,
}: {
type: InputType;
format: string;
config: Record<string, any>;
menuObjid?: number;
onChangeFormat: (next: string) => void;
updateConfig: (patch: Record<string, any>) => void;
onChangeAll: (next: Record<string, any>) => void;
}) {
const formats = INPUT_FORMATS[type];
return (
<CPSection title="② 형식별 설정">
<CPFormatTrigger formats={formats} value={format} onChange={onChangeFormat} />
<FormatOptions
type={type}
format={format}
config={config}
menuObjid={menuObjid}
updateConfig={updateConfig}
onChangeAll={onChangeAll}
/>
</CPSection>
);
}
function FormatOptions({
type,
format,
config,
menuObjid,
updateConfig,
onChangeAll,
}: {
type: InputType;
format: string;
config: Record<string, any>;
menuObjid?: number;
updateConfig: (patch: Record<string, any>) => void;
onChangeAll: (next: Record<string, any>) => void;
}) {
// ── 글자 ─────────────────────────────────────
if (type === "text") {
if (format === "free") {
return (
<>
<CPRow label="최소/최대">
<div style={{ display: "flex", gap: 5 }}>
<CPNumber
value={config.minLength ?? ""}
onChange={(v) => updateConfig({ minLength: v })}
placeholder="0"
suffix="자"
/>
<CPNumber
value={config.maxLength ?? ""}
onChange={(v) => updateConfig({ maxLength: v })}
placeholder="제한 없음"
suffix="자"
/>
</div>
</CPRow>
<CPRow label="글자수 카운터">
<CPSwitch
value={!!config.showCounter}
onChange={(v) => updateConfig({ showCounter: v })}
/>
</CPRow>
</>
);
}
if (format === "longtext") {
return (
<>
<CPRow label="줄 수">
<CPNumber
value={config.rows ?? 3}
onChange={(v) => updateConfig({ rows: v ?? 3 })}
min={2}
max={20}
suffix="줄"
/>
</CPRow>
<CPRow label="최대 길이">
<CPNumber
value={config.maxLength ?? ""}
onChange={(v) => updateConfig({ maxLength: v })}
placeholder="제한 없음"
suffix="자"
/>
</CPRow>
<CPRow label="크기 조절">
<CPSegment
value={config.resize ?? "vertical"}
onChange={(v) => updateConfig({ resize: v })}
options={[
{ value: "none", label: "고정" },
{ value: "vertical", label: "세로만" },
{ value: "both", label: "자유" },
]}
/>
</CPRow>
</>
);
}
if (format === "email") {
return (
<>
<CPRow label="도메인 제한" help="비워두면 모든 도메인 허용">
<CPText
mono
value={config.allowedDomain ?? ""}
onChange={(v) => updateConfig({ allowedDomain: v })}
placeholder="예: @company.com"
/>
</CPRow>
<CPRow label="대소문자">
<CPSegment
value={config.caseRule ?? "lower"}
onChange={(v) => updateConfig({ caseRule: v })}
options={[
{ value: "keep", label: "유지" },
{ value: "lower", label: "소문자" },
]}
/>
</CPRow>
</>
);
}
if (format === "tel") {
return (
<>
<CPRow label="국가">
<CPSegment
value={config.telCountry ?? "kr"}
onChange={(v) => updateConfig({ telCountry: v })}
options={[
{ value: "kr", label: "국내" },
{ value: "intl", label: "국제" },
]}
/>
</CPRow>
<CPRow label="형식">
<CPText
mono
value={config.mask ?? "010-####-####"}
onChange={(v) => updateConfig({ mask: v })}
/>
</CPRow>
</>
);
}
if (format === "url") {
return (
<>
<CPRow label="프로토콜">
<CPSegment
value={config.urlProtocol ?? "any"}
onChange={(v) => updateConfig({ urlProtocol: v })}
options={[
{ value: "any", label: "모두" },
{ value: "https", label: "HTTPS만" },
]}
/>
</CPRow>
</>
);
}
if (format === "biz_no") {
return (
<>
<CPRow label="형식">
<CPText mono value={config.mask ?? "###-##-#####"} onChange={(v) => updateConfig({ mask: v })} />
</CPRow>
<CPRow label="체크섬 검증">
<CPSwitch value={config.verifyChecksum ?? true} onChange={(v) => updateConfig({ verifyChecksum: v })} />
</CPRow>
</>
);
}
if (format === "mask") {
return (
<>
<CPRow label="마스킹 패턴" required help="# = 숫자, A = 영문, * = 모두">
<CPText
mono
value={config.mask ?? ""}
onChange={(v) => updateConfig({ mask: v })}
placeholder="예: ###-####"
/>
</CPRow>
</>
);
}
}
// ── 숫자 ─────────────────────────────────────
if (type === "number") {
if (format === "int" || format === "decimal" || format === "currency") {
return (
<>
<CPRow label="최소값">
<CPNumber value={config.min ?? ""} onChange={(v) => updateConfig({ min: v })} placeholder="제한 없음" />
</CPRow>
<CPRow label="최대값">
<CPNumber value={config.max ?? ""} onChange={(v) => updateConfig({ max: v })} placeholder="제한 없음" />
</CPRow>
{format === "decimal" && (
<CPRow label="소수 자리">
<CPNumber
value={config.decimalPlaces ?? 2}
onChange={(v) => updateConfig({ decimalPlaces: v ?? 2 })}
suffix="자리"
/>
</CPRow>
)}
<CPRow label="천 단위 구분">
<CPSwitch value={config.thousands ?? true} onChange={(v) => updateConfig({ thousands: v })} />
</CPRow>
{format === "currency" ? (
<CPRow label="통화">
<CPSelect
value={config.currency ?? "KRW"}
onChange={(v) => updateConfig({ currency: v })}
>
<option value="KRW"> (KRW)</option>
<option value="USD"> (USD)</option>
<option value="EUR"> (EUR)</option>
<option value="JPY"> (JPY)</option>
</CPSelect>
</CPRow>
) : (
<CPRow label="단위">
<CPText value={config.unit ?? ""} onChange={(v) => updateConfig({ unit: v })} placeholder="예: 개, kg" />
</CPRow>
)}
</>
);
}
if (format === "slider") {
return (
<>
<CPRow label="범위">
<div style={{ display: "flex", gap: 5 }}>
<CPNumber value={config.min ?? 0} onChange={(v) => updateConfig({ min: v ?? 0 })} placeholder="0" />
<CPNumber value={config.max ?? 100} onChange={(v) => updateConfig({ max: v ?? 100 })} placeholder="100" />
</div>
</CPRow>
<CPRow label="단계">
<CPNumber value={config.step ?? 1} onChange={(v) => updateConfig({ step: v ?? 1 })} placeholder="1" />
</CPRow>
<CPRow label="값 표시">
<CPSwitch value={config.showValue ?? true} onChange={(v) => updateConfig({ showValue: v })} />
</CPRow>
</>
);
}
}
// ── 비밀번호 ──────────────────────────────────
if (type === "password") {
return (
<>
<CPRow label="최소 길이">
<CPNumber
value={config.minLength ?? 8}
onChange={(v) => updateConfig({ minLength: v ?? 8 })}
suffix="자"
/>
</CPRow>
<CPRow label="강도 표시">
<CPSwitch value={!!config.showStrength} onChange={(v) => updateConfig({ showStrength: v })} />
</CPRow>
<CPRow label="보기 토글">
<CPSwitch value={config.toggleVisible ?? true} onChange={(v) => updateConfig({ toggleVisible: v })} />
</CPRow>
</>
);
}
// ── 색상 ─────────────────────────────────────
if (type === "color") {
return (
<>
<CPRow label="기본값">
<CPColor value={config.defaultColor ?? "#6c5ce7"} onChange={(v) => updateConfig({ defaultColor: v })} />
</CPRow>
<CPRow label="알파(투명도)">
<CPSwitch value={!!config.allowAlpha} onChange={(v) => updateConfig({ allowAlpha: v })} />
</CPRow>
</>
);
}
// ── 자동채번 ──────────────────────────────────
if (type === "autonum") {
if (format === "rule") {
return (
<NumberingRuleOptions
config={config}
menuObjid={menuObjid}
updateConfig={updateConfig}
onChangeAll={onChangeAll}
/>
);
}
if (format === "auto") {
return <AutoGenerationOptions config={config} updateConfig={updateConfig} />;
}
}
return (
<div style={{ fontSize: 11, color: "var(--cp-text-muted)", padding: 8 }}>
.
</div>
);
}
// ── 채번규칙 옵션 ──────────────────────────────────────
function NumberingRuleOptions({
config,
menuObjid,
updateConfig,
onChangeAll,
}: {
config: Record<string, any>;
menuObjid?: number;
updateConfig: (patch: Record<string, any>) => void;
onChangeAll: (next: Record<string, any>) => void;
}) {
const [parentMenus, setParentMenus] = useState<any[]>([]);
const [loadingMenus, setLoadingMenus] = useState(false);
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
const selectedMenuObjid: number | undefined = config.autoGeneration?.selectedMenuObjid ?? menuObjid;
useEffect(() => {
let cancelled = false;
(async () => {
setLoadingMenus(true);
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get("/admin/menus");
if (!cancelled && response.data.success && response.data.data) {
const allMenus = response.data.data;
const userMenus = allMenus.filter((menu: any) => {
const menuType = menu.menu_type;
const level = menu.level ?? menu.lev ?? menu.LEVEL;
return menuType === "1" && (level === 2 || level === 3 || level === "2" || level === "3");
});
setParentMenus(userMenus);
}
} catch (error) {
console.error("부모 메뉴 로드 실패:", error);
} finally {
if (!cancelled) setLoadingMenus(false);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!selectedMenuObjid) {
setNumberingRules([]);
return;
}
let cancelled = false;
setLoadingRules(true);
(async () => {
try {
const response = await getAvailableNumberingRules(selectedMenuObjid);
if (!cancelled && response.success && response.data) {
setNumberingRules(response.data);
}
} catch (error) {
console.error("채번 규칙 목록 로드 실패:", error);
if (!cancelled) setNumberingRules([]);
} finally {
if (!cancelled) setLoadingRules(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedMenuObjid]);
const setMenu = (id: number | undefined) => {
onChangeAll({
...config,
autoGeneration: {
...(config.autoGeneration ?? {}),
type: "numbering_rule" as AutoGenerationType,
enabled: true,
selectedMenuObjid: id,
},
});
};
const setRule = (ruleId: string | number) => {
onChangeAll({
...config,
autoGeneration: {
...(config.autoGeneration ?? {}),
type: "numbering_rule" as AutoGenerationType,
enabled: true,
selectedMenuObjid,
numberingRuleId: ruleId,
options: { ...(config.autoGeneration?.options ?? {}), numberingRuleId: ruleId },
},
});
};
return (
<>
<CPRow label="대상 메뉴" required>
{loadingMenus ? (
<span
style={{
fontSize: 11,
color: "var(--cp-text-muted)",
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
<Loader2 size={11} className="animate-spin" /> ...
</span>
) : (
<CPSelect
value={selectedMenuObjid ? String(selectedMenuObjid) : ""}
onChange={(v) => setMenu(v ? Number(v) : undefined)}
>
<option value=""> </option>
{parentMenus.map((menu: any) => (
<option key={menu.objid} value={String(menu.objid)}>
{menu.menu_name_kor ?? menu.translated_name ?? menu.menu_name ?? `메뉴 ${menu.objid}`}
</option>
))}
</CPSelect>
)}
</CPRow>
<CPRow label="채번 규칙" required>
{!selectedMenuObjid ? (
<span style={{ fontSize: 10.5, color: "var(--v5-amber)" }}> </span>
) : loadingRules ? (
<span
style={{
fontSize: 11,
color: "var(--cp-text-muted)",
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
<Loader2 size={11} className="animate-spin" /> ...
</span>
) : numberingRules.length === 0 ? (
<span style={{ fontSize: 10.5, color: "var(--cp-text-muted)" }}> </span>
) : (
<CPSelect
value={
config.autoGeneration?.numberingRuleId
? String(config.autoGeneration.numberingRuleId)
: config.autoGeneration?.options?.numberingRuleId
? String(config.autoGeneration.options.numberingRuleId)
: ""
}
onChange={(v) => setRule(v)}
>
<option value=""> </option>
{numberingRules.map((rule) => (
<option key={rule.rule_id} value={String(rule.rule_id)}>
{rule.rule_name} ({rule.separator || "-"}
{"{번호}"})
</option>
))}
</CPSelect>
)}
</CPRow>
<CPRow label="읽기 전용" help="채번 필드는 자동 생성되므로 읽기전용 권장">
<CPSwitch
value={config.readonly !== false}
onChange={(v) => updateConfig({ readonly: v })}
/>
</CPRow>
</>
);
}
// ── 자동생성(UUID/시간/랜덤) 옵션 ─────────────────────────
function AutoGenerationOptions({
config,
updateConfig,
}: {
config: Record<string, any>;
updateConfig: (patch: Record<string, any>) => void;
}) {
const ag = config.autoGeneration ?? {};
const setAg = (patch: Record<string, any>) =>
updateConfig({ autoGeneration: { ...(ag ?? {}), enabled: true, ...patch } });
const setOptions = (patch: Record<string, any>) =>
setAg({ options: { ...(ag.options ?? {}), ...patch } });
return (
<>
<CPRow label="생성 방식" required>
<CPSelect value={ag.type ?? "uuid"} onChange={(v) => setAg({ type: v as AutoGenerationType })}>
<option value="uuid">UUID </option>
<option value="current_user"> ID</option>
<option value="current_time"> </option>
<option value="sequence"> </option>
<option value="random_string"> </option>
<option value="random_number"> </option>
<option value="company_code"> </option>
<option value="department"> </option>
</CPSelect>
</CPRow>
{ag.type && ag.type !== "none" && (
<div
style={{
fontSize: 10.5,
color: "var(--cp-text-muted)",
padding: "0 0 6px",
lineHeight: 1.5,
}}
>
{AutoGenerationUtils.getTypeDescription(ag.type as AutoGenerationType)}
</div>
)}
{ag.type && ["random_string", "random_number"].includes(ag.type) && (
<CPRow label="길이">
<CPNumber
value={ag.options?.length ?? 8}
onChange={(v) => setOptions({ length: v ?? 8 })}
min={1}
max={50}
suffix="자"
/>
</CPRow>
)}
{ag.type && ["random_string", "random_number", "sequence"].includes(ag.type) && (
<>
<CPRow label="접두사">
<CPText
value={ag.options?.prefix ?? ""}
onChange={(v) => setOptions({ prefix: v })}
placeholder="예: INV-"
/>
</CPRow>
<CPRow label="접미사">
<CPText value={ag.options?.suffix ?? ""} onChange={(v) => setOptions({ suffix: v })} />
</CPRow>
<CPRow label="미리보기" align="top">
<div
style={{
fontSize: 11,
fontFamily: "var(--v5-font-mono)",
padding: "4px 8px",
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border)",
borderRadius: 5,
color: "var(--cp-text)",
}}
>
{AutoGenerationUtils.generatePreviewValue(ag)}
</div>
</CPRow>
</>
)}
</>
);
}
// ── 고급 탭 ───────────────────────────────────────────
function AdvancedSection({
config,
updateConfig,
type,
}: {
config: Record<string, any>;
updateConfig: (patch: Record<string, any>) => void;
type: InputType;
}) {
return (
<>
<CPSection title="규칙">
<CPRow label="필수 입력">
<CPSwitch value={!!config.required} onChange={(v) => updateConfig({ required: v })} />
</CPRow>
<CPRow label="읽기 전용">
<CPSwitch value={!!config.readonly} onChange={(v) => updateConfig({ readonly: v })} />
</CPRow>
<CPRow label="숨김">
<CPSwitch value={!!config.hidden} onChange={(v) => updateConfig({ hidden: v })} />
</CPRow>
<CPRow label="비활성화">
<CPSwitch value={!!config.disabled} onChange={(v) => updateConfig({ disabled: v })} />
</CPRow>
</CPSection>
{type !== "autonum" && (
<CPSection title="자동 생성" desc="값이 자동으로 채워져요">
<CPRow label="자동 생성 켬">
<CPSwitch
value={!!config.autoGeneration?.enabled}
onChange={(v) =>
updateConfig({
autoGeneration: { ...(config.autoGeneration ?? {}), enabled: v, type: config.autoGeneration?.type ?? "none" },
})
}
/>
</CPRow>
{config.autoGeneration?.enabled && (
<AutoGenerationOptions config={config} updateConfig={updateConfig} />
)}
</CPSection>
)}
<CPSection title="개발자">
<CPRow label="필드 ID">
<CPText
mono
value={config.fieldId ?? ""}
onChange={(v) => updateConfig({ fieldId: v })}
placeholder="자동 생성"
/>
</CPRow>
</CPSection>
</>
);
}
// ── 데이터 바인딩 (테이블 컬럼 → 필드) ─────────────────────
function DataBindingSection({
config,
onChange,
allComponents,
}: {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
allComponents: any[];
}) {
const [tableColumns, setTableColumns] = useState<string[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const tableListComponents = useMemo(() => {
return allComponents.filter((comp) => {
const t =
comp.componentType ?? comp.widgetType ?? comp.componentConfig?.type ?? (comp.url && comp.url.split("/").pop());
return t === "v2-table-list";
});
}, [allComponents]);
const selectedTableComponent = useMemo(() => {
if (!config.dataBinding?.sourceComponentId) return null;
return tableListComponents.find((comp) => comp.id === config.dataBinding.sourceComponentId);
}, [tableListComponents, config.dataBinding?.sourceComponentId]);
const selectedTableName = useMemo(() => {
if (!selectedTableComponent) return null;
return selectedTableComponent.componentConfig?.selectedTable ?? selectedTableComponent.selectedTable ?? null;
}, [selectedTableComponent]);
useEffect(() => {
if (!selectedTableName) {
setTableColumns([]);
return;
}
let cancelled = false;
setLoadingColumns(true);
(async () => {
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const response = await tableTypeApi.getColumns(selectedTableName);
if (!cancelled && Array.isArray(response) && response.length > 0) {
const cols = response.map((col: any) => col.column_name || col.columnName).filter(Boolean);
setTableColumns(cols);
}
} catch {
const configColumns = selectedTableComponent?.componentConfig?.columns;
if (Array.isArray(configColumns) && !cancelled) {
setTableColumns(configColumns.map((c: any) => c.columnName).filter(Boolean));
}
} finally {
if (!cancelled) setLoadingColumns(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedTableName, selectedTableComponent]);
const enabled = !!config.dataBinding?.sourceComponentId;
const sourceColumn = config.dataBinding?.sourceColumn ?? "";
return (
<CPSection title="저장 위치 / 바인딩">
<CPRow label="바인딩 활성">
<CPSwitch
value={enabled}
onChange={(v) => {
if (v) {
const firstTable = tableListComponents[0];
onChange({
...config,
dataBinding: { sourceComponentId: firstTable?.id ?? "", sourceColumn: "" },
});
} else {
onChange({ ...config, dataBinding: undefined });
}
}}
/>
</CPRow>
{enabled && (
<>
{tableListComponents.length === 0 ? (
<div
style={{
fontSize: 10.5,
color: "var(--v5-amber)",
padding: "4px 0",
}}
>
v2-table-list .
</div>
) : (
<CPRow label="소스 표">
<CPSelect
value={config.dataBinding?.sourceComponentId ?? ""}
onChange={(v) =>
onChange({
...config,
dataBinding: { ...(config.dataBinding ?? {}), sourceComponentId: v, sourceColumn: "" },
})
}
>
<option value=""> </option>
{tableListComponents.map((comp) => {
const tblName = comp.componentConfig?.selectedTable ?? comp.selectedTable ?? "";
const label = comp.componentConfig?.label ?? comp.label ?? comp.id;
return (
<option key={comp.id} value={comp.id}>
{label} ({tblName || comp.id})
</option>
);
})}
</CPSelect>
</CPRow>
)}
{config.dataBinding?.sourceComponentId && (
<CPRow label="가져올 컬럼">
{loadingColumns ? (
<span
style={{
fontSize: 11,
color: "var(--cp-text-muted)",
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
<Loader2 size={11} className="animate-spin" /> ...
</span>
) : tableColumns.length === 0 ? (
<CPText
value={sourceColumn}
onChange={(v) =>
onChange({ ...config, dataBinding: { ...(config.dataBinding ?? {}), sourceColumn: v } })
}
placeholder="컬럼명 직접 입력"
/>
) : (
<CPSelect
value={sourceColumn}
onChange={(v) =>
onChange({ ...config, dataBinding: { ...(config.dataBinding ?? {}), sourceColumn: v } })
}
>
<option value=""> </option>
{tableColumns.map((col) => (
<option key={col} value={col}>
{col}
</option>
))}
</CPSelect>
)}
</CPRow>
)}
{selectedTableName && sourceColumn && (
<CPRow label="현재 바인딩">
<CPBindChip
table={selectedTableName}
column={sourceColumn}
onClear={() => onChange({ ...config, dataBinding: undefined })}
/>
</CPRow>
)}
</>
)}
</CPSection>
);
}
export default V2InputConfigPanel;