29682e5b63
- _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>
1134 lines
37 KiB
TypeScript
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;
|