refactor(components): consolidate canonical input cleanup
This commit is contained in:
@@ -4072,21 +4072,14 @@ export default function ScreenDesigner({
|
||||
// 웹타입별 기본 비율 매핑 (12컬럼 기준 비율)
|
||||
const gridColumnsRatioMap: Record<string, number> = {
|
||||
// 입력 컴포넌트 (INPUT 카테고리)
|
||||
"text-input": 4 / 12, // 텍스트 입력 (33%)
|
||||
"number-input": 2 / 12, // 숫자 입력 (16.67%)
|
||||
// 텍스트/숫자/날짜/textarea/select/checkbox 는 canonical input 으로 흡수됨 (Phase E).
|
||||
// radio-basic / toggle-switch 는 Phase F.1 에서 canonical input 으로 흡수됨.
|
||||
"email-input": 4 / 12, // 이메일 입력 (33%)
|
||||
"tel-input": 3 / 12, // 전화번호 입력 (25%)
|
||||
"date-input": 3 / 12, // 날짜 입력 (25%)
|
||||
"datetime-input": 4 / 12, // 날짜시간 입력 (33%)
|
||||
"time-input": 2 / 12, // 시간 입력 (16.67%)
|
||||
"textarea-basic": 6 / 12, // 텍스트 영역 (50%)
|
||||
"select-basic": 3 / 12, // 셀렉트 (25%)
|
||||
"checkbox-basic": 2 / 12, // 체크박스 (16.67%)
|
||||
"radio-basic": 3 / 12, // 라디오 (25%)
|
||||
"file-basic": 4 / 12, // 파일 (33%)
|
||||
"file-upload": 4 / 12, // 파일 업로드 (33%)
|
||||
"slider-basic": 3 / 12, // 슬라이더 (25%)
|
||||
"toggle-switch": 2 / 12, // 토글 스위치 (16.67%)
|
||||
"repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%)
|
||||
|
||||
// 표시 컴포넌트 (DISPLAY 카테고리)
|
||||
|
||||
@@ -3181,7 +3181,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
// 소스/타겟과 다른 컴포넌트 중 값을 제공할 수 있는 타입
|
||||
return ["conditional-container", "select-basic", "select", "combobox"].some((t) =>
|
||||
return ["conditional-container", "select", "combobox"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
})
|
||||
|
||||
@@ -387,7 +387,7 @@ export const DataTab: React.FC<DataTabProps> = ({
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["conditional-container", "select-basic", "select", "combobox"].some((t) =>
|
||||
return ["conditional-container", "select", "combobox"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
})
|
||||
|
||||
@@ -73,10 +73,8 @@ interface LangText {
|
||||
}
|
||||
|
||||
// 입력 가능한 폼 컴포넌트 타입 목록
|
||||
// text/number/date 등 6종 component id 는 Phase E 에서 canonical "input" 으로 흡수.
|
||||
const INPUT_COMPONENT_TYPES = new Set([
|
||||
"text-input",
|
||||
"number-input",
|
||||
"date-input",
|
||||
"datetime-input",
|
||||
"select-input",
|
||||
"textarea-input",
|
||||
|
||||
@@ -96,11 +96,8 @@ export function ComponentsPanel({
|
||||
const componentsByCategory = useMemo(() => {
|
||||
// 숨길 컴포넌트 ID 목록
|
||||
const hiddenComponents = [
|
||||
// 기본 입력 컴포넌트 (테이블 컬럼 드래그 시 자동 생성)
|
||||
"text-input",
|
||||
"number-input",
|
||||
"date-input",
|
||||
"textarea-basic",
|
||||
// 기본 입력 6종 (text-input/number-input/date-input/select-basic/
|
||||
// checkbox-basic/textarea-basic) — Phase E 에서 canonical input 으로 흡수, 등록/폴더 모두 삭제.
|
||||
// canonical input 으로 대체됨 (Phase D.4)
|
||||
"image-widget", // → canonical input (type='file', format='image')
|
||||
"file-upload", // → canonical input (type='file', format='file')
|
||||
@@ -127,8 +124,7 @@ export function ComponentsPanel({
|
||||
"accordion-basic", // 아코디언 컴포넌트
|
||||
"conditional-container", // 조건부 컨테이너
|
||||
"universal-form-modal", // 범용 폼 모달
|
||||
// 옛 v2-media — Phase D.4 에서 canonical input 으로 흡수. hidden 유지 (생성 차단)
|
||||
"v2-media",
|
||||
// v2-media — Phase D.4 에서 canonical input 으로 흡수, 폴더/렌더러 삭제.
|
||||
// 플로우 위젯 숨김 처리
|
||||
"flow-widget",
|
||||
// 선택 항목 상세입력 - 거래처 품목 추가 등에서 사용
|
||||
@@ -158,19 +154,13 @@ export function ComponentsPanel({
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase B-1): 필드 입력 20+종 → `input`
|
||||
// (V2 입력/선택은 Phase D.2 에서 완전 폐기 — 등록/생성 경로 자체 삭제, hidden 목록에 둘 필요 없음)
|
||||
"v2-category-manager", // → input (type='select', 추후 category 특화)
|
||||
"v2-file-upload", // → canonical input (type='file', Phase D.4)
|
||||
// v2-media 는 이미 위에서 hidden 처리됨
|
||||
// v2-numbering-rule: 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체
|
||||
// v2-file-upload / v2-media / v2-numbering-rule 는 Phase D.4 / D.5 / 2026-05-11
|
||||
// 폐기. 폴더/렌더러 삭제 — hidden 목록에 둘 필요 없음.
|
||||
"v2-location-swap-selector", // → input (type='entity')
|
||||
// 아래 legacy 들은 이미 상단 "기본 입력 컴포넌트" 섹션에서 hidden:
|
||||
// text-input, number-input, date-input, textarea-basic, image-widget,
|
||||
// entity-search-input, autocomplete-search-input, file-upload (일부)
|
||||
// 이미 리스트에 없는 것만 추가:
|
||||
"select-basic", // → input (type='select')
|
||||
"checkbox-basic", // → input (type='checkbox')
|
||||
"radio-basic", // → input (type='select', radio 렌더)
|
||||
"toggle-switch", // → input (type='checkbox', toggle 렌더)
|
||||
"slider-basic", // → input (type='number', slider 렌더)
|
||||
// 아래 legacy 들은 이미 상단 섹션에서 hidden / 또는 Phase E·F.1 에서 폴더 삭제:
|
||||
// text-input, number-input, date-input, textarea-basic, select-basic, checkbox-basic
|
||||
// radio-basic, toggle-switch (Phase F.1)
|
||||
// image-widget, entity-search-input, autocomplete-search-input, file-upload (일부)
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats`
|
||||
"v2-aggregation-widget", // → stats
|
||||
"v2-status-count", // → stats
|
||||
|
||||
@@ -394,15 +394,11 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
"boolean",
|
||||
"file",
|
||||
"autocomplete",
|
||||
"text-input",
|
||||
"number-input",
|
||||
"date-input",
|
||||
"textarea-basic",
|
||||
"select-basic",
|
||||
"checkbox-basic",
|
||||
"radio-basic",
|
||||
"entity-search-input",
|
||||
"autocomplete-search-input",
|
||||
// 입력 6종(text/number/date/select/checkbox/textarea) 은 Phase E,
|
||||
// radio-basic/toggle-switch 는 Phase F.1 에서 canonical input 으로 흡수,
|
||||
// 목록에서 제거됨.
|
||||
// 새로운 통합 입력 컴포넌트 (옛 V2 입력/선택은 input canonical 로 흡수되어 목록에서 제거됨)
|
||||
"input",
|
||||
"v2-entity-select",
|
||||
|
||||
@@ -23,7 +23,7 @@ export const WebTypeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ web_type
|
||||
// webType을 소문자로 변환하고 기본 타입 추출
|
||||
const normalizedWebType = String(webType || "").toLowerCase();
|
||||
|
||||
// 기본 타입 추출 (예: "radio-horizontal" -> "radio", "checkbox-basic" -> "checkbox")
|
||||
// 기본 타입 추출 (예: "radio-horizontal" -> "radio", "select-multi" -> "select")
|
||||
const getBaseType = (type: string): string => {
|
||||
if (type.startsWith("radio")) return "radio";
|
||||
if (type.startsWith("checkbox")) return "checkbox";
|
||||
|
||||
@@ -98,12 +98,14 @@ const FORMATS_BY_TYPE: Record<Type, CPFormatItem[]> = {
|
||||
{ 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: "₩" },
|
||||
@@ -253,16 +255,8 @@ function resolveTriple(
|
||||
return { kind: "attach", type: "file", format: "any" };
|
||||
}
|
||||
|
||||
// 2.5 옛 레거시 컴포넌트 ID → triple default
|
||||
// config.kind/type 가 없을 때만 도달. 명시된 config 가 있으면 step 0 에서 이미 return.
|
||||
switch (componentType) {
|
||||
case "text-input": return { kind: "input", type: "text", format: "free" };
|
||||
case "number-input": return { kind: "input", type: "number", format: "int" };
|
||||
case "date-input": return { kind: "input", type: "date", format: "date" };
|
||||
case "select-basic": return { kind: "choice", type: "single", format: "list" };
|
||||
case "checkbox-basic": return { kind: "choice", type: "single", format: "boolean" };
|
||||
case "textarea-basic": return { kind: "input", type: "text", format: "free" };
|
||||
}
|
||||
// 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;
|
||||
|
||||
@@ -386,7 +386,7 @@ Hot Reload 제어 (비동기):
|
||||
💡 사용 예시:
|
||||
__COMPONENT_REGISTRY__.search("input")
|
||||
__COMPONENT_REGISTRY__.byCategory("input")
|
||||
__COMPONENT_REGISTRY__.get("text-input")
|
||||
__COMPONENT_REGISTRY__.get("input")
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -372,10 +372,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||
"v2-button-primary": "button", "button-primary": "button",
|
||||
// search
|
||||
"v2-table-search-widget": "search", "table-search-widget": "search",
|
||||
// input (V2 입력/선택은 Phase D.2 에서 완전 폐기 — alias 제거)
|
||||
"text-input": "input", "number-input": "input", "date-input": "input",
|
||||
"select-basic": "input", "checkbox-basic": "input", "textarea-basic": "input",
|
||||
"slider-basic": "input", "radio-basic": "input", "toggle-switch": "input",
|
||||
// input (V2 입력/선택 Phase D.2, 입력 6종 Phase E, radio-basic/toggle-switch
|
||||
// Phase F.1 폐기 — alias 모두 제거)
|
||||
// stats
|
||||
"v2-aggregation-widget": "stats", "aggregation-widget": "stats",
|
||||
"v2-status-count": "stats",
|
||||
|
||||
@@ -167,25 +167,31 @@ export const DynamicWebTypeRenderer: React.FC<DynamicWebTypeRendererProps> = ({
|
||||
|
||||
// 파일 웹타입 — 위 통합 분기에서 처리됨. fallback 도 canonical input 으로 일원화.
|
||||
|
||||
// 텍스트 입력 웹타입들
|
||||
if (["text", "email", "password", "tel"].includes(webType)) {
|
||||
const { TextInputComponent } = require("@/lib/registry/components/text-input/TextInputComponent");
|
||||
// console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`);
|
||||
return <TextInputComponent {...props} {...finalProps} />;
|
||||
}
|
||||
|
||||
// 숫자 입력 웹타입들
|
||||
if (["number", "decimal"].includes(webType)) {
|
||||
const { NumberInputComponent } = require("@/lib/registry/components/number-input/NumberInputComponent");
|
||||
// console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`);
|
||||
return <NumberInputComponent {...props} {...finalProps} />;
|
||||
}
|
||||
|
||||
// 날짜 입력 웹타입들
|
||||
if (["date", "datetime", "time"].includes(webType)) {
|
||||
const { DateInputComponent } = require("@/lib/registry/components/date-input/DateInputComponent");
|
||||
// console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`);
|
||||
return <DateInputComponent {...props} {...finalProps} />;
|
||||
// 텍스트 / 숫자 / 날짜 / 시간 입력 — Phase E (2026-05-12) 부터 canonical input 으로 라우팅.
|
||||
// 옛 text-input / number-input / date-input 폴더는 흡수 후 삭제되었다.
|
||||
const TEXT_LIKE = ["text", "email", "password", "tel", "url"];
|
||||
const NUMBER_LIKE = ["number", "decimal"];
|
||||
const DATE_LIKE = ["date", "datetime", "time", "daterange"];
|
||||
if (
|
||||
TEXT_LIKE.includes(webType) ||
|
||||
NUMBER_LIKE.includes(webType) ||
|
||||
DATE_LIKE.includes(webType)
|
||||
) {
|
||||
const { InputComponent } = require("@/lib/registry/components/input/InputComponent");
|
||||
const inputCfg: Record<string, any> = NUMBER_LIKE.includes(webType)
|
||||
? { type: "number", format: webType === "decimal" ? "decimal" : "int" }
|
||||
: DATE_LIKE.includes(webType)
|
||||
? { type: webType === "daterange" ? "daterange" : (webType as "date" | "datetime" | "time") }
|
||||
: {
|
||||
type: "text",
|
||||
format:
|
||||
webType === "email" ? "email" :
|
||||
webType === "password" ? "password" :
|
||||
webType === "tel" ? "phone" :
|
||||
webType === "url" ? "url" :
|
||||
"free",
|
||||
};
|
||||
return <InputComponent {...props} {...finalProps} config={{ ...inputCfg, ...mergedConfig }} />;
|
||||
}
|
||||
|
||||
// 카테고리 셀렉트 웹타입
|
||||
@@ -197,7 +203,6 @@ export const DynamicWebTypeRenderer: React.FC<DynamicWebTypeRendererProps> = ({
|
||||
|
||||
// 기본 폴백: Input 컴포넌트 사용
|
||||
const { Input } = require("@/components/ui/input");
|
||||
const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
|
||||
// console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
|
||||
const safeFallbackProps = filterDOMProps(props);
|
||||
return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...safeFallbackProps} />;
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
import { FolderTree } from "lucide-react";
|
||||
import { CategoryWidget } from "@/components/screen/widgets/CategoryWidget";
|
||||
|
||||
/**
|
||||
* 카테고리 관리 컴포넌트 정의
|
||||
* - 메뉴 스코프 기반 카테고리 값 관리
|
||||
* - 좌우 분할 UI (컬럼 목록 + 값 관리)
|
||||
*/
|
||||
export const categoryManagerDefinition = {
|
||||
// 기본 정보
|
||||
id: "category-manager",
|
||||
name: "카테고리 관리",
|
||||
name_eng: "Category Manager",
|
||||
description: "메뉴 스코프 기반 카테고리 값 관리 (좌우 분할 UI)",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
web_type: "category" as any,
|
||||
|
||||
// 컴포넌트
|
||||
component: CategoryWidget,
|
||||
|
||||
// 아이콘
|
||||
icon: FolderTree,
|
||||
|
||||
// 기본 설정
|
||||
default_config: {},
|
||||
|
||||
// 기본 크기
|
||||
default_size: {
|
||||
width: 1000,
|
||||
height: 600,
|
||||
},
|
||||
|
||||
// 태그
|
||||
tags: ["category", "reference", "manager", "scope"],
|
||||
|
||||
// 작성자
|
||||
author: "system",
|
||||
|
||||
// 속성
|
||||
properties: {
|
||||
menuId: {
|
||||
type: "number",
|
||||
label: "메뉴 ID",
|
||||
description: "현재 화면의 메뉴 ID (자동 설정)",
|
||||
required: true,
|
||||
},
|
||||
tableName: {
|
||||
type: "string",
|
||||
label: "테이블명",
|
||||
description: "현재 화면의 테이블명 (자동 설정)",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
// 특징
|
||||
features: [
|
||||
"메뉴 스코프 기반 카테고리 관리",
|
||||
"좌우 분할 UI (컬럼 목록 + 값 관리)",
|
||||
"실시간 검색 및 필터링",
|
||||
"CRUD 기능 (추가, 수정, 삭제)",
|
||||
"색상 및 아이콘 설정",
|
||||
"계층 구조 지원 (부모-자식)",
|
||||
],
|
||||
|
||||
// 제약사항
|
||||
constraints: {
|
||||
minSize: { width: 800, height: 400 },
|
||||
maxSize: { width: 1400, height: 1000 },
|
||||
},
|
||||
} as ComponentDefinition;
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { CheckboxBasicConfig } from "./types";
|
||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface CheckboxBasicComponentProps extends ComponentRendererProps {
|
||||
config?: CheckboxBasicConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* CheckboxBasic 컴포넌트
|
||||
* checkbox-basic 컴포넌트입니다
|
||||
*/
|
||||
export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as CheckboxBasicConfig;
|
||||
|
||||
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
|
||||
const webType = component.componentConfig?.webType || "checkbox";
|
||||
|
||||
// 상태 관리
|
||||
const [isChecked, setIsChecked] = useState<boolean>(component.value === true || component.value === "true");
|
||||
const [checkedValues, setCheckedValues] = useState<string[]>([]);
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (checked: boolean) => {
|
||||
setIsChecked(checked);
|
||||
if (component.onChange) {
|
||||
component.onChange(checked);
|
||||
}
|
||||
if (isInteractive && onFormDataChange && component.column_name) {
|
||||
onFormDataChange(component.column_name, checked);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroupChange = (value: string, checked: boolean) => {
|
||||
const newValues = checked ? [...checkedValues, value] : checkedValues.filter((v) => v !== value);
|
||||
setCheckedValues(newValues);
|
||||
if (isInteractive && onFormDataChange && component.column_name) {
|
||||
onFormDataChange(component.column_name, newValues.join(","));
|
||||
}
|
||||
};
|
||||
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(props);
|
||||
|
||||
// 세부 타입별 렌더링
|
||||
const renderCheckboxByWebType = () => {
|
||||
// boolean: On/Off 스위치
|
||||
if (webType === "boolean") {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => handleCheckboxChange(e.target.checked)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"h-6 w-11 rounded-full transition-colors",
|
||||
isChecked ? "bg-primary" : "bg-muted/60",
|
||||
"peer-focus:ring-2 peer-focus:ring-primary/20",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform",
|
||||
isChecked && "translate-x-5",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-foreground">{componentConfig.checkboxLabel || component.text || "스위치"}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// checkbox-group: 여러 체크박스
|
||||
if (webType === "checkbox-group") {
|
||||
const options = componentConfig.options || [
|
||||
{ value: "option1", label: "옵션 1" },
|
||||
{ value: "option2", label: "옵션 2" },
|
||||
{ value: "option3", label: "옵션 3" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={option.value}
|
||||
checked={checkedValues.includes(option.value)}
|
||||
onChange={(e) => handleGroupChange(option.value, e.target.checked)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="border-input text-primary h-4 w-4 rounded focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// checkbox (기본 체크박스)
|
||||
return (
|
||||
<label className="flex h-full w-full cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
required={componentConfig.required || false}
|
||||
onChange={(e) => handleCheckboxChange(e.target.checked)}
|
||||
className="border-input text-primary focus:ring-ring h-4 w-4 rounded"
|
||||
/>
|
||||
<span className="text-sm">{componentConfig.checkboxLabel || component.text || "체크박스"}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (component.style?.labelDisplay ?? true) && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 세부 타입별 UI 렌더링 */}
|
||||
{renderCheckboxByWebType()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CheckboxBasic 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const CheckboxBasicWrapper: React.FC<CheckboxBasicComponentProps> = (props) => {
|
||||
return <CheckboxBasicComponent {...props} />;
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { CheckboxBasicConfig } from "./types";
|
||||
|
||||
export interface CheckboxBasicConfigPanelProps {
|
||||
config: CheckboxBasicConfig;
|
||||
onChange: (config: Partial<CheckboxBasicConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CheckboxBasic 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const CheckboxBasicConfigPanel: React.FC<CheckboxBasicConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof CheckboxBasicConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
checkbox-basic 설정
|
||||
</div>
|
||||
|
||||
{/* checkbox 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { CheckboxBasicDefinition } from "./index";
|
||||
import { CheckboxBasicComponent } from "./CheckboxBasicComponent";
|
||||
|
||||
/**
|
||||
* CheckboxBasic 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class CheckboxBasicRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = CheckboxBasicDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <CheckboxBasicComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// checkbox 타입 특화 속성 처리
|
||||
protected getCheckboxBasicProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// checkbox 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 checkbox 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
CheckboxBasicRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
CheckboxBasicRenderer.enableHotReload();
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
# CheckboxBasic 컴포넌트
|
||||
|
||||
checkbox-basic 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `checkbox-basic`
|
||||
- **카테고리**: input
|
||||
- **웹타입**: checkbox
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { CheckboxBasicComponent } from "@/lib/registry/components/checkbox-basic";
|
||||
|
||||
<CheckboxBasicComponent
|
||||
component={{
|
||||
id: "my-checkbox-basic",
|
||||
type: "widget",
|
||||
webType: "checkbox",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 120, height: 24 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<CheckboxBasicComponent
|
||||
component={{
|
||||
id: "sample-checkbox-basic",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js checkbox-basic --category=input --webType=checkbox`
|
||||
- **경로**: `lib/registry/components/checkbox-basic/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/checkbox-basic)
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckboxBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* CheckboxBasic 컴포넌트 기본 설정
|
||||
*/
|
||||
export const CheckboxBasicDefaultConfig: CheckboxBasicConfig = {
|
||||
placeholder: "입력하세요",
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* CheckboxBasic 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const CheckboxBasicConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { CheckboxBasicWrapper } from "./CheckboxBasicComponent";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { CheckboxBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* CheckboxBasic 컴포넌트 정의
|
||||
* checkbox-basic 컴포넌트입니다
|
||||
*/
|
||||
export const CheckboxBasicDefinition = createComponentDefinition({
|
||||
id: "checkbox-basic",
|
||||
name: "체크박스",
|
||||
name_eng: "CheckboxBasic Component",
|
||||
description: "체크 상태 선택을 위한 체크박스 컴포넌트",
|
||||
category: ComponentCategory.FORM,
|
||||
web_type: "checkbox",
|
||||
component: CheckboxBasicWrapper,
|
||||
default_config: {
|
||||
kind: "choice",
|
||||
type: "single",
|
||||
format: "boolean",
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
default_size: { width: 150, height: 120 }, // 40 * 3 (3개 옵션)
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/checkbox-basic",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { CheckboxBasicConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { CheckboxBasicComponent } from "./CheckboxBasicComponent";
|
||||
export { CheckboxBasicRenderer } from "./CheckboxBasicRenderer";
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* CheckboxBasic 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface CheckboxBasicConfig extends ComponentConfig {
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CheckboxBasic 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface CheckboxBasicProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: CheckboxBasicConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { DateInputConfig } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { AutoGenerationConfig } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface DateInputComponentProps extends ComponentRendererProps {
|
||||
config?: DateInputConfig;
|
||||
value?: any; // 외부에서 전달받는 값
|
||||
auto_generation?: AutoGenerationConfig;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DateInput 컴포넌트
|
||||
* date-input 컴포넌트입니다
|
||||
*/
|
||||
export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
form_data,
|
||||
onFormDataChange,
|
||||
value: externalValue, // 외부에서 전달받은 값
|
||||
auto_generation,
|
||||
hidden,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as DateInputConfig;
|
||||
|
||||
// 🎯 자동생성 상태 관리
|
||||
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
|
||||
|
||||
// 자동생성 설정 (props 우선, 컴포넌트 설정 폴백)
|
||||
const finalAutoGeneration = auto_generation || component.auto_generation;
|
||||
const finalHidden = hidden !== undefined ? hidden : component.hidden;
|
||||
|
||||
// 자동생성 로직
|
||||
useEffect(() => {
|
||||
if (finalAutoGeneration?.enabled) {
|
||||
const n = new Date();
|
||||
const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, "0")}-${String(n.getDate()).padStart(2, "0")}`;
|
||||
setAutoGeneratedValue(today);
|
||||
|
||||
// 인터랙티브 모드에서 폼 데이터에도 설정
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, today);
|
||||
}
|
||||
}
|
||||
|
||||
// 원래 자동생성 로직 (주석 처리)
|
||||
/*
|
||||
if (finalAutoGeneration?.enabled && finalAutoGeneration.type !== "none") {
|
||||
const fieldName = component.columnName || component.id;
|
||||
const generatedValue = AutoGenerationUtils.generateValue(finalAutoGeneration, fieldName);
|
||||
|
||||
console.log("🎯 DateInputComponent 자동생성 시도:", {
|
||||
componentId: component.id,
|
||||
fieldName,
|
||||
type: finalAutoGeneration.type,
|
||||
options: finalAutoGeneration.options,
|
||||
generatedValue,
|
||||
isInteractive,
|
||||
isDesignMode,
|
||||
});
|
||||
|
||||
if (generatedValue) {
|
||||
console.log("✅ DateInputComponent 자동생성 성공:", generatedValue);
|
||||
setAutoGeneratedValue(generatedValue);
|
||||
|
||||
// 인터랙티브 모드에서 폼 데이터 업데이트
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
const currentValue = formData?.[component.columnName];
|
||||
if (!currentValue) {
|
||||
console.log("📤 DateInputComponent -> onFormDataChange 호출:", component.columnName, generatedValue);
|
||||
onFormDataChange(component.columnName, generatedValue);
|
||||
} else {
|
||||
console.log("⚠️ DateInputComponent 기존 값이 있어서 자동생성 스킵:", currentValue);
|
||||
}
|
||||
} else {
|
||||
console.log("⚠️ DateInputComponent 자동생성 조건 불만족:", {
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
hasColumnName: !!component.columnName,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log("❌ DateInputComponent 자동생성 실패: generatedValue가 null");
|
||||
}
|
||||
} else {
|
||||
console.log("⚠️ DateInputComponent 자동생성 비활성화:", {
|
||||
enabled: finalAutoGeneration?.enabled,
|
||||
type: finalAutoGeneration?.type,
|
||||
});
|
||||
}
|
||||
*/
|
||||
}, [
|
||||
finalAutoGeneration?.enabled,
|
||||
finalAutoGeneration?.type,
|
||||
finalAutoGeneration?.options,
|
||||
component.id,
|
||||
component.columnName,
|
||||
isInteractive,
|
||||
]);
|
||||
|
||||
// 날짜 값 계산 및 디버깅
|
||||
const fieldName = component.columnName || component.id;
|
||||
|
||||
// 값 우선순위: externalValue > formData > autoGeneratedValue > component.value
|
||||
let rawValue: any;
|
||||
if (externalValue !== undefined) {
|
||||
rawValue = externalValue;
|
||||
} else if (isInteractive && form_data && component.columnName && form_data[component.columnName]) {
|
||||
rawValue = form_data[component.columnName];
|
||||
} else if (autoGeneratedValue) {
|
||||
rawValue = autoGeneratedValue;
|
||||
} else {
|
||||
rawValue = component.value;
|
||||
}
|
||||
|
||||
// 날짜 형식 변환 함수 (HTML input[type="date"]는 YYYY-MM-DD 형식만 허용)
|
||||
const formatDateForInput = (dateValue: any): string => {
|
||||
if (!dateValue) return "";
|
||||
|
||||
const dateStr = String(dateValue);
|
||||
|
||||
// 이미 YYYY-MM-DD 형식인 경우
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// YYYY-MM-DD HH:mm:ss 형식에서 날짜 부분만 추출
|
||||
if (/^\d{4}-\d{2}-\d{2}\s/.test(dateStr)) {
|
||||
return dateStr.split(" ")[0];
|
||||
}
|
||||
|
||||
// YYYY/MM/DD 형식
|
||||
if (/^\d{4}\/\d{2}\/\d{2}$/.test(dateStr)) {
|
||||
return dateStr.replace(/\//g, "-");
|
||||
}
|
||||
|
||||
// MM/DD/YYYY 형식
|
||||
if (/^\d{2}\/\d{2}\/\d{4}$/.test(dateStr)) {
|
||||
const [month, day, year] = dateStr.split("/");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// DD-MM-YYYY 형식
|
||||
if (/^\d{2}-\d{2}-\d{4}$/.test(dateStr)) {
|
||||
const [day, month, year] = dateStr.split("-");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// ISO 8601 날짜 (2023-12-31T00:00:00.000Z 등)
|
||||
// 🆕 UTC 시간을 로컬 시간으로 변환하여 날짜 추출 (타임존 이슈 해결)
|
||||
if (/^\d{4}-\d{2}-\d{2}T/.test(dateStr)) {
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 다른 형식의 날짜 문자열이나 Date 객체 처리
|
||||
try {
|
||||
const date = new Date(dateValue);
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn("🚨 DateInputComponent - 유효하지 않은 날짜:", dateValue);
|
||||
return "";
|
||||
}
|
||||
|
||||
// YYYY-MM-DD 형식으로 변환 (로컬 시간대 사용)
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const formattedDate = `${year}-${month}-${day}`;
|
||||
|
||||
console.log("📅 날짜 형식 변환:", {
|
||||
원본: dateValue,
|
||||
변환후: formattedDate,
|
||||
});
|
||||
|
||||
return formattedDate;
|
||||
} catch (error) {
|
||||
console.error("🚨 DateInputComponent - 날짜 변환 오류:", error, "원본:", dateValue);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const formattedValue = formatDateForInput(rawValue);
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed hsl(var(--border))";
|
||||
componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig: _componentConfig,
|
||||
component: _component,
|
||||
isSelected: _isSelected,
|
||||
onClick: _onClick,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(domProps);
|
||||
|
||||
// webType에 따른 실제 input type 결정
|
||||
const webType = component.componentConfig?.webType || "date";
|
||||
const inputType = (() => {
|
||||
switch (webType) {
|
||||
case "datetime":
|
||||
return "datetime-local";
|
||||
case "time":
|
||||
return "time";
|
||||
case "month":
|
||||
return "month";
|
||||
case "year":
|
||||
return "number";
|
||||
case "date":
|
||||
default:
|
||||
return "date";
|
||||
}
|
||||
})();
|
||||
|
||||
// daterange 시작일/종료일 분리 (최상위에서 계산)
|
||||
const [dateRangeStart, dateRangeEnd] = React.useMemo(() => {
|
||||
if (webType === "daterange" && typeof rawValue === "string" && rawValue.includes("~")) {
|
||||
return rawValue.split("~").map((d) => d.trim());
|
||||
}
|
||||
return ["", ""];
|
||||
}, [webType, rawValue]);
|
||||
|
||||
// 입력 필드에 직접 적용할 폰트 크기
|
||||
const inputFontSize = component.style?.fontSize;
|
||||
|
||||
// daterange 타입 전용 UI
|
||||
if (webType === "daterange") {
|
||||
return (
|
||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-2">
|
||||
{/* 시작일 */}
|
||||
<input
|
||||
type="date"
|
||||
value={dateRangeStart}
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const newStartDate = e.target.value;
|
||||
const newValue = `${newStartDate} ~ ${dateRangeEnd}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
|
||||
{/* 구분자 */}
|
||||
<span className="text-muted-foreground text-base font-medium">~</span>
|
||||
|
||||
{/* 종료일 */}
|
||||
<input
|
||||
type="date"
|
||||
value={dateRangeEnd}
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const newEndDate = e.target.value;
|
||||
const newValue = `${dateRangeStart} ~ ${newEndDate}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// year 타입 전용 UI (number input with YYYY format)
|
||||
if (webType === "year") {
|
||||
return (
|
||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="number"
|
||||
value={rawValue}
|
||||
placeholder="YYYY"
|
||||
min="1900"
|
||||
max="2100"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const year = e.target.value;
|
||||
if (year.length <= 4) {
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, year);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input
|
||||
type={inputType}
|
||||
value={formattedValue}
|
||||
placeholder={
|
||||
finalAutoGeneration?.enabled
|
||||
? `자동생성: ${AutoGenerationUtils.getTypeDescription(finalAutoGeneration.type)}`
|
||||
: componentConfig.placeholder || ""
|
||||
}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
||||
className={cn(
|
||||
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
console.log("🎯 DateInputComponent onChange 호출:", {
|
||||
componentId: component.id,
|
||||
columnName: component.columnName,
|
||||
newValue,
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
hasOnChange: !!props.onChange,
|
||||
});
|
||||
|
||||
// isInteractive 모드에서는 formData 업데이트
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
console.log(`📤 DateInputComponent -> onFormDataChange 호출: ${component.columnName} = "${newValue}"`);
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
// 디자인 모드에서는 component.onChange 호출
|
||||
else if (component.onChange) {
|
||||
console.log(`📤 DateInputComponent -> component.onChange 호출: ${newValue}`);
|
||||
component.onChange(newValue);
|
||||
}
|
||||
// props.onChange가 있으면 호출 (호환성)
|
||||
else if (props.onChange) {
|
||||
console.log(`📤 DateInputComponent -> props.onChange 호출: ${newValue}`);
|
||||
props.onChange(newValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* DateInput 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const DateInputWrapper: React.FC<DateInputComponentProps> = (props) => {
|
||||
return <DateInputComponent {...props} />;
|
||||
};
|
||||
@@ -1,258 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DateInputConfig } from "./types";
|
||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
|
||||
export interface DateInputConfigPanelProps {
|
||||
config: DateInputConfig;
|
||||
onChange: (config: Partial<DateInputConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DateInput 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const DateInputConfigPanel: React.FC<DateInputConfigPanelProps> = ({ config, onChange }) => {
|
||||
const handleChange = (key: keyof DateInputConfig, value: any) => {
|
||||
console.log("🔧 DateInputConfigPanel.handleChange:", { key, value });
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">date-input 설정</div>
|
||||
|
||||
{/* date 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 숨김 기능 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hidden">숨김</Label>
|
||||
<Checkbox
|
||||
id="hidden"
|
||||
checked={config.hidden || false}
|
||||
onCheckedChange={(checked) => handleChange("hidden", checked)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">편집기에서는 연하게 보이지만 실제 화면에서는 숨겨집니다</p>
|
||||
</div>
|
||||
|
||||
{/* 자동생성 기능 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="autoGeneration">자동생성</Label>
|
||||
<Checkbox
|
||||
id="autoGeneration"
|
||||
checked={config.autoGeneration?.enabled || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const newAutoGeneration: AutoGenerationConfig = {
|
||||
...config.autoGeneration,
|
||||
enabled: checked as boolean,
|
||||
type: config.autoGeneration?.type || "current_time",
|
||||
};
|
||||
handleChange("autoGeneration", newAutoGeneration);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.autoGeneration?.enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="autoGenerationType">자동생성 타입</Label>
|
||||
<Select
|
||||
value={config.autoGeneration?.type || "current_time"}
|
||||
onValueChange={(value: AutoGenerationType) => {
|
||||
const newAutoGeneration: AutoGenerationConfig = {
|
||||
...config.autoGeneration,
|
||||
type: value,
|
||||
options: value === "current_time" ? { format: "date" } : {},
|
||||
};
|
||||
handleChange("autoGeneration", newAutoGeneration);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="current_time">현재 날짜/시간</SelectItem>
|
||||
<SelectItem value="uuid">UUID</SelectItem>
|
||||
<SelectItem value="current_user">현재 사용자</SelectItem>
|
||||
<SelectItem value="sequence">순차 번호</SelectItem>
|
||||
<SelectItem value="random_string">랜덤 문자열</SelectItem>
|
||||
<SelectItem value="random_number">랜덤 숫자</SelectItem>
|
||||
<SelectItem value="company_code">회사 코드</SelectItem>
|
||||
<SelectItem value="department">부서 코드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{AutoGenerationUtils.getTypeDescription(config.autoGeneration?.type || "current_time")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{config.autoGeneration?.type === "current_time" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateFormat">날짜 형식</Label>
|
||||
<Select
|
||||
value={config.autoGeneration?.options?.format || "date"}
|
||||
onValueChange={(value) => {
|
||||
const newAutoGeneration: AutoGenerationConfig = {
|
||||
...config.autoGeneration!,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
format: value,
|
||||
},
|
||||
};
|
||||
handleChange("autoGeneration", newAutoGeneration);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date">날짜만 (YYYY-MM-DD)</SelectItem>
|
||||
<SelectItem value="datetime">날짜+시간 (YYYY-MM-DD HH:mm:ss)</SelectItem>
|
||||
<SelectItem value="time">시간만 (HH:mm:ss)</SelectItem>
|
||||
<SelectItem value="timestamp">타임스탬프</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.autoGeneration?.type === "sequence" ||
|
||||
config.autoGeneration?.type === "random_string" ||
|
||||
config.autoGeneration?.type === "random_number") && (
|
||||
<>
|
||||
{config.autoGeneration?.type === "sequence" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startValue">시작값</Label>
|
||||
<Input
|
||||
id="startValue"
|
||||
type="number"
|
||||
value={config.autoGeneration?.options?.startValue || 1}
|
||||
onChange={(e) => {
|
||||
const newAutoGeneration: AutoGenerationConfig = {
|
||||
...config.autoGeneration!,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
startValue: parseInt(e.target.value) || 1,
|
||||
},
|
||||
};
|
||||
handleChange("autoGeneration", newAutoGeneration);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.autoGeneration?.type === "random_string" ||
|
||||
config.autoGeneration?.type === "random_number") && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="length">길이</Label>
|
||||
<Input
|
||||
id="length"
|
||||
type="number"
|
||||
value={
|
||||
config.autoGeneration?.options?.length ||
|
||||
(config.autoGeneration?.type === "random_string" ? 8 : 6)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newAutoGeneration: AutoGenerationConfig = {
|
||||
...config.autoGeneration!,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
length: parseInt(e.target.value) || 8,
|
||||
},
|
||||
};
|
||||
handleChange("autoGeneration", newAutoGeneration);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prefix">접두사</Label>
|
||||
<Input
|
||||
id="prefix"
|
||||
value={config.autoGeneration?.options?.prefix || ""}
|
||||
onChange={(e) => {
|
||||
const newAutoGeneration: AutoGenerationConfig = {
|
||||
...config.autoGeneration!,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
prefix: e.target.value,
|
||||
},
|
||||
};
|
||||
handleChange("autoGeneration", newAutoGeneration);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="suffix">접미사</Label>
|
||||
<Input
|
||||
id="suffix"
|
||||
value={config.autoGeneration?.options?.suffix || ""}
|
||||
onChange={(e) => {
|
||||
const newAutoGeneration: AutoGenerationConfig = {
|
||||
...config.autoGeneration!,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
suffix: e.target.value,
|
||||
},
|
||||
};
|
||||
handleChange("autoGeneration", newAutoGeneration);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="rounded bg-muted p-2 text-xs">
|
||||
<strong>미리보기:</strong> {AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { DateInputDefinition } from "./index";
|
||||
import { DateInputComponent } from "./DateInputComponent";
|
||||
|
||||
/**
|
||||
* DateInput 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class DateInputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = DateInputDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
console.log("🎯 DateInputRenderer.render() 호출:", {
|
||||
componentId: this.props.component?.id,
|
||||
autoGeneration: this.props.autoGeneration,
|
||||
componentAutoGeneration: this.props.component?.autoGeneration,
|
||||
allProps: Object.keys(this.props),
|
||||
});
|
||||
|
||||
return <DateInputComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// date 타입 특화 속성 처리
|
||||
protected getDateInputProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// date 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 date 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
DateInputRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
DateInputRenderer.enableHotReload();
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
# DateInput 컴포넌트
|
||||
|
||||
date-input 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `date-input`
|
||||
- **카테고리**: input
|
||||
- **웹타입**: date
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { DateInputComponent } from "@/lib/registry/components/date-input";
|
||||
|
||||
<DateInputComponent
|
||||
component={{
|
||||
id: "my-date-input",
|
||||
type: "widget",
|
||||
webType: "date",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 180, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<DateInputComponent
|
||||
component={{
|
||||
id: "sample-date-input",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js date-input --category=input --webType=date`
|
||||
- **경로**: `lib/registry/components/date-input/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/date-input)
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { DateInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
* DateInput 컴포넌트 기본 설정
|
||||
*/
|
||||
export const DateInputDefaultConfig: DateInputConfig = {
|
||||
placeholder: "입력하세요",
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* DateInput 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const DateInputConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { DateInputComponent } from "./DateInputComponent";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { DateInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
* DateInput 컴포넌트 정의
|
||||
* date-input 컴포넌트입니다
|
||||
*/
|
||||
export const DateInputDefinition = createComponentDefinition({
|
||||
id: "date-input",
|
||||
name: "날짜 선택",
|
||||
name_eng: "DateInput Component",
|
||||
description: "날짜 선택을 위한 날짜 선택기 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
web_type: "date",
|
||||
component: DateInputComponent,
|
||||
default_config: {
|
||||
kind: "input",
|
||||
type: "date",
|
||||
format: "date",
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
default_size: { width: 220, height: 40 },
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/date-input",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { DateInputConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { DateInputComponent } from "./DateInputComponent";
|
||||
export { DateInputRenderer } from "./DateInputRenderer";
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
import { AutoGenerationConfig } from "@/types/screen";
|
||||
|
||||
/**
|
||||
* DateInput 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface DateInputConfig extends ComponentConfig {
|
||||
// date 관련 설정
|
||||
placeholder?: string;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
helperText?: string;
|
||||
|
||||
// 자동생성 및 숨김 기능
|
||||
autoGeneration?: AutoGenerationConfig;
|
||||
hidden?: boolean;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DateInput 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface DateInputProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: DateInputConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -16,18 +16,10 @@ import { initializeHotReload } from "../utils/hotReload";
|
||||
* CLI로 생성된 컴포넌트들은 여기에 import만 추가하면 자동으로 등록됩니다
|
||||
*/
|
||||
|
||||
// 기본 입력 컴포넌트들 (v2 버전 없음 - 유지)
|
||||
import "./text-input/TextInputRenderer";
|
||||
import "./textarea-basic/TextareaBasicRenderer";
|
||||
import "./number-input/NumberInputRenderer";
|
||||
import "./select-basic/SelectBasicRenderer";
|
||||
import "./checkbox-basic/CheckboxBasicRenderer";
|
||||
import "./radio-basic/RadioBasicRenderer";
|
||||
import "./date-input/DateInputRenderer";
|
||||
// file-upload / image-widget / image-display renderer — Phase D.5 폐기.
|
||||
// canonical input (FilePicker) 으로 일원화. auto-register import 제거.
|
||||
import "./slider-basic/SliderBasicRenderer";
|
||||
import "./toggle-switch/ToggleSwitchRenderer";
|
||||
// 기본 입력 컴포넌트들 — Phase E (text/number/date/select/checkbox/textarea 6종)
|
||||
// 와 Phase F.1 (radio-basic / toggle-switch) 에서 canonical input 으로 흡수,
|
||||
// 폴더/렌더러 모두 삭제. file-upload/image-widget/image-display 는 Phase D.5
|
||||
// 에서 canonical input (FilePicker) 으로 흡수.
|
||||
import "./accordion-basic/AccordionBasicRenderer"; // 컴포넌트 패널에서만 숨김
|
||||
import "./split-panel-layout2/SplitPanelLayout2Renderer"; // 분할 패널 레이아웃 v2
|
||||
import "./domain/map/MapRenderer";
|
||||
|
||||
@@ -39,6 +39,60 @@ function isValidType(v: unknown): v is InputFieldType {
|
||||
return typeof v === "string" && (VALID_TYPES as string[]).includes(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 입력 mask 적용. `#`/`0` = 숫자, `A` = 영문, `*` = 임의 문자, 그 외는 literal.
|
||||
* 예: `###-####` + "1234567" → "123-4567"
|
||||
*
|
||||
* 사용자가 친 값에서 mask 와 매칭되지 않는 문자는 건너뛰고, literal 자리는 항상 그대로 출력.
|
||||
* 사용자가 literal 위치에 literal 과 동일한 문자를 친 경우는 1회 소비해서 정렬을 맞춘다.
|
||||
*/
|
||||
function applyInputMask(value: string, mask: string): string {
|
||||
if (!mask) return value;
|
||||
let out = "";
|
||||
let vi = 0;
|
||||
for (let mi = 0; mi < mask.length; mi++) {
|
||||
const m = mask[mi];
|
||||
if (vi >= value.length) break;
|
||||
if (m === "#" || m === "0") {
|
||||
while (vi < value.length && !/[0-9]/.test(value[vi])) vi++;
|
||||
if (vi >= value.length) break;
|
||||
out += value[vi++];
|
||||
} else if (m === "A") {
|
||||
while (vi < value.length && !/[A-Za-z]/.test(value[vi])) vi++;
|
||||
if (vi >= value.length) break;
|
||||
out += value[vi++];
|
||||
} else if (m === "*") {
|
||||
out += value[vi++];
|
||||
} else {
|
||||
out += m;
|
||||
if (value[vi] === m) vi++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
||||
function normalizeHex(v: unknown, fallback = "#000000"): string {
|
||||
if (typeof v === "string" && HEX_PATTERN.test(v)) return v;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 색상 텍스트 입력 sanitizer — 항상 `#rrggbb` 6자리 hex 로 강제.
|
||||
* - 선행 `#` 제거
|
||||
* - hex 가 아닌 문자 제거
|
||||
* - 6자 초과는 잘라냄
|
||||
* - 모자란 자리는 `0` 으로 패딩
|
||||
* - 결과는 소문자 `#rrggbb`
|
||||
*/
|
||||
function sanitizeHexInput(raw: string): string {
|
||||
const stripped = raw.startsWith("#") ? raw.slice(1) : raw;
|
||||
const hexOnly = stripped.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
|
||||
const trimmed = hexOnly.slice(0, 6);
|
||||
const padded = trimmed.padEnd(6, "0");
|
||||
return `#${padded}`;
|
||||
}
|
||||
|
||||
export interface InputComponentProps extends ComponentRendererProps {
|
||||
config?: InputConfig;
|
||||
}
|
||||
@@ -82,6 +136,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
if (typeof p.rows === "number") fromProps.rows = p.rows;
|
||||
if (typeof p.accept === "string") fromProps.accept = p.accept;
|
||||
if (typeof p.multiple === "boolean") fromProps.multiple = p.multiple;
|
||||
if (typeof p.mask === "string") fromProps.mask = p.mask;
|
||||
|
||||
const componentConfig = {
|
||||
...config,
|
||||
@@ -90,10 +145,12 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
...fromProps,
|
||||
} as InputConfig;
|
||||
|
||||
// type 결정 — InvField canonical (kind/type/format) 우선, 옛 inputType/web_type 폴백.
|
||||
// type 결정 — InvField canonical (kind/type/format) 우선, 옛 inputType/webType 폴백.
|
||||
// webType 은 camelCase / snake_case 두 표기 모두 흡수.
|
||||
const rawType: any = componentConfig.type;
|
||||
const inputType: any = (componentConfig as any).inputType;
|
||||
const webType: any = (componentConfig as any).web_type;
|
||||
const webType: any =
|
||||
(componentConfig as any).webType ?? (componentConfig as any).web_type;
|
||||
const fmt: any = (componentConfig as any).format;
|
||||
const type: InputFieldType = (() => {
|
||||
// ─── InvField (canonical) type 매핑 ─────────────────────────
|
||||
@@ -114,10 +171,14 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
if (fmt === "range") return "daterange";
|
||||
return "date";
|
||||
}
|
||||
// slider / color — 옛 inputType/webType 진입점. 각각 number/text 로 라우팅 후 format 으로 분기.
|
||||
// rawType 이 text/number 같은 유효값으로 같이 들어와도 명시 inputType/webType 을 존중한다.
|
||||
if (inputType === "slider" || webType === "slider") return "number";
|
||||
if (inputType === "color" || webType === "color") return "text";
|
||||
// text/number/file/checkbox 등 InputFieldType 와 동일한 키는 그대로
|
||||
if (isValidType(rawType)) return rawType;
|
||||
|
||||
// ─── 옛 inputType / web_type 폴백 (점진 폐기 영역) ───────────
|
||||
// ─── 옛 inputType / webType 폴백 (점진 폐기 영역) ───────────
|
||||
if (isValidType(inputType)) return inputType;
|
||||
// (옛 V2 선택 rawType 분기 — Phase D.2 에서 제거. 더 이상 옛 V2 선택 컴포넌트가 생성/렌더되지 않음)
|
||||
// V2-era 의 inputType="numbering" → code (autonum 의 옛 키)
|
||||
@@ -290,6 +351,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
onRefresh: _15,
|
||||
onClose: _16,
|
||||
web_type: _17,
|
||||
webType: _17a,
|
||||
autoGeneration: _18,
|
||||
isInteractive: _19,
|
||||
formData: _20,
|
||||
@@ -349,6 +411,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
rows: _69,
|
||||
accept: _70,
|
||||
multiple: _71,
|
||||
mask: _71a,
|
||||
// 기타 noise
|
||||
columnName: _72,
|
||||
fieldKey: _73,
|
||||
@@ -431,7 +494,55 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case "number":
|
||||
case "number": {
|
||||
const numFmt = (componentConfig as any).format;
|
||||
const isSlider =
|
||||
numFmt === "slider" || inputType === "slider" || webType === "slider";
|
||||
if (isSlider) {
|
||||
const minN = typeof componentConfig.min === "number" ? componentConfig.min : 0;
|
||||
const maxN = typeof componentConfig.max === "number" ? componentConfig.max : 100;
|
||||
const stepN = typeof componentConfig.step === "number" ? componentConfig.step : 1;
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value !== ""
|
||||
? Number(value)
|
||||
: NaN;
|
||||
const sliderVal = Number.isFinite(parsed) ? parsed : minN;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "0 8px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={minN}
|
||||
max={maxN}
|
||||
step={stepN}
|
||||
value={sliderVal}
|
||||
onChange={(e) => propagate(e.target.valueAsNumber)}
|
||||
disabled={disabled || isDesignMode || readonly}
|
||||
style={{ flex: 1, accentColor: "hsl(var(--primary))" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
minWidth: 36,
|
||||
textAlign: "right",
|
||||
color: "hsl(var(--foreground))",
|
||||
}}
|
||||
>
|
||||
{sliderVal}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
@@ -443,6 +554,7 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "date":
|
||||
return (
|
||||
<SingleDatePicker
|
||||
@@ -763,8 +875,55 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
);
|
||||
case "text":
|
||||
default: {
|
||||
// InvField format 별 native input type 분기 (password/email/tel/url)
|
||||
// InvField format 별 native input type 분기 (password/email/tel/url/color)
|
||||
const f = (componentConfig as any).format;
|
||||
const isColor =
|
||||
f === "color" || inputType === "color" || webType === "color";
|
||||
if (isColor) {
|
||||
const rawStr = typeof value === "string" ? value : "";
|
||||
const colorVal = normalizeHex(rawStr);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
width: "100%",
|
||||
padding: "0 8px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={colorVal}
|
||||
onChange={(e) => propagate(e.target.value)}
|
||||
disabled={disabled || isDesignMode || readonly}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 22,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
background: "transparent",
|
||||
cursor: disabled || readonly ? "not-allowed" : "pointer",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={rawStr}
|
||||
onChange={(e) => propagate(sanitizeHexInput(e.target.value))}
|
||||
placeholder={placeholder || "#000000"}
|
||||
maxLength={7}
|
||||
disabled={disabled || isDesignMode}
|
||||
readOnly={readonly}
|
||||
style={{ ...baseInputStyle, flex: 1, padding: "0 4px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const maskPattern: string | undefined =
|
||||
typeof componentConfig.mask === "string" && componentConfig.mask
|
||||
? componentConfig.mask
|
||||
: undefined;
|
||||
const inputHtmlType =
|
||||
f === "password" ? "password" :
|
||||
f === "email" ? "email" :
|
||||
@@ -775,7 +934,11 @@ export const InputComponent: React.FC<InputComponentProps> = ({
|
||||
<input
|
||||
type={inputHtmlType}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => propagate(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const next = maskPattern ? applyInputMask(raw, maskPattern) : raw;
|
||||
propagate(next);
|
||||
}}
|
||||
minLength={componentConfig.minLength}
|
||||
maxLength={componentConfig.maxLength}
|
||||
{...common}
|
||||
|
||||
@@ -12,14 +12,14 @@ import type { InputConfig } from "./types";
|
||||
* 하나의 컴포넌트가 FieldConfig.type 10종을 전부 처리. 팔레트에는 1개만 나오고
|
||||
* 사용자가 type 을 선택하면 해당 위젯으로 전환.
|
||||
*
|
||||
* 흡수 대상 (20+):
|
||||
* v2 입력/선택 (Phase D.2 폐기), v2-category-manager, v2-file-upload,
|
||||
* v2-media, v2-numbering-rule, v2-location-swap-selector,
|
||||
* entity-search-input, autocomplete-search-input,
|
||||
* text-input, number-input, date-input, select-basic, checkbox-basic,
|
||||
* radio-basic, toggle-switch, slider-basic, textarea-basic,
|
||||
* file-upload, image-display, image-widget,
|
||||
* selected-items-detail-input, test-input
|
||||
* 흡수 대상 (20+, 폴더 삭제 완료):
|
||||
* v2 입력/선택 (Phase D.2), v2-category-manager, v2-file-upload, v2-media,
|
||||
* v2-numbering-rule, v2-location-swap-selector, entity-search-input,
|
||||
* autocomplete-search-input, Phase D.7 의 슬라이더, Phase E 의 기본 입력 6종
|
||||
* (텍스트/숫자/날짜/셀렉트/체크박스/textarea), Phase F.1 의 radio-basic /
|
||||
* toggle-switch, Phase F.2 의 test-input,
|
||||
* file-upload, image-display, image-widget.
|
||||
* (selected-items-detail-input 은 explicit 컴포넌트로 별도 유지.)
|
||||
*
|
||||
* 관련 문서:
|
||||
* notes/gbpark/2026-04-11-component-unification-plan.md §3.4
|
||||
|
||||
@@ -10,12 +10,13 @@ import type { FieldRef, FieldOption } from "@/types/invyone-component";
|
||||
* 과 완전히 호환되어, 화면 수준 `fields: FieldConfig[]` 가 각 Input 컴포넌트에
|
||||
* 그대로 흘러가도록 설계.
|
||||
*
|
||||
* 흡수 대상 (20+):
|
||||
* v2 입력/선택 (Phase D.2 폐기) / v2-category-manager / v2-file-upload /
|
||||
* v2-media / v2-numbering-rule / entity-search-input / v2-location-swap-selector /
|
||||
* text-input / number-input / date-input / select-basic / checkbox-basic /
|
||||
* radio-basic / toggle-switch / slider-basic / textarea-basic / file-upload /
|
||||
* image-display / image-widget / selected-items-detail-input / test-input
|
||||
* 흡수 대상 (20+, 폴더 삭제 완료):
|
||||
* v2 입력/선택 (Phase D.2) · v2-category-manager · v2-file-upload · v2-media ·
|
||||
* v2-numbering-rule · entity-search-input · v2-location-swap-selector ·
|
||||
* Phase D.7 의 슬라이더 · Phase E 의 기본 입력 6종 (텍스트/숫자/날짜/셀렉트/
|
||||
* 체크박스/textarea) · Phase F.1 의 radio-basic / toggle-switch ·
|
||||
* Phase F.2 의 test-input · file-upload · image-display · image-widget.
|
||||
* (selected-items-detail-input 은 explicit 컴포넌트로 별도 유지.)
|
||||
*
|
||||
* 날짜/시간 (date / datetime / time / daterange) 은 InputFieldType 4종으로 통합되어
|
||||
* SingleDatePicker / DateTimePicker / TimePicker / RangeDatePicker 가 직접 렌더한다
|
||||
@@ -84,6 +85,11 @@ export interface InputConfig extends ComponentConfig {
|
||||
maxLength?: number;
|
||||
/** textarea 줄 수. 기본 3 */
|
||||
rows?: number;
|
||||
/**
|
||||
* 입력 마스크 패턴. `#`/`0` = 숫자, `A` = 영문, `*` = 임의 문자, 그 외는 literal.
|
||||
* 예: `###-####`, `010-####-####`, `####-****-****-####`
|
||||
*/
|
||||
mask?: string;
|
||||
|
||||
// ─── file ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ComponentRendererProps, AutoGenerationConfig } from "@/types/component";
|
||||
import { NumberInputConfig } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
|
||||
export interface NumberInputComponentProps extends ComponentRendererProps {
|
||||
config?: NumberInputConfig;
|
||||
value?: any; // 외부에서 전달받는 값
|
||||
}
|
||||
|
||||
/**
|
||||
* NumberInput 컴포넌트
|
||||
* number-input 컴포넌트입니다
|
||||
*/
|
||||
export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
value: externalValue, // 외부에서 전달받은 값
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as NumberInputConfig;
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig: _componentConfig,
|
||||
component: _component,
|
||||
isSelected: _isSelected,
|
||||
onClick: _onClick,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(domProps);
|
||||
|
||||
// webType에 따른 step 값 결정
|
||||
const webType = component.componentConfig?.webType || "number";
|
||||
const defaultStep = webType === "decimal" ? "0.01" : "1";
|
||||
const step = componentConfig.step !== undefined ? componentConfig.step : defaultStep;
|
||||
|
||||
// 숫자 값 가져오기
|
||||
const rawValue =
|
||||
externalValue !== undefined
|
||||
? externalValue
|
||||
: isInteractive && formData && component.columnName
|
||||
? formData[component.columnName] || ""
|
||||
: component.value || "";
|
||||
|
||||
// 천 단위 구분자 추가 함수
|
||||
const formatNumberWithCommas = (value: string | number): string => {
|
||||
if (!value) return "";
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return String(value);
|
||||
return num.toLocaleString("ko-KR");
|
||||
};
|
||||
|
||||
// 천 단위 구분자 제거 함수
|
||||
const removeCommas = (value: string): string => {
|
||||
return value.replace(/,/g, "");
|
||||
};
|
||||
|
||||
// 입력 필드에 직접 적용할 폰트 크기
|
||||
const inputFontSize = component.style?.fontSize;
|
||||
|
||||
// Currency 타입 전용 UI
|
||||
if (webType === "currency") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-1">
|
||||
{/* 통화 기호 */}
|
||||
<span className="pl-2 text-base font-semibold text-emerald-600">₩</span>
|
||||
|
||||
{/* 숫자 입력 */}
|
||||
<input
|
||||
type="text"
|
||||
value={formatNumberWithCommas(rawValue)}
|
||||
placeholder="0"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const inputValue = removeCommas(e.target.value);
|
||||
const numericValue = inputValue.replace(/[^0-9.]/g, "");
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, numericValue);
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-primary ring-2 ring-blue-100" : "border-input"} ${componentConfig.disabled ? "bg-muted text-muted-foreground/70" : "bg-white text-emerald-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Percentage 타입 전용 UI
|
||||
if (webType === "percentage") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-1">
|
||||
{/* 숫자 입력 */}
|
||||
<input
|
||||
type="text"
|
||||
value={rawValue}
|
||||
placeholder="0"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const numericValue = e.target.value.replace(/[^0-9.]/g, "");
|
||||
const num = parseFloat(numericValue);
|
||||
|
||||
// 0-100 범위 제한
|
||||
if (num > 100) return;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, numericValue);
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-primary ring-2 ring-blue-100" : "border-input"} ${componentConfig.disabled ? "bg-muted text-muted-foreground/70" : "bg-white text-primary"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
|
||||
{/* 퍼센트 기호 */}
|
||||
<span className="pr-2 text-base font-semibold text-primary">%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="number"
|
||||
value={
|
||||
// 1순위: 외부에서 전달받은 value (DynamicComponentRenderer에서 전달)
|
||||
externalValue !== undefined
|
||||
? externalValue
|
||||
: // 2순위: 인터랙티브 모드에서 formData
|
||||
isInteractive && formData && component.columnName
|
||||
? formData[component.columnName] || ""
|
||||
: // 3순위: 컴포넌트 자체 값
|
||||
component.value || ""
|
||||
}
|
||||
placeholder={componentConfig.placeholder || ""}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
min={componentConfig.min}
|
||||
max={componentConfig.max}
|
||||
step={step}
|
||||
className={`box-border h-full w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none ${isSelected ? "border-primary ring-2 ring-blue-100" : "border-input"} ${componentConfig.disabled ? "bg-muted text-muted-foreground/70" : "bg-white text-foreground"} placeholder:text-muted-foreground/70 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
console.log("🎯 NumberInputComponent onChange 호출:", {
|
||||
componentId: component.id,
|
||||
columnName: component.columnName,
|
||||
newValue,
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
hasOnChange: !!props.onChange,
|
||||
});
|
||||
|
||||
// isInteractive 모드에서는 formData 업데이트
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
console.log(`📤 NumberInputComponent -> onFormDataChange 호출: ${component.columnName} = "${newValue}"`);
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
// 디자인 모드에서는 component.onChange 호출
|
||||
else if (component.onChange) {
|
||||
console.log(`📤 NumberInputComponent -> component.onChange 호출: ${newValue}`);
|
||||
component.onChange(newValue);
|
||||
}
|
||||
// props.onChange가 있으면 호출 (호환성)
|
||||
else if (props.onChange) {
|
||||
console.log(`📤 NumberInputComponent -> props.onChange 호출: ${newValue}`);
|
||||
props.onChange(newValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* NumberInput 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const NumberInputWrapper: React.FC<NumberInputComponentProps> = (props) => {
|
||||
return <NumberInputComponent {...props} />;
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { NumberInputConfig } from "./types";
|
||||
|
||||
export interface NumberInputConfigPanelProps {
|
||||
config: NumberInputConfig;
|
||||
onChange: (config: Partial<NumberInputConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* NumberInput 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const NumberInputConfigPanel: React.FC<NumberInputConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof NumberInputConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
number-input 설정
|
||||
</div>
|
||||
|
||||
{/* 숫자 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="min">최소값</Label>
|
||||
<Input
|
||||
id="min"
|
||||
type="number"
|
||||
value={config.min || ""}
|
||||
onChange={(e) => handleChange("min", parseFloat(e.target.value) || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max">최대값</Label>
|
||||
<Input
|
||||
id="max"
|
||||
type="number"
|
||||
value={config.max || ""}
|
||||
onChange={(e) => handleChange("max", parseFloat(e.target.value) || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step">단계</Label>
|
||||
<Input
|
||||
id="step"
|
||||
type="number"
|
||||
value={config.step || 1}
|
||||
onChange={(e) => handleChange("step", parseFloat(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { NumberInputDefinition } from "./index";
|
||||
import { NumberInputComponent } from "./NumberInputComponent";
|
||||
|
||||
/**
|
||||
* NumberInput 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class NumberInputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = NumberInputDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <NumberInputComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// number 타입 특화 속성 처리
|
||||
protected getNumberInputProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// number 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 number 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
NumberInputRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
NumberInputRenderer.enableHotReload();
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
# NumberInput 컴포넌트
|
||||
|
||||
number-input 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `number-input`
|
||||
- **카테고리**: input
|
||||
- **웹타입**: number
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { NumberInputComponent } from "@/lib/registry/components/number-input";
|
||||
|
||||
<NumberInputComponent
|
||||
component={{
|
||||
id: "my-number-input",
|
||||
type: "widget",
|
||||
webType: "number",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 150, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| min | number | - | 최소값 |
|
||||
| max | number | - | 최대값 |
|
||||
| step | number | 1 | 증감 단위 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<NumberInputComponent
|
||||
component={{
|
||||
id: "sample-number-input",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js number-input --category=input --webType=number`
|
||||
- **경로**: `lib/registry/components/number-input/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/number-input)
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { NumberInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
* NumberInput 컴포넌트 기본 설정
|
||||
*/
|
||||
export const NumberInputDefaultConfig: NumberInputConfig = {
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* NumberInput 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const NumberInputConfigSchema = {
|
||||
min: { type: "number" },
|
||||
max: { type: "number" },
|
||||
step: { type: "number", default: 1, min: 0.01 },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { NumberInputWrapper } from "./NumberInputComponent";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { NumberInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
* NumberInput 컴포넌트 정의
|
||||
* number-input 컴포넌트입니다
|
||||
*/
|
||||
export const NumberInputDefinition = createComponentDefinition({
|
||||
id: "number-input",
|
||||
name: "숫자 입력",
|
||||
name_eng: "NumberInput Component",
|
||||
description: "숫자 값 입력을 위한 숫자 입력 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
web_type: "number",
|
||||
component: NumberInputWrapper,
|
||||
default_config: {
|
||||
kind: "input",
|
||||
type: "number",
|
||||
format: "int",
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
},
|
||||
default_size: { width: 200, height: 40 },
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/number-input",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { NumberInputConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { NumberInputComponent } from "./NumberInputComponent";
|
||||
export { NumberInputRenderer } from "./NumberInputRenderer";
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* NumberInput 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface NumberInputConfig extends ComponentConfig {
|
||||
// 숫자 관련 설정
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* NumberInput 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface NumberInputProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: NumberInputConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
# RadioBasic 컴포넌트
|
||||
|
||||
radio-basic 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `radio-basic`
|
||||
- **카테고리**: input
|
||||
- **웹타입**: radio
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { RadioBasicComponent } from "@/lib/registry/components/radio-basic";
|
||||
|
||||
<RadioBasicComponent
|
||||
component={{
|
||||
id: "my-radio-basic",
|
||||
type: "widget",
|
||||
webType: "radio",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 120, height: 24 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<RadioBasicComponent
|
||||
component={{
|
||||
id: "sample-radio-basic",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js radio-basic --category=input --webType=radio`
|
||||
- **경로**: `lib/registry/components/radio-basic/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/radio-basic)
|
||||
@@ -1,173 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { RadioBasicConfig } from "./types";
|
||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface RadioBasicComponentProps extends ComponentRendererProps {
|
||||
config?: RadioBasicConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* RadioBasic 컴포넌트
|
||||
* radio-basic 컴포넌트입니다
|
||||
*/
|
||||
export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as RadioBasicConfig;
|
||||
|
||||
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
|
||||
const webType = component.componentConfig?.webType || "radio";
|
||||
|
||||
// 상태 관리
|
||||
const [selectedValue, setSelectedValue] = useState<string>(component.value || "");
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
const handleRadioChange = (value: string) => {
|
||||
setSelectedValue(value);
|
||||
if (component.onChange) {
|
||||
component.onChange(value);
|
||||
}
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, value);
|
||||
}
|
||||
};
|
||||
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(props);
|
||||
|
||||
// 세부 타입별 렌더링
|
||||
const renderRadioByWebType = () => {
|
||||
const options = componentConfig.options || [
|
||||
{ value: "option1", label: "옵션 1" },
|
||||
{ value: "option2", label: "옵션 2" },
|
||||
];
|
||||
|
||||
// radio-horizontal: 가로 배치
|
||||
if (webType === "radio-horizontal") {
|
||||
return (
|
||||
<div className="flex flex-row gap-4">
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
onChange={() => handleRadioChange(option.value)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="h-4 w-4 border-input text-primary focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm text-foreground">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// radio-vertical: 세로 배치
|
||||
if (webType === "radio-vertical") {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
onChange={() => handleRadioChange(option.value)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="h-4 w-4 border-input text-primary focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm text-foreground">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// radio (기본 라디오 - direction 설정 따름)
|
||||
return (
|
||||
<div className={cn("flex gap-3", componentConfig.direction === "horizontal" ? "flex-row" : "flex-col")}>
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
onChange={() => handleRadioChange(option.value)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
required={componentConfig.required || false}
|
||||
className="border-input text-primary h-4 w-4 focus:ring-0"
|
||||
/>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 세부 타입별 UI 렌더링 */}
|
||||
{renderRadioByWebType()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* RadioBasic 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const RadioBasicWrapper: React.FC<RadioBasicComponentProps> = (props) => {
|
||||
return <RadioBasicComponent {...props} />;
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { RadioBasicConfig } from "./types";
|
||||
|
||||
export interface RadioBasicConfigPanelProps {
|
||||
config: RadioBasicConfig;
|
||||
onChange: (config: Partial<RadioBasicConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RadioBasic 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const RadioBasicConfigPanel: React.FC<RadioBasicConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof RadioBasicConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
radio-basic 설정
|
||||
</div>
|
||||
|
||||
{/* radio 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { RadioBasicDefinition } from "./index";
|
||||
import { RadioBasicComponent } from "./RadioBasicComponent";
|
||||
|
||||
/**
|
||||
* RadioBasic 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class RadioBasicRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = RadioBasicDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <RadioBasicComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// radio 타입 특화 속성 처리
|
||||
protected getRadioBasicProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// radio 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 radio 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
RadioBasicRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
RadioBasicRenderer.enableHotReload();
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { RadioBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* RadioBasic 컴포넌트 기본 설정
|
||||
*/
|
||||
export const RadioBasicDefaultConfig: RadioBasicConfig = {
|
||||
placeholder: "입력하세요",
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* RadioBasic 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const RadioBasicConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { RadioBasicWrapper } from "./RadioBasicComponent";
|
||||
import { RadioBasicConfigPanel } from "./RadioBasicConfigPanel";
|
||||
import { RadioBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* RadioBasic 컴포넌트 정의
|
||||
* radio-basic 컴포넌트입니다
|
||||
*/
|
||||
export const RadioBasicDefinition = createComponentDefinition({
|
||||
id: "radio-basic",
|
||||
name: "라디오 버튼",
|
||||
name_eng: "RadioBasic Component",
|
||||
description: "단일 옵션 선택을 위한 라디오 버튼 그룹 컴포넌트",
|
||||
category: ComponentCategory.FORM,
|
||||
web_type: "radio",
|
||||
component: RadioBasicWrapper,
|
||||
default_config: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
default_size: { width: 150, height: 80 }, // 40 * 2 (2개 옵션)
|
||||
config_panel: RadioBasicConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/radio-basic",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { RadioBasicConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { RadioBasicComponent } from "./RadioBasicComponent";
|
||||
export { RadioBasicRenderer } from "./RadioBasicRenderer";
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* RadioBasic 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface RadioBasicConfig extends ComponentConfig {
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RadioBasic 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface RadioBasicProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: RadioBasicConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export type LayoutType = "vertical" | "horizontal" | "grid";
|
||||
*/
|
||||
export interface SlotComponentConfig {
|
||||
id: string;
|
||||
/** 컴포넌트 타입 (예: "text-input", "text-display") */
|
||||
/** 컴포넌트 타입 (예: "input", "title") */
|
||||
componentType: string;
|
||||
/** 컴포넌트 라벨 */
|
||||
label?: string;
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# SelectBasic 컴포넌트
|
||||
|
||||
select-basic 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `select-basic`
|
||||
- **카테고리**: input
|
||||
- **웹타입**: select
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { SelectBasicComponent } from "@/lib/registry/components/select-basic";
|
||||
|
||||
<SelectBasicComponent
|
||||
component={{
|
||||
id: "my-select-basic",
|
||||
type: "widget",
|
||||
webType: "select",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 200, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<SelectBasicComponent
|
||||
component={{
|
||||
id: "sample-select-basic",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js select-basic --category=input --webType=select`
|
||||
- **경로**: `lib/registry/components/select-basic/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/select-basic)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,689 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link2, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { SelectBasicConfig } from "./types";
|
||||
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||
import { categoryValueCascadingApi, CategoryValueCascadingGroup } from "@/lib/api/categoryValueCascading";
|
||||
|
||||
export interface SelectBasicConfigPanelProps {
|
||||
config: SelectBasicConfig;
|
||||
onChange: (config: Partial<SelectBasicConfig>) => void;
|
||||
/** 현재 화면의 모든 컴포넌트 목록 (부모 필드 자동 감지용) */
|
||||
allComponents?: any[];
|
||||
/** 현재 컴포넌트 정보 */
|
||||
currentComponent?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectBasic 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
allComponents = [],
|
||||
currentComponent,
|
||||
}) => {
|
||||
// 연쇄 드롭다운 관련 상태
|
||||
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
|
||||
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
|
||||
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계 상태
|
||||
const [categoryRelationEnabled, setCategoryRelationEnabled] = useState(!!(config as any).categoryRelationCode);
|
||||
const [categoryRelationList, setCategoryRelationList] = useState<CategoryValueCascadingGroup[]>([]);
|
||||
const [loadingCategoryRelations, setLoadingCategoryRelations] = useState(false);
|
||||
|
||||
// 연쇄 관계 목록 로드
|
||||
useEffect(() => {
|
||||
if (cascadingEnabled && relationList.length === 0) {
|
||||
loadRelationList();
|
||||
}
|
||||
}, [cascadingEnabled]);
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계 목록 로드
|
||||
useEffect(() => {
|
||||
if (categoryRelationEnabled && categoryRelationList.length === 0) {
|
||||
loadCategoryRelationList();
|
||||
}
|
||||
}, [categoryRelationEnabled]);
|
||||
|
||||
// config 변경 시 상태 동기화
|
||||
useEffect(() => {
|
||||
setCascadingEnabled(!!config.cascadingRelationCode);
|
||||
setCategoryRelationEnabled(!!(config as any).categoryRelationCode);
|
||||
}, [config.cascadingRelationCode, (config as any).categoryRelationCode]);
|
||||
|
||||
const loadRelationList = async () => {
|
||||
setLoadingRelations(true);
|
||||
try {
|
||||
const response = await cascadingRelationApi.getList("Y");
|
||||
if (response.success && response.data) {
|
||||
setRelationList(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingRelations(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계 목록 로드
|
||||
const loadCategoryRelationList = async () => {
|
||||
setLoadingCategoryRelations(true);
|
||||
try {
|
||||
const response = await categoryValueCascadingApi.getGroups("Y");
|
||||
if (response.success && response.data) {
|
||||
setCategoryRelationList(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 값 연쇄관계 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingCategoryRelations(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
||||
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
||||
const newConfig = { ...config, [key]: value };
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// 연쇄 드롭다운 토글
|
||||
const handleCascadingToggle = (enabled: boolean) => {
|
||||
setCascadingEnabled(enabled);
|
||||
if (!enabled) {
|
||||
// 비활성화 시 관계 설정 제거
|
||||
const newConfig = {
|
||||
...config,
|
||||
cascadingRelationCode: undefined,
|
||||
cascadingRole: undefined,
|
||||
cascadingParentField: undefined,
|
||||
};
|
||||
onChange(newConfig);
|
||||
} else {
|
||||
loadRelationList();
|
||||
// 카테고리 값 연쇄관계 비활성화 (둘 중 하나만 사용)
|
||||
if (categoryRelationEnabled) {
|
||||
setCategoryRelationEnabled(false);
|
||||
onChange({ ...config, categoryRelationCode: undefined } as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계 토글
|
||||
const handleCategoryRelationToggle = (enabled: boolean) => {
|
||||
setCategoryRelationEnabled(enabled);
|
||||
if (!enabled) {
|
||||
// 비활성화 시 관계 설정 제거
|
||||
const newConfig = {
|
||||
...config,
|
||||
categoryRelationCode: undefined,
|
||||
cascadingRole: undefined,
|
||||
cascadingParentField: undefined,
|
||||
} as any;
|
||||
onChange(newConfig);
|
||||
} else {
|
||||
loadCategoryRelationList();
|
||||
// 일반 연쇄관계 비활성화 (둘 중 하나만 사용)
|
||||
if (cascadingEnabled) {
|
||||
setCascadingEnabled(false);
|
||||
onChange({ ...config, cascadingRelationCode: undefined });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 같은 연쇄 관계의 부모 역할 컴포넌트 찾기
|
||||
const findParentComponent = (relationCode: string) => {
|
||||
console.log("🔍 findParentComponent 호출:", {
|
||||
relationCode,
|
||||
allComponentsLength: allComponents?.length,
|
||||
currentComponentId: currentComponent?.id,
|
||||
});
|
||||
|
||||
if (!allComponents || allComponents.length === 0) {
|
||||
console.log("❌ allComponents가 비어있음");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 모든 컴포넌트의 cascading 설정 확인
|
||||
allComponents.forEach((comp: any) => {
|
||||
const compConfig = comp.componentConfig || {};
|
||||
if (compConfig.cascadingRelationCode) {
|
||||
console.log("📦 컴포넌트 cascading 설정:", {
|
||||
id: comp.id,
|
||||
columnName: comp.columnName,
|
||||
cascadingRelationCode: compConfig.cascadingRelationCode,
|
||||
cascadingRole: compConfig.cascadingRole,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const found = allComponents.find((comp: any) => {
|
||||
const compConfig = comp.componentConfig || {};
|
||||
return (
|
||||
comp.id !== currentComponent?.id && // 자기 자신 제외
|
||||
compConfig.cascadingRelationCode === relationCode &&
|
||||
compConfig.cascadingRole === "parent"
|
||||
);
|
||||
});
|
||||
|
||||
console.log("🔍 찾은 부모 컴포넌트:", found);
|
||||
return found;
|
||||
};
|
||||
|
||||
// 역할 변경 시 부모 필드 자동 감지
|
||||
const handleRoleChange = (role: "parent" | "child") => {
|
||||
let parentField = config.cascadingParentField;
|
||||
|
||||
// 자식 역할 선택 시 부모 필드 자동 감지
|
||||
if (role === "child" && config.cascadingRelationCode) {
|
||||
const parentComp = findParentComponent(config.cascadingRelationCode);
|
||||
if (parentComp) {
|
||||
parentField = parentComp.columnName;
|
||||
console.log("🔗 부모 필드 자동 감지:", parentField);
|
||||
}
|
||||
}
|
||||
|
||||
const newConfig = {
|
||||
...config,
|
||||
cascadingRole: role,
|
||||
// 부모 역할일 때는 부모 필드 불필요, 자식일 때는 자동 감지된 값 또는 기존 값
|
||||
cascadingParentField: role === "parent" ? undefined : parentField,
|
||||
};
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// 선택된 관계 정보
|
||||
const selectedRelation = relationList.find((r) => r.relation_code === config.cascadingRelationCode);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">select-basic 설정</div>
|
||||
|
||||
{/* select 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="multiple">다중 선택</Label>
|
||||
<Checkbox
|
||||
id="multiple"
|
||||
checked={config.multiple || false}
|
||||
onCheckedChange={(checked) => handleChange("multiple", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 연쇄 드롭다운 설정 */}
|
||||
<div className="mt-4 space-y-3 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<Label className="text-sm font-medium">연쇄 드롭다운</Label>
|
||||
</div>
|
||||
<Switch checked={cascadingEnabled} onCheckedChange={handleCascadingToggle} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">다른 필드의 값에 따라 옵션이 동적으로 변경됩니다.</p>
|
||||
|
||||
{cascadingEnabled && (
|
||||
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
||||
{/* 관계 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">연쇄 관계 선택</Label>
|
||||
<Select
|
||||
value={config.cascadingRelationCode || ""}
|
||||
onValueChange={(value) => handleChange("cascadingRelationCode", value || undefined)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{relationList.map((relation) => (
|
||||
<SelectItem key={relation.relation_code} value={relation.relation_code}>
|
||||
<div className="flex flex-col">
|
||||
<span>{relation.relation_name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{relation.parent_table} → {relation.child_table}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 역할 선택 */}
|
||||
{config.cascadingRelationCode && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">역할 선택</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={config.cascadingRole === "parent" ? "default" : "outline"}
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => handleRoleChange("parent")}
|
||||
>
|
||||
부모 (상위 선택)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={config.cascadingRole === "child" ? "default" : "outline"}
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => handleRoleChange("child")}
|
||||
>
|
||||
자식 (하위 선택)
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{config.cascadingRole === "parent"
|
||||
? "이 필드가 상위 선택 역할을 합니다. (예: 창고 선택)"
|
||||
: config.cascadingRole === "child"
|
||||
? "이 필드는 상위 필드 값에 따라 옵션이 변경됩니다. (예: 위치 선택)"
|
||||
: "이 필드의 역할을 선택하세요."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 부모 필드 설정 (자식 역할일 때만) */}
|
||||
{config.cascadingRelationCode &&
|
||||
config.cascadingRole === "child" &&
|
||||
(() => {
|
||||
// 선택된 관계에서 부모 값 컬럼 가져오기
|
||||
const expectedParentColumn = selectedRelation?.parent_value_column;
|
||||
|
||||
// 부모 역할에 맞는 컴포넌트만 필터링
|
||||
const parentFieldCandidates = allComponents.filter((comp) => {
|
||||
// 현재 컴포넌트 제외
|
||||
if (currentComponent && comp.id === currentComponent.id) return false;
|
||||
// 관계에서 지정한 부모 컬럼명과 일치하는 컴포넌트만
|
||||
if (expectedParentColumn && comp.columnName !== expectedParentColumn) return false;
|
||||
// columnName이 있어야 함
|
||||
return !!comp.columnName;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">부모 필드 선택</Label>
|
||||
{expectedParentColumn && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
관계에서 지정된 부모 컬럼: <strong>{expectedParentColumn}</strong>
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
value={config.cascadingParentField || ""}
|
||||
onValueChange={(value) => handleChange("cascadingParentField", value || undefined)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="부모 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentFieldCandidates.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.columnName}>
|
||||
{comp.label || comp.columnName} ({comp.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
{parentFieldCandidates.length === 0 && (
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
||||
{expectedParentColumn
|
||||
? `'${expectedParentColumn}' 컬럼을 가진 컴포넌트가 화면에 없습니다`
|
||||
: "선택 가능한 부모 필드가 없습니다"}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">상위 값을 제공할 필드를 선택하세요.</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 선택된 관계 정보 표시 */}
|
||||
{selectedRelation && config.cascadingRole && (
|
||||
<div className="bg-background space-y-1 rounded-md p-2 text-xs">
|
||||
{config.cascadingRole === "parent" ? (
|
||||
<>
|
||||
<div className="font-medium text-primary">부모 역할 (상위 선택)</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">데이터 소스:</span>{" "}
|
||||
<span className="font-medium">{selectedRelation.parent_table}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">저장 값:</span>{" "}
|
||||
<span className="font-medium">{selectedRelation.parent_value_column}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium text-emerald-600">자식 역할 (하위 선택)</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">데이터 소스:</span>{" "}
|
||||
<span className="font-medium">{selectedRelation.child_table}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">필터 기준:</span>{" "}
|
||||
<span className="font-medium">{selectedRelation.child_filter_column}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">저장 값:</span>{" "}
|
||||
<span className="font-medium">{selectedRelation.child_value_column}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 관계 관리 페이지 링크 */}
|
||||
<div className="flex justify-end">
|
||||
<Link href="/admin/cascading-relations" target="_blank">
|
||||
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
관계 관리
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 🆕 카테고리 값 연쇄관계 설정 */}
|
||||
<div className="mt-4 space-y-3 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<Label className="text-sm font-medium">카테고리 값 연쇄</Label>
|
||||
</div>
|
||||
<Switch checked={categoryRelationEnabled} onCheckedChange={handleCategoryRelationToggle} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
부모 카테고리 값 선택에 따라 자식 카테고리 옵션이 변경됩니다.
|
||||
<br />
|
||||
예: 검사유형 선택 시 해당 유형에 맞는 적용대상만 표시
|
||||
</p>
|
||||
|
||||
{categoryRelationEnabled && (
|
||||
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
||||
{/* 관계 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">카테고리 값 연쇄 관계 선택</Label>
|
||||
<Select
|
||||
value={(config as any).categoryRelationCode || ""}
|
||||
onValueChange={(value) => handleChange("categoryRelationCode" as any, value || undefined)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder={loadingCategoryRelations ? "로딩 중..." : "관계 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryRelationList.map((relation) => (
|
||||
<SelectItem key={relation.relation_code} value={relation.relation_code}>
|
||||
<div className="flex flex-col">
|
||||
<span>{relation.relation_name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{relation.parent_table_name}.{relation.parent_column_name} → {relation.child_table_name}.
|
||||
{relation.child_column_name}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 역할 선택 */}
|
||||
{(config as any).categoryRelationCode && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">역할 선택</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={config.cascadingRole === "parent" ? "default" : "outline"}
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => handleRoleChange("parent")}
|
||||
>
|
||||
부모 (상위 선택)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={config.cascadingRole === "child" ? "default" : "outline"}
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => handleRoleChange("child")}
|
||||
>
|
||||
자식 (하위 선택)
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{config.cascadingRole === "parent"
|
||||
? "이 필드가 상위 카테고리 선택 역할을 합니다. (예: 검사유형)"
|
||||
: config.cascadingRole === "child"
|
||||
? "이 필드는 상위 카테고리 값에 따라 옵션이 변경됩니다. (예: 적용대상)"
|
||||
: "이 필드의 역할을 선택하세요."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 부모 필드 설정 (자식 역할일 때만) */}
|
||||
{(config as any).categoryRelationCode &&
|
||||
config.cascadingRole === "child" &&
|
||||
(() => {
|
||||
// 선택된 관계 정보 가져오기
|
||||
const selectedRelation = categoryRelationList.find(
|
||||
(r) => r.relation_code === (config as any).categoryRelationCode,
|
||||
);
|
||||
const expectedParentColumn = selectedRelation?.parent_column_name;
|
||||
|
||||
// 부모 역할에 맞는 컴포넌트만 필터링
|
||||
const parentFieldCandidates = allComponents.filter((comp) => {
|
||||
// 현재 컴포넌트 제외
|
||||
if (currentComponent && comp.id === currentComponent.id) return false;
|
||||
// 관계에서 지정한 부모 컬럼명과 일치하는 컴포넌트만
|
||||
if (expectedParentColumn && comp.columnName !== expectedParentColumn) return false;
|
||||
// columnName이 있어야 함
|
||||
return !!comp.columnName;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">부모 필드 선택</Label>
|
||||
{expectedParentColumn && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
관계에서 지정된 부모 컬럼: <strong>{expectedParentColumn}</strong>
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
value={config.cascadingParentField || ""}
|
||||
onValueChange={(value) => handleChange("cascadingParentField", value || undefined)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="부모 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentFieldCandidates.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.columnName}>
|
||||
{comp.label || comp.columnName} ({comp.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
{parentFieldCandidates.length === 0 && (
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
||||
{expectedParentColumn
|
||||
? `'${expectedParentColumn}' 컬럼을 가진 컴포넌트가 화면에 없습니다`
|
||||
: "선택 가능한 부모 필드가 없습니다"}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">상위 카테고리 값을 제공할 필드를 선택하세요.</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 관계 관리 페이지 링크 */}
|
||||
<div className="flex justify-end">
|
||||
<Link href="/admin/cascading-management?tab=category-value" target="_blank">
|
||||
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
카테고리 값 연쇄 관리
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 계층구조 코드 설정 */}
|
||||
<div className="mt-4 space-y-3 border-t pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2v4m0 12v4m-6-10H2m20 0h-4m-1.5-6.5L18 4m-12 0 1.5 1.5M6 18l-1.5 1.5M18 18l1.5 1.5" />
|
||||
</svg>
|
||||
<Label className="text-sm font-medium">계층구조 코드 설정</Label>
|
||||
</div>
|
||||
<div className="rounded border border-primary/20 bg-primary/10 p-2 text-xs text-primary">
|
||||
공통코드에 계층구조(depth 2 이상)가 있으면 자동으로 대분류 → 중분류 → 소분류 셀렉트박스로 표시됩니다.
|
||||
</div>
|
||||
|
||||
{/* 상세 설정 (항상 표시, 계층구조가 있을 때 적용됨) */}
|
||||
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
||||
<p className="text-muted-foreground text-xs">계층구조 코드가 감지되면 아래 설정이 적용됩니다.</p>
|
||||
|
||||
{/* 코드 카테고리 선택 안내 */}
|
||||
{!config.codeCategory && (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 p-2 text-xs text-yellow-800">
|
||||
먼저 상단에서 코드 카테고리를 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 최대 깊이 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">최대 깊이</Label>
|
||||
<Select
|
||||
value={String(config.hierarchicalMaxDepth || 3)}
|
||||
onValueChange={(value) => handleChange("hierarchicalMaxDepth", Number(value) as 1 | 2 | 3)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="깊이 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1단계만</SelectItem>
|
||||
<SelectItem value="2">2단계까지</SelectItem>
|
||||
<SelectItem value="3">3단계까지</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">표시할 계층의 최대 깊이를 선택하세요.</p>
|
||||
</div>
|
||||
|
||||
{/* 1단계 라벨 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">1단계 라벨</Label>
|
||||
<Input
|
||||
value={config.hierarchicalLabels?.[0] || ""}
|
||||
onChange={(e) => {
|
||||
const newLabels: [string, string?, string?] = [
|
||||
e.target.value,
|
||||
config.hierarchicalLabels?.[1],
|
||||
config.hierarchicalLabels?.[2],
|
||||
];
|
||||
handleChange("hierarchicalLabels", newLabels);
|
||||
}}
|
||||
placeholder="예: 대분류"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2단계 라벨 */}
|
||||
{(config.hierarchicalMaxDepth || 3) >= 2 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">2단계 라벨</Label>
|
||||
<Input
|
||||
value={config.hierarchicalLabels?.[1] || ""}
|
||||
onChange={(e) => {
|
||||
const newLabels: [string, string?, string?] = [
|
||||
config.hierarchicalLabels?.[0] || "대분류",
|
||||
e.target.value,
|
||||
config.hierarchicalLabels?.[2],
|
||||
];
|
||||
handleChange("hierarchicalLabels", newLabels);
|
||||
}}
|
||||
placeholder="예: 중분류"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3단계 라벨 */}
|
||||
{(config.hierarchicalMaxDepth || 3) >= 3 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">3단계 라벨</Label>
|
||||
<Input
|
||||
value={config.hierarchicalLabels?.[2] || ""}
|
||||
onChange={(e) => {
|
||||
const newLabels: [string, string?, string?] = [
|
||||
config.hierarchicalLabels?.[0] || "대분류",
|
||||
config.hierarchicalLabels?.[1] || "중분류",
|
||||
e.target.value,
|
||||
];
|
||||
handleChange("hierarchicalLabels", newLabels);
|
||||
}}
|
||||
placeholder="예: 소분류"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 인라인 표시 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">가로 배치</Label>
|
||||
<Switch
|
||||
checked={config.hierarchicalInline || false}
|
||||
onCheckedChange={(checked) => handleChange("hierarchicalInline", checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">셀렉트박스를 가로로 나란히 표시합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { SelectBasicDefinition } from "./index";
|
||||
import { SelectBasicComponent } from "./SelectBasicComponent";
|
||||
|
||||
/**
|
||||
* SelectBasic 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class SelectBasicRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = SelectBasicDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <SelectBasicComponent {...(this.props as any)} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// select 타입 특화 속성 처리
|
||||
protected getSelectBasicProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// select 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 select 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
SelectBasicRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
SelectBasicRenderer.enableHotReload();
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SelectBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* SelectBasic 컴포넌트 기본 설정
|
||||
*/
|
||||
export const SelectBasicDefaultConfig: SelectBasicConfig = {
|
||||
options: [],
|
||||
placeholder: "선택하세요",
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* SelectBasic 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const SelectBasicConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { SelectBasicWrapper } from "./SelectBasicComponent";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { SelectBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* SelectBasic 컴포넌트 정의
|
||||
* select-basic 컴포넌트입니다
|
||||
*/
|
||||
export const SelectBasicDefinition = createComponentDefinition({
|
||||
id: "select-basic",
|
||||
name: "선택상자",
|
||||
name_eng: "SelectBasic Component",
|
||||
description: "옵션 선택을 위한 드롭다운 선택상자 컴포넌트",
|
||||
category: ComponentCategory.FORM,
|
||||
web_type: "select",
|
||||
component: SelectBasicWrapper,
|
||||
default_config: {
|
||||
kind: "choice",
|
||||
type: "single",
|
||||
format: "list",
|
||||
options: [],
|
||||
placeholder: "선택하세요",
|
||||
},
|
||||
default_size: { width: 250, height: 40 },
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/select-basic",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { SelectBasicConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { SelectBasicComponent } from "./SelectBasicComponent";
|
||||
export { SelectBasicRenderer } from "./SelectBasicRenderer";
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* SelectBasic 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface SelectBasicConfig extends ComponentConfig {
|
||||
// select 관련 설정
|
||||
placeholder?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
multiple?: boolean;
|
||||
|
||||
// 코드 관련 설정
|
||||
codeCategory?: string;
|
||||
|
||||
// 🆕 연쇄 드롭다운 설정
|
||||
/** 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
|
||||
cascadingRelationCode?: string;
|
||||
/** 연쇄 드롭다운 역할: parent(부모) 또는 child(자식) */
|
||||
cascadingRole?: "parent" | "child";
|
||||
/** 부모 필드명 (자식 역할일 때, 화면 내 부모 필드의 columnName) */
|
||||
cascadingParentField?: string;
|
||||
|
||||
// 🆕 계층구조 코드 설정
|
||||
/** 계층구조 코드 사용 여부 */
|
||||
useHierarchicalCode?: boolean;
|
||||
/** 계층구조 최대 깊이 (1, 2, 3) */
|
||||
hierarchicalMaxDepth?: 1 | 2 | 3;
|
||||
/** 각 단계별 라벨 */
|
||||
hierarchicalLabels?: [string, string?, string?];
|
||||
/** 각 단계별 placeholder */
|
||||
hierarchicalPlaceholders?: [string, string?, string?];
|
||||
/** 가로 배열 여부 */
|
||||
hierarchicalInline?: boolean;
|
||||
|
||||
// 🆕 다중 컬럼 계층구조 설정 (테이블 타입관리에서 설정)
|
||||
/** 계층 역할: 대분류(large), 중분류(medium), 소분류(small) */
|
||||
hierarchyRole?: "large" | "medium" | "small";
|
||||
/** 상위 계층 필드명 (중분류는 대분류 필드명, 소분류는 중분류 필드명) */
|
||||
hierarchyParentField?: string;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectBasic 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface SelectBasicProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: SelectBasicConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
# SliderBasic 컴포넌트
|
||||
|
||||
slider-basic 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `slider-basic`
|
||||
- **카테고리**: input
|
||||
- **웹타입**: number
|
||||
- **작성자**: Developer
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { SliderBasicComponent } from "@/lib/registry/components/slider-basic";
|
||||
|
||||
<SliderBasicComponent
|
||||
component={{
|
||||
id: "my-slider-basic",
|
||||
type: "widget",
|
||||
webType: "number",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 200, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| min | number | - | 최소값 |
|
||||
| max | number | - | 최대값 |
|
||||
| step | number | 1 | 증감 단위 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<SliderBasicComponent
|
||||
component={{
|
||||
id: "sample-slider-basic",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js slider-basic --category=input --webType=number`
|
||||
- **경로**: `lib/registry/components/slider-basic/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/slider-basic)
|
||||
@@ -1,175 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { SliderBasicConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface SliderBasicComponentProps extends ComponentRendererProps {
|
||||
config?: SliderBasicConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* SliderBasic 컴포넌트
|
||||
* slider-basic 컴포넌트입니다
|
||||
*/
|
||||
export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as SliderBasicConfig;
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig: _componentConfig,
|
||||
component: _component,
|
||||
isSelected: _isSelected,
|
||||
onClick: _onClick,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: getAdaptiveLabelColor(component.style?.labelColor),
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "8px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={componentConfig.min || 0}
|
||||
max={componentConfig.max || 100}
|
||||
step={componentConfig.step || 1}
|
||||
value={component.value || componentConfig.min || 0}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{
|
||||
width: "70%",
|
||||
height: "6px",
|
||||
outline: "none",
|
||||
borderRadius: "3px",
|
||||
background: "#e5e7eb",
|
||||
accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: "30%",
|
||||
textAlign: "center",
|
||||
fontSize: "14px",
|
||||
color: "hsl(var(--foreground))",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.value || componentConfig.min || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* SliderBasic 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const SliderBasicWrapper: React.FC<SliderBasicComponentProps> = (props) => {
|
||||
return <SliderBasicComponent {...props} />;
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SliderBasicConfig } from "./types";
|
||||
|
||||
export interface SliderBasicConfigPanelProps {
|
||||
config: SliderBasicConfig;
|
||||
onChange: (config: Partial<SliderBasicConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SliderBasic 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const SliderBasicConfigPanel: React.FC<SliderBasicConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof SliderBasicConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
slider-basic 설정
|
||||
</div>
|
||||
|
||||
{/* 숫자 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="min">최소값</Label>
|
||||
<Input
|
||||
id="min"
|
||||
type="number"
|
||||
value={config.min || ""}
|
||||
onChange={(e) => handleChange("min", parseFloat(e.target.value) || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max">최대값</Label>
|
||||
<Input
|
||||
id="max"
|
||||
type="number"
|
||||
value={config.max || ""}
|
||||
onChange={(e) => handleChange("max", parseFloat(e.target.value) || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step">단계</Label>
|
||||
<Input
|
||||
id="step"
|
||||
type="number"
|
||||
value={config.step || 1}
|
||||
onChange={(e) => handleChange("step", parseFloat(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { SliderBasicDefinition } from "./index";
|
||||
import { SliderBasicComponent } from "./SliderBasicComponent";
|
||||
|
||||
/**
|
||||
* SliderBasic 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class SliderBasicRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = SliderBasicDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <SliderBasicComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// number 타입 특화 속성 처리
|
||||
protected getSliderBasicProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// number 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 number 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
SliderBasicRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
SliderBasicRenderer.enableHotReload();
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SliderBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* SliderBasic 컴포넌트 기본 설정
|
||||
*/
|
||||
export const SliderBasicDefaultConfig: SliderBasicConfig = {
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* SliderBasic 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const SliderBasicConfigSchema = {
|
||||
min: { type: "number" },
|
||||
max: { type: "number" },
|
||||
step: { type: "number", default: 1, min: 0.01 },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { SliderBasicWrapper } from "./SliderBasicComponent";
|
||||
import { SliderBasicConfigPanel } from "./SliderBasicConfigPanel";
|
||||
import { SliderBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* SliderBasic 컴포넌트 정의
|
||||
* slider-basic 컴포넌트입니다
|
||||
*/
|
||||
export const SliderBasicDefinition = createComponentDefinition({
|
||||
id: "slider-basic",
|
||||
name: "슬라이더",
|
||||
name_eng: "SliderBasic Component",
|
||||
description: "범위 값 선택을 위한 슬라이더 컴포넌트",
|
||||
category: ComponentCategory.FORM,
|
||||
web_type: "number",
|
||||
component: SliderBasicWrapper,
|
||||
default_config: {
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
},
|
||||
default_size: { width: 250, height: 40 },
|
||||
config_panel: SliderBasicConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "Developer",
|
||||
documentation: "https://docs.example.com/components/slider-basic",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { SliderBasicConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { SliderBasicComponent } from "./SliderBasicComponent";
|
||||
export { SliderBasicRenderer } from "./SliderBasicRenderer";
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* SliderBasic 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface SliderBasicConfig extends ComponentConfig {
|
||||
// 숫자 관련 설정
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SliderBasic 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface SliderBasicProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: SliderBasicConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
# TestInput 컴포넌트
|
||||
|
||||
테스트용 입력 컴포넌트
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `test-input`
|
||||
- **카테고리**: input
|
||||
- **웹타입**: text
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { TestInputComponent } from "@/lib/registry/components/test-input";
|
||||
|
||||
<TestInputComponent
|
||||
component={{
|
||||
id: "my-test-input",
|
||||
type: "widget",
|
||||
webType: "text",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 200, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| maxLength | number | 255 | 최대 입력 길이 |
|
||||
| minLength | number | 0 | 최소 입력 길이 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<TestInputComponent
|
||||
component={{
|
||||
id: "sample-test-input",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-12
|
||||
- **CLI 명령어**: `node scripts/create-component.js test-input "테스트 입력" "테스트용 입력 컴포넌트" input text`
|
||||
- **경로**: `lib/registry/components/test-input/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/test-input)
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { TestInputConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface TestInputComponentProps extends ComponentRendererProps {
|
||||
config?: TestInputConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestInput 컴포넌트
|
||||
* 테스트용 입력 컴포넌트
|
||||
*/
|
||||
export const TestInputComponent: React.FC<TestInputComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
screenId,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as TestInputConfig;
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig: _componentConfig,
|
||||
component: _component,
|
||||
isSelected: _isSelected,
|
||||
onClick: _onClick,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: getAdaptiveLabelColor(component.style?.labelColor),
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={component.value || ""}
|
||||
placeholder={componentConfig.placeholder || ""}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "#f97316";
|
||||
e.target.style.boxShadow = "0 0 0 3px rgba(249, 115, 22, 0.1)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "#d1d5db";
|
||||
e.target.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onChange={(e) => {
|
||||
if (domProps.onChange) {
|
||||
domProps.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* TestInput 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const TestInputWrapper: React.FC<TestInputComponentProps> = (props) => {
|
||||
return <TestInputComponent {...props} />;
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { TestInputConfig } from "./types";
|
||||
|
||||
export interface TestInputConfigPanelProps {
|
||||
config: TestInputConfig;
|
||||
onChange: (config: Partial<TestInputConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestInput 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const TestInputConfigPanel: React.FC<TestInputConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof TestInputConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
테스트용 입력 컴포넌트 설정
|
||||
</div>
|
||||
|
||||
{/* 텍스트 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxLength">최대 길이</Label>
|
||||
<Input
|
||||
id="maxLength"
|
||||
type="number"
|
||||
value={config.maxLength || ""}
|
||||
onChange={(e) => handleChange("maxLength", parseInt(e.target.value) || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { TestInputDefinition } from "./index";
|
||||
import { TestInputComponent } from "./TestInputComponent";
|
||||
|
||||
/**
|
||||
* TestInput 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class TestInputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = TestInputDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <TestInputComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// text 타입 특화 속성 처리
|
||||
protected getTestInputProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// text 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 text 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
TestInputRenderer.registerSelf();
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { TestInputWrapper } from "./TestInputComponent";
|
||||
import { TestInputConfigPanel } from "./TestInputConfigPanel";
|
||||
import { TestInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
* TestInput 컴포넌트 정의
|
||||
* 테스트용 입력 컴포넌트
|
||||
*/
|
||||
export const TestInputDefinition = createComponentDefinition({
|
||||
id: "test-input",
|
||||
name: "테스트 입력",
|
||||
name_eng: "TestInput Component",
|
||||
description: "테스트용 입력 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
web_type: "text",
|
||||
component: TestInputWrapper,
|
||||
default_config: {
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
},
|
||||
default_size: { width: 200, height: 36 },
|
||||
config_panel: TestInputConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/test-input",
|
||||
});
|
||||
|
||||
// 컴포넌트는 TestInputRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { TestInputConfig } from "./types";
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* TestInput 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface TestInputConfig extends ComponentConfig {
|
||||
// 텍스트 관련 설정
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestInput 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface TestInputProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: TestInputConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
# TextInput 컴포넌트
|
||||
|
||||
text-input 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `text-input`
|
||||
- **카테고리**: input
|
||||
- **웹타입**: text
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { TextInputComponent } from "@/lib/registry/components/text-input";
|
||||
|
||||
<TextInputComponent
|
||||
component={{
|
||||
id: "my-text-input",
|
||||
type: "widget",
|
||||
webType: "text",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 200, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| maxLength | number | 255 | 최대 입력 길이 |
|
||||
| minLength | number | 0 | 최소 입력 길이 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<TextInputComponent
|
||||
component={{
|
||||
id: "sample-text-input",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js text-input --category=input --webType=text`
|
||||
- **경로**: `lib/registry/components/text-input/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/text-input)
|
||||
@@ -1,879 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { AutoGenerationConfig, AutoGenerationType } from "@/types/screen";
|
||||
import { TextInputConfig } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { INPUT_CLASSES, cn, getInputClasses } from "../common/inputStyles";
|
||||
import { ChevronDown, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
|
||||
export interface TextInputComponentProps extends ComponentRendererProps {
|
||||
config?: TextInputConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* TextInput 컴포넌트
|
||||
* text-input 컴포넌트입니다
|
||||
*/
|
||||
export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as TextInputConfig;
|
||||
|
||||
// 자동생성 설정 (props에서 전달받은 값 우선 사용)
|
||||
const autoGeneration: AutoGenerationConfig = props.autoGeneration ||
|
||||
component.autoGeneration ||
|
||||
componentConfig.autoGeneration || {
|
||||
type: "none",
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
// 숨김 상태 (props에서 전달받은 값 우선 사용)
|
||||
const isHidden = props.hidden !== undefined ? props.hidden : component.hidden || componentConfig.hidden || false;
|
||||
|
||||
// 수정 모드 여부 확인 (originalData가 있으면 수정 모드)
|
||||
const originalData = props.originalData || (props as any)._originalData;
|
||||
const isEditMode = originalData && Object.keys(originalData).length > 0;
|
||||
|
||||
// 자동생성된 값 상태
|
||||
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
|
||||
|
||||
// API 호출 중복 방지를 위한 ref
|
||||
const isGeneratingRef = React.useRef(false);
|
||||
const hasGeneratedRef = React.useRef(false);
|
||||
|
||||
// 테스트용: 컴포넌트 라벨에 "test"가 포함되면 강제로 UUID 자동생성 활성화
|
||||
const testAutoGeneration = component.label?.toLowerCase().includes("test")
|
||||
? {
|
||||
type: "uuid" as AutoGenerationType,
|
||||
enabled: true,
|
||||
}
|
||||
: autoGeneration;
|
||||
|
||||
// 디버그 로그 (필요시 주석 해제)
|
||||
// console.log("🔧 텍스트 입력 컴포넌트 설정:", {
|
||||
// config,
|
||||
// componentConfig,
|
||||
// component: component,
|
||||
// autoGeneration,
|
||||
// testAutoGeneration,
|
||||
// isTestMode: component.label?.toLowerCase().includes("test"),
|
||||
// isHidden,
|
||||
// isInteractive,
|
||||
// formData,
|
||||
// columnName: component.columnName,
|
||||
// currentFormValue: formData?.[component.columnName],
|
||||
// componentValue: component.value,
|
||||
// autoGeneratedValue,
|
||||
// });
|
||||
|
||||
// 자동생성 원본 값 추적 (수동/자동 모드 구분용)
|
||||
const [originalAutoGeneratedValue, setOriginalAutoGeneratedValue] = useState<string>("");
|
||||
const [isManualMode, setIsManualMode] = useState<boolean>(false);
|
||||
|
||||
// 자동생성 값 생성 (컴포넌트 마운트 시 한 번만 실행)
|
||||
useEffect(() => {
|
||||
const generateAutoValue = async () => {
|
||||
// 이미 생성 중이거나 생성 완료된 경우 중복 실행 방지
|
||||
if (isGeneratingRef.current || hasGeneratedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 수정 모드일 때는 채번 규칙 스킵 (기존 값 유지)
|
||||
if (isEditMode) {
|
||||
console.log("⏭️ 수정 모드 - 채번 규칙 스킵:", {
|
||||
columnName: component.columnName,
|
||||
originalValue: originalData?.[component.columnName],
|
||||
});
|
||||
hasGeneratedRef.current = true; // 생성 완료로 표시하여 재실행 방지
|
||||
return;
|
||||
}
|
||||
|
||||
if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") {
|
||||
// 폼 데이터에 이미 값이 있으면 자동생성하지 않음
|
||||
const currentFormValue = formData?.[component.columnName];
|
||||
const currentComponentValue = component.value;
|
||||
|
||||
// 🆕 채번 규칙이 설정되어 있으면 항상 _numberingRuleId를 formData에 설정
|
||||
// (값 생성 성공 여부와 관계없이, 저장 시점에 allocateCode를 호출하기 위함)
|
||||
if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numbering_rule_id) {
|
||||
const ruleId = testAutoGeneration.options.numbering_rule_id;
|
||||
if (ruleId && ruleId !== "undefined" && ruleId !== "null" && ruleId !== "") {
|
||||
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
||||
// formData에 아직 설정되지 않은 경우에만 설정
|
||||
if (isInteractive && onFormDataChange && !formData?.[ruleIdKey]) {
|
||||
onFormDataChange(ruleIdKey, ruleId);
|
||||
console.log("📝 채번 규칙 ID 사전 설정:", ruleIdKey, ruleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
|
||||
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
|
||||
isGeneratingRef.current = true; // 생성 시작 플래그
|
||||
let generatedValue: string | null = null;
|
||||
|
||||
// 채번 규칙은 비동기로 처리
|
||||
if (testAutoGeneration.type === "numbering_rule") {
|
||||
const ruleId = testAutoGeneration.options?.numbering_rule_id;
|
||||
if (ruleId && ruleId !== "undefined" && ruleId !== "null") {
|
||||
try {
|
||||
const { previewNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
const response = await previewNumberingCode(ruleId);
|
||||
if (response.success && response.data) {
|
||||
generatedValue = response.data.generatedCode;
|
||||
}
|
||||
// 실패 시 조용히 무시 (채번 규칙이 없어도 화면은 정상 로드)
|
||||
} catch {
|
||||
// 네트워크 에러 등 예외 상황은 조용히 무시
|
||||
} finally {
|
||||
isGeneratingRef.current = false;
|
||||
}
|
||||
} else {
|
||||
isGeneratingRef.current = false;
|
||||
}
|
||||
} else {
|
||||
// 기타 타입은 동기로 처리
|
||||
generatedValue = await AutoGenerationUtils.generateValue(testAutoGeneration, component.columnName);
|
||||
isGeneratingRef.current = false;
|
||||
}
|
||||
|
||||
if (generatedValue) {
|
||||
setAutoGeneratedValue(generatedValue);
|
||||
setOriginalAutoGeneratedValue(generatedValue); // 🆕 원본 값 저장
|
||||
hasGeneratedRef.current = true; // 생성 완료 플래그
|
||||
|
||||
// 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만)
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, generatedValue);
|
||||
|
||||
// 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함)
|
||||
if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numbering_rule_id) {
|
||||
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
||||
onFormDataChange(ruleIdKey, testAutoGeneration.options.numbering_rule_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!autoGeneratedValue) {
|
||||
// 디자인 모드에서도 미리보기용 자동생성 값 표시
|
||||
const previewValue = AutoGenerationUtils.generatePreviewValue(testAutoGeneration);
|
||||
setAutoGeneratedValue(previewValue);
|
||||
hasGeneratedRef.current = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
generateAutoValue();
|
||||
}, [testAutoGeneration.enabled, testAutoGeneration.type, component.columnName, isInteractive, isEditMode]);
|
||||
|
||||
// 실제 화면에서 숨김 처리된 컴포넌트는 렌더링하지 않음
|
||||
if (isHidden && !isDesignMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
// 입력 필드에 직접 적용할 폰트 크기
|
||||
const inputFontSize = component.style?.fontSize;
|
||||
|
||||
const componentStyle: React.CSSProperties = {
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
// 숨김 기능: 편집 모드에서만 연하게 표시
|
||||
...(isHidden &&
|
||||
isDesignMode && {
|
||||
opacity: 0.4,
|
||||
backgroundColor: "hsl(var(--muted))",
|
||||
pointerEvents: "auto",
|
||||
}),
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed hsl(var(--border))";
|
||||
componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig: _componentConfig,
|
||||
component: _component,
|
||||
isSelected: _isSelected,
|
||||
onClick: _onClick,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
autoGeneration: _autoGeneration,
|
||||
hidden: _hidden,
|
||||
isInModal: _isInModal,
|
||||
isPreview: _isPreview,
|
||||
originalData: _originalData,
|
||||
_originalData: __originalData,
|
||||
_initialData: __initialData,
|
||||
_groupedData: __groupedData,
|
||||
allComponents: _allComponents,
|
||||
selectedRows: _selectedRows,
|
||||
selectedRowsData: _selectedRowsData,
|
||||
refreshKey: _refreshKey,
|
||||
onUpdateLayout: _onUpdateLayout,
|
||||
onSelectedRowsChange: _onSelectedRowsChange,
|
||||
onConfigChange: _onConfigChange,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(domProps);
|
||||
|
||||
// webType에 따른 실제 input type 및 검증 규칙 결정
|
||||
const webType = component.componentConfig?.webType || "text";
|
||||
const inputType = (() => {
|
||||
switch (webType) {
|
||||
case "email":
|
||||
return "email";
|
||||
case "tel":
|
||||
return "tel";
|
||||
case "url":
|
||||
return "url";
|
||||
case "password":
|
||||
return "password";
|
||||
case "textarea":
|
||||
return "text"; // textarea는 별도 처리
|
||||
case "text":
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
})();
|
||||
|
||||
// webType별 검증 패턴
|
||||
const validationPattern = (() => {
|
||||
switch (webType) {
|
||||
case "tel":
|
||||
// 한국 전화번호 형식: 010-1234-5678, 02-1234-5678 등
|
||||
return "[0-9]{2,3}-[0-9]{3,4}-[0-9]{4}";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
// webType별 placeholder
|
||||
const defaultPlaceholder = (() => {
|
||||
switch (webType) {
|
||||
case "email":
|
||||
return "example@email.com";
|
||||
case "tel":
|
||||
return "010-1234-5678";
|
||||
case "url":
|
||||
return "https://example.com";
|
||||
case "password":
|
||||
return "비밀번호를 입력하세요";
|
||||
case "textarea":
|
||||
return "내용을 입력하세요";
|
||||
default:
|
||||
return "텍스트를 입력하세요";
|
||||
}
|
||||
})();
|
||||
|
||||
// 이메일 입력 상태 (username@domain 분리)
|
||||
const [emailUsername, setEmailUsername] = React.useState("");
|
||||
const [emailDomain, setEmailDomain] = React.useState("gmail.com");
|
||||
const [emailDomainOpen, setEmailDomainOpen] = React.useState(false);
|
||||
|
||||
// 전화번호 입력 상태 (3개 부분으로 분리)
|
||||
const [telPart1, setTelPart1] = React.useState("");
|
||||
const [telPart2, setTelPart2] = React.useState("");
|
||||
const [telPart3, setTelPart3] = React.useState("");
|
||||
|
||||
// URL 입력 상태 (프로토콜 + 도메인)
|
||||
const [urlProtocol, setUrlProtocol] = React.useState("https://");
|
||||
const [urlDomain, setUrlDomain] = React.useState("");
|
||||
|
||||
// 이메일 도메인 목록
|
||||
const emailDomains = ["gmail.com", "naver.com", "daum.net", "kakao.com", "직접입력"];
|
||||
|
||||
// 이메일 값 동기화
|
||||
React.useEffect(() => {
|
||||
if (webType === "email") {
|
||||
const currentValue =
|
||||
isInteractive && formData && component.columnName ? formData[component.columnName] : component.value || "";
|
||||
|
||||
if (currentValue && typeof currentValue === "string" && currentValue.includes("@")) {
|
||||
const [username, domain] = currentValue.split("@");
|
||||
setEmailUsername(username || "");
|
||||
setEmailDomain(domain || "gmail.com");
|
||||
}
|
||||
}
|
||||
}, [webType, component.value, formData, component.columnName, isInteractive]);
|
||||
|
||||
// 전화번호 값 동기화
|
||||
React.useEffect(() => {
|
||||
if (webType === "tel") {
|
||||
const currentValue =
|
||||
isInteractive && formData && component.columnName ? formData[component.columnName] : component.value || "";
|
||||
|
||||
if (currentValue && typeof currentValue === "string") {
|
||||
const parts = currentValue.split("-");
|
||||
setTelPart1(parts[0] || "");
|
||||
setTelPart2(parts[1] || "");
|
||||
setTelPart3(parts[2] || "");
|
||||
}
|
||||
}
|
||||
}, [webType, component.value, formData, component.columnName, isInteractive]);
|
||||
|
||||
// URL 값 동기화
|
||||
React.useEffect(() => {
|
||||
if (webType === "url") {
|
||||
const currentValue =
|
||||
isInteractive && formData && component.columnName ? formData[component.columnName] : component.value || "";
|
||||
|
||||
if (currentValue && typeof currentValue === "string") {
|
||||
if (currentValue.startsWith("https://")) {
|
||||
setUrlProtocol("https://");
|
||||
setUrlDomain(currentValue.substring(8));
|
||||
} else if (currentValue.startsWith("http://")) {
|
||||
setUrlProtocol("http://");
|
||||
setUrlDomain(currentValue.substring(7));
|
||||
} else {
|
||||
setUrlDomain(currentValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [webType, component.value, formData, component.columnName, isInteractive]);
|
||||
|
||||
// 이메일 타입 전용 UI
|
||||
if (webType === "email") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-2">
|
||||
{/* 사용자명 입력 */}
|
||||
<input
|
||||
type="text"
|
||||
value={emailUsername}
|
||||
placeholder="사용자명"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const newUsername = e.target.value;
|
||||
setEmailUsername(newUsername);
|
||||
const fullEmail = `${newUsername}@${emailDomain}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, fullEmail);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
|
||||
{/* @ 구분자 */}
|
||||
<span className="text-muted-foreground text-base font-medium">@</span>
|
||||
|
||||
{/* 도메인 선택/입력 (Combobox) */}
|
||||
<Popover open={emailDomainOpen} onOpenChange={setEmailDomainOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={emailDomainOpen}
|
||||
disabled={componentConfig.disabled || false}
|
||||
className={cn(
|
||||
"flex h-full flex-1 items-center justify-between rounded-md border px-3 py-2 text-sm transition-all duration-200",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"hover:border-ring/80",
|
||||
emailDomainOpen && "border-ring ring-ring/50 ring-2",
|
||||
)}
|
||||
>
|
||||
<span className={cn("truncate", !emailDomain && "text-muted-foreground")}>
|
||||
{emailDomain || "도메인 선택"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="도메인 검색 또는 입력..."
|
||||
value={emailDomain}
|
||||
onValueChange={(value) => {
|
||||
setEmailDomain(value);
|
||||
const fullEmail = `${emailUsername}@${value}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, fullEmail);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>직접 입력한 도메인: {emailDomain}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{emailDomains
|
||||
.filter((d) => d !== "직접입력")
|
||||
.map((domain) => (
|
||||
<CommandItem
|
||||
key={domain}
|
||||
value={domain}
|
||||
onSelect={(currentValue) => {
|
||||
setEmailDomain(currentValue);
|
||||
const fullEmail = `${emailUsername}@${currentValue}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, fullEmail);
|
||||
}
|
||||
setEmailDomainOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", emailDomain === domain ? "opacity-100" : "opacity-0")} />
|
||||
{domain}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 전화번호 타입 전용 UI
|
||||
if (webType === "tel") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-1.5">
|
||||
{/* 첫 번째 부분 (지역번호) */}
|
||||
<input
|
||||
type="text"
|
||||
value={telPart1}
|
||||
placeholder="010"
|
||||
maxLength={3}
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/[^0-9]/g, "");
|
||||
setTelPart1(value);
|
||||
const fullTel = `${value}-${telPart2}-${telPart3}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, fullTel);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
|
||||
<span className="text-muted-foreground text-base font-medium">-</span>
|
||||
|
||||
{/* 두 번째 부분 */}
|
||||
<input
|
||||
type="text"
|
||||
value={telPart2}
|
||||
placeholder="1234"
|
||||
maxLength={4}
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/[^0-9]/g, "");
|
||||
setTelPart2(value);
|
||||
const fullTel = `${telPart1}-${value}-${telPart3}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, fullTel);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
|
||||
<span className="text-muted-foreground text-base font-medium">-</span>
|
||||
|
||||
{/* 세 번째 부분 */}
|
||||
<input
|
||||
type="text"
|
||||
value={telPart3}
|
||||
placeholder="5678"
|
||||
maxLength={4}
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/[^0-9]/g, "");
|
||||
setTelPart3(value);
|
||||
const fullTel = `${telPart1}-${telPart2}-${value}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, fullTel);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// URL 타입 전용 UI
|
||||
if (webType === "url") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-1">
|
||||
{/* 프로토콜 선택 */}
|
||||
<select
|
||||
value={urlProtocol}
|
||||
disabled={componentConfig.disabled || false}
|
||||
onChange={(e) => {
|
||||
const newProtocol = e.target.value;
|
||||
setUrlProtocol(newProtocol);
|
||||
const fullUrl = `${newProtocol}${urlDomain}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, fullUrl);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full w-[100px] cursor-pointer rounded-md border px-2 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<option value="https://">https://</option>
|
||||
<option value="http://">http://</option>
|
||||
</select>
|
||||
|
||||
{/* 도메인 입력 */}
|
||||
<input
|
||||
type="text"
|
||||
value={urlDomain}
|
||||
placeholder="www.example.com"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const newDomain = e.target.value;
|
||||
setUrlDomain(newDomain);
|
||||
const fullUrl = `${urlProtocol}${newDomain}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, fullUrl);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// textarea 타입인 경우 별도 렌더링
|
||||
if (webType === "textarea") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={(() => {
|
||||
let displayValue = "";
|
||||
|
||||
if (isInteractive && formData && component.columnName) {
|
||||
displayValue = formData[component.columnName] || autoGeneratedValue || "";
|
||||
} else {
|
||||
displayValue = component.value || autoGeneratedValue || "";
|
||||
}
|
||||
|
||||
return displayValue;
|
||||
})()}
|
||||
placeholder={
|
||||
testAutoGeneration.enabled && testAutoGeneration.type !== "none"
|
||||
? `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
|
||||
: componentConfig.placeholder || defaultPlaceholder
|
||||
}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, e.target.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"box-border h-full w-full max-w-full resize-none rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 수동/자동 모드 표시 배지 */}
|
||||
{testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && (
|
||||
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
isManualMode
|
||||
? "bg-amber-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
: "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary/80",
|
||||
)}
|
||||
>
|
||||
{isManualMode ? "수동" : "자동"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type={inputType}
|
||||
value={(() => {
|
||||
let displayValue = "";
|
||||
|
||||
if (isInteractive && formData && component.columnName) {
|
||||
// 인터랙티브 모드: formData 우선, 없으면 자동생성 값
|
||||
const rawValue = formData[component.columnName] ?? autoGeneratedValue ?? "";
|
||||
// 객체인 경우 빈 문자열로 변환 (에러 방지)
|
||||
displayValue = typeof rawValue === "object" ? "" : String(rawValue);
|
||||
} else {
|
||||
// 디자인 모드: component.value 우선, 없으면 자동생성 값
|
||||
const rawValue = component.value || autoGeneratedValue || "";
|
||||
displayValue = typeof rawValue === "object" ? "" : String(rawValue);
|
||||
}
|
||||
|
||||
return displayValue;
|
||||
})()}
|
||||
placeholder={
|
||||
testAutoGeneration.enabled && testAutoGeneration.type !== "none"
|
||||
? isManualMode
|
||||
? "수동 입력 모드"
|
||||
: `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
|
||||
: componentConfig.placeholder || defaultPlaceholder
|
||||
}
|
||||
pattern={validationPattern}
|
||||
title={
|
||||
webType === "tel"
|
||||
? "전화번호 형식: 010-1234-5678"
|
||||
: isManualMode
|
||||
? `${component.label} (수동 입력 모드 - 채번 규칙 미적용)`
|
||||
: component.label
|
||||
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
|
||||
: component.columnName || undefined
|
||||
}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
className={cn(
|
||||
"box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||
componentConfig.disabled
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
onClick={(e) => {
|
||||
handleClick(e);
|
||||
}}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
// console.log("🎯 TextInputComponent onChange 호출:", {
|
||||
// componentId: component.id,
|
||||
// columnName: component.columnName,
|
||||
// newValue,
|
||||
// isInteractive,
|
||||
// hasOnFormDataChange: !!onFormDataChange,
|
||||
// hasOnChange: !!props.onChange,
|
||||
// });
|
||||
|
||||
// 🆕 사용자 수정 감지 (자동 생성 값과 다르면 수동 모드로 전환)
|
||||
if (testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule") {
|
||||
if (originalAutoGeneratedValue && newValue !== originalAutoGeneratedValue) {
|
||||
if (!isManualMode) {
|
||||
setIsManualMode(true);
|
||||
console.log("🔄 수동 모드로 전환:", {
|
||||
field: component.columnName,
|
||||
original: originalAutoGeneratedValue,
|
||||
modified: newValue,
|
||||
});
|
||||
|
||||
// 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함)
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
||||
onFormDataChange(ruleIdKey, null);
|
||||
console.log("🗑️ 채번 규칙 ID 제거 (수동 모드):", ruleIdKey);
|
||||
}
|
||||
}
|
||||
} else if (isManualMode && newValue === originalAutoGeneratedValue) {
|
||||
// 사용자가 원본 값으로 되돌렸을 때 자동 모드로 복구
|
||||
setIsManualMode(false);
|
||||
console.log("🔄 자동 모드로 복구:", {
|
||||
field: component.columnName,
|
||||
value: newValue,
|
||||
});
|
||||
|
||||
// 채번 규칙 ID 복구
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
const ruleId = testAutoGeneration.options?.numbering_rule_id;
|
||||
if (ruleId) {
|
||||
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
||||
onFormDataChange(ruleIdKey, ruleId);
|
||||
console.log("✅ 채번 규칙 ID 복구 (자동 모드):", ruleIdKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isInteractive 모드에서는 formData 업데이트
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
|
||||
// props.onChange는 DynamicComponentRenderer의 handleChange
|
||||
// 이벤트 객체 감지 및 값 추출 로직이 있으므로 안전하게 호출 가능
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* TextInput 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const TextInputWrapper: React.FC<TextInputComponentProps> = (props) => {
|
||||
return <TextInputComponent {...props} />;
|
||||
};
|
||||
@@ -1,391 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { TextInputConfig } from "./types";
|
||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
export interface TextInputConfigPanelProps {
|
||||
config: TextInputConfig;
|
||||
onChange: (config: Partial<TextInputConfig>) => void;
|
||||
screenTableName?: string; // 🆕 현재 화면의 테이블명
|
||||
menuObjid?: number; // 🆕 메뉴 OBJID (사용자 선택)
|
||||
}
|
||||
|
||||
/**
|
||||
* TextInput 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange, screenTableName, menuObjid }) => {
|
||||
// 채번 규칙 목록 상태
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
|
||||
// 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
|
||||
const [parentMenus, setParentMenus] = useState<any[]>([]);
|
||||
|
||||
// useState 초기값에서 저장된 값 복원 (우선순위: 저장된 값 > menuObjid prop)
|
||||
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
|
||||
return (config.autoGeneration as any)?.selectedMenuObjid || menuObjid;
|
||||
});
|
||||
|
||||
const [loadingMenus, setLoadingMenus] = useState(false);
|
||||
|
||||
// 부모 메뉴 목록 로드 (사용자 메뉴의 레벨 2만)
|
||||
useEffect(() => {
|
||||
const loadMenus = async () => {
|
||||
setLoadingMenus(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get("/admin/menus");
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const allMenus = response.data.data;
|
||||
|
||||
// 사용자 메뉴(menu_type='1')의 레벨 2만 필터링
|
||||
const level2UserMenus = allMenus.filter((menu: any) =>
|
||||
menu.menu_type === '1' && menu.lev === 2
|
||||
);
|
||||
|
||||
setParentMenus(level2UserMenus);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("부모 메뉴 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingMenus(false);
|
||||
}
|
||||
};
|
||||
loadMenus();
|
||||
}, []);
|
||||
|
||||
// 채번 규칙 목록 로드 (선택된 메뉴 기준)
|
||||
useEffect(() => {
|
||||
const loadRules = async () => {
|
||||
// autoGeneration.type이 numbering_rule이 아니면 로드하지 않음
|
||||
if (config.autoGeneration?.type !== "numbering_rule") {
|
||||
return;
|
||||
}
|
||||
|
||||
// 메뉴가 선택되지 않았으면 로드하지 않음
|
||||
if (!selectedMenuObjid) {
|
||||
setNumberingRules([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingRules(true);
|
||||
try {
|
||||
const response = await getAvailableNumberingRules(selectedMenuObjid);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 목록 로드 실패:", error);
|
||||
setNumberingRules([]);
|
||||
} finally {
|
||||
setLoadingRules(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRules();
|
||||
}, [selectedMenuObjid, config.autoGeneration?.type]);
|
||||
|
||||
const handleChange = (key: keyof TextInputConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">text-input 설정</div>
|
||||
|
||||
{/* 텍스트 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxLength">최대 길이</Label>
|
||||
<Input
|
||||
id="maxLength"
|
||||
type="number"
|
||||
value={config.maxLength || ""}
|
||||
onChange={(e) => handleChange("maxLength", parseInt(e.target.value) || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="mb-3 text-sm font-medium">고급 기능</div>
|
||||
|
||||
{/* 숨김 기능 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hidden">숨김</Label>
|
||||
<Checkbox
|
||||
id="hidden"
|
||||
checked={config.hidden || false}
|
||||
onCheckedChange={(checked) => handleChange("hidden", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 자동생성 기능 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="autoGeneration">자동생성 활성화</Label>
|
||||
<Checkbox
|
||||
id="autoGeneration"
|
||||
checked={config.autoGeneration?.enabled || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
|
||||
handleChange("autoGeneration", {
|
||||
...currentConfig,
|
||||
enabled: checked as boolean,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 자동생성 타입 선택 */}
|
||||
{config.autoGeneration?.enabled && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="autoGenerationType">자동생성 타입</Label>
|
||||
<Select
|
||||
value={config.autoGeneration?.type || "none"}
|
||||
onValueChange={(value: AutoGenerationType) => {
|
||||
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
|
||||
handleChange("autoGeneration", {
|
||||
...currentConfig,
|
||||
type: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="자동생성 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">자동생성 없음</SelectItem>
|
||||
<SelectItem value="uuid">UUID 생성</SelectItem>
|
||||
<SelectItem value="current_user">현재 사용자 ID</SelectItem>
|
||||
<SelectItem value="current_time">현재 시간</SelectItem>
|
||||
<SelectItem value="sequence">순차 번호</SelectItem>
|
||||
<SelectItem value="numbering_rule">채번 규칙</SelectItem>
|
||||
<SelectItem value="random_string">랜덤 문자열</SelectItem>
|
||||
<SelectItem value="random_number">랜덤 숫자</SelectItem>
|
||||
<SelectItem value="company_code">회사 코드</SelectItem>
|
||||
<SelectItem value="department">부서 코드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 선택된 타입 설명 */}
|
||||
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 채번 규칙 선택 */}
|
||||
{config.autoGeneration?.type === "numbering_rule" && (
|
||||
<>
|
||||
{/* 부모 메뉴 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetMenu">
|
||||
대상 메뉴 선택 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedMenuObjid?.toString() || ""}
|
||||
onValueChange={(value) => {
|
||||
const menuObjid = parseInt(value);
|
||||
setSelectedMenuObjid(menuObjid);
|
||||
|
||||
// 컴포넌트 설정에 저장하여 언마운트 시에도 유지
|
||||
handleChange("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
selectedMenuObjid: menuObjid,
|
||||
});
|
||||
}}
|
||||
disabled={loadingMenus}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingMenus ? "메뉴 로딩 중..." : "채번규칙을 사용할 메뉴 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentMenus.length === 0 ? (
|
||||
<SelectItem value="no-menus" disabled>
|
||||
사용 가능한 메뉴가 없습니다
|
||||
</SelectItem>
|
||||
) : (
|
||||
parentMenus.map((menu) => (
|
||||
<SelectItem key={menu.objid} value={menu.objid.toString()}>
|
||||
{menu.menu_name_kor}
|
||||
{menu.menu_name_eng && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
({menu.menu_name_eng})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
이 입력 필드가 어느 메뉴에 속할지 선택하세요 (해당 메뉴의 채번규칙이 적용됩니다)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 채번 규칙 선택 (메뉴 선택 후) */}
|
||||
{selectedMenuObjid ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numberingRuleId">
|
||||
채번 규칙 선택 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.autoGeneration?.options?.numbering_rule_id || ""}
|
||||
onValueChange={(value) => {
|
||||
const currentConfig = config.autoGeneration!;
|
||||
handleChange("autoGeneration", {
|
||||
...currentConfig,
|
||||
options: {
|
||||
...currentConfig.options,
|
||||
numbering_rule_id: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={loadingRules}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{numberingRules.length === 0 ? (
|
||||
<SelectItem value="no-rules" disabled>
|
||||
선택된 메뉴에 사용 가능한 규칙이 없습니다
|
||||
</SelectItem>
|
||||
) : (
|
||||
numberingRules.map((rule) => (
|
||||
<SelectItem key={rule.rule_id} value={rule.rule_id}>
|
||||
{rule.rule_name}
|
||||
{rule.description && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
- {rule.description}
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
선택된 메뉴 및 형제 메뉴에서 사용 가능한 채번 규칙만 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
먼저 대상 메뉴를 선택하세요
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자동생성 옵션 */}
|
||||
{config.autoGeneration?.enabled &&
|
||||
config.autoGeneration?.type &&
|
||||
["random_string", "random_number", "sequence"].includes(config.autoGeneration.type) && (
|
||||
<div className="space-y-2">
|
||||
<Label>자동생성 옵션</Label>
|
||||
|
||||
{/* 길이 설정 (랜덤 문자열/숫자용) */}
|
||||
{["random_string", "random_number"].includes(config.autoGeneration.type) && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="autoGenLength" className="text-xs">
|
||||
길이
|
||||
</Label>
|
||||
<Input
|
||||
id="autoGenLength"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={
|
||||
config.autoGeneration?.options?.length || (config.autoGeneration.type === "random_string" ? 8 : 6)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const currentConfig = config.autoGeneration!;
|
||||
handleChange("autoGeneration", {
|
||||
...currentConfig,
|
||||
options: {
|
||||
...currentConfig.options,
|
||||
length: parseInt(e.target.value) || 8,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 접두사 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="autoGenPrefix" className="text-xs">
|
||||
접두사
|
||||
</Label>
|
||||
<Input
|
||||
id="autoGenPrefix"
|
||||
value={config.autoGeneration?.options?.prefix || ""}
|
||||
onChange={(e) => {
|
||||
const currentConfig = config.autoGeneration!;
|
||||
handleChange("autoGeneration", {
|
||||
...currentConfig,
|
||||
options: {
|
||||
...currentConfig.options,
|
||||
prefix: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 접미사 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="autoGenSuffix" className="text-xs">
|
||||
접미사
|
||||
</Label>
|
||||
<Input
|
||||
id="autoGenSuffix"
|
||||
value={config.autoGeneration?.options?.suffix || ""}
|
||||
onChange={(e) => {
|
||||
const currentConfig = config.autoGeneration!;
|
||||
handleChange("autoGeneration", {
|
||||
...currentConfig,
|
||||
options: {
|
||||
...currentConfig.options,
|
||||
suffix: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">미리보기</Label>
|
||||
<div className="rounded border bg-muted p-2 text-xs">
|
||||
{AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { TextInputDefinition } from "./index";
|
||||
import { TextInputComponent } from "./TextInputComponent";
|
||||
|
||||
/**
|
||||
* TextInput 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class TextInputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = TextInputDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <TextInputComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// text 타입 특화 속성 처리
|
||||
protected getTextInputProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// text 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 text 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
TextInputRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
TextInputRenderer.enableHotReload();
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TextInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
* TextInput 컴포넌트 기본 설정
|
||||
*/
|
||||
export const TextInputDefaultConfig: TextInputConfig = {
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* TextInput 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const TextInputConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
maxLength: { type: "number", min: 1 },
|
||||
minLength: { type: "number", min: 0 },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { TextInputWrapper } from "./TextInputComponent";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { TextInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
* TextInput 컴포넌트 정의
|
||||
* text-input 컴포넌트입니다
|
||||
*/
|
||||
export const TextInputDefinition = createComponentDefinition({
|
||||
id: "text-input",
|
||||
name: "텍스트 입력",
|
||||
name_eng: "TextInput Component",
|
||||
description: "텍스트 입력을 위한 기본 입력 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
web_type: "text",
|
||||
component: TextInputWrapper,
|
||||
default_config: {
|
||||
kind: "input",
|
||||
type: "text",
|
||||
format: "free",
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
},
|
||||
default_size: { width: 300, height: 40 },
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: ["텍스트", "입력", "폼"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/text-input",
|
||||
});
|
||||
|
||||
// 컴포넌트는 TextInputRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { TextInputConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { TextInputComponent } from "./TextInputComponent";
|
||||
export { TextInputRenderer } from "./TextInputRenderer";
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
import { AutoGenerationConfig } from "@/types/screen";
|
||||
|
||||
/**
|
||||
* TextInput 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface TextInputConfig extends ComponentConfig {
|
||||
// 텍스트 관련 설정
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
|
||||
// 새로운 기능들
|
||||
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
|
||||
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
||||
}
|
||||
|
||||
/**
|
||||
* TextInput 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface TextInputProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: TextInputConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
# TextareaBasic 컴포넌트
|
||||
|
||||
textarea-basic 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `textarea-basic`
|
||||
- **카테고리**: input
|
||||
- **웹타입**: textarea
|
||||
- **작성자**: 개발팀
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { TextareaBasicComponent } from "@/lib/registry/components/textarea-basic";
|
||||
|
||||
<TextareaBasicComponent
|
||||
component={{
|
||||
id: "my-textarea-basic",
|
||||
type: "widget",
|
||||
webType: "textarea",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 200, height: 80 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| rows | number | 3 | 표시할 행 수 |
|
||||
| maxLength | number | 1000 | 최대 입력 길이 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<TextareaBasicComponent
|
||||
component={{
|
||||
id: "sample-textarea-basic",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js textarea-basic --category=input --webType=textarea`
|
||||
- **경로**: `lib/registry/components/textarea-basic/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/textarea-basic)
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { TextareaBasicConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
|
||||
export interface TextareaBasicComponentProps extends ComponentRendererProps {
|
||||
config?: TextareaBasicConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* TextareaBasic 컴포넌트
|
||||
* textarea-basic 컴포넌트입니다
|
||||
*/
|
||||
export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as TextareaBasicConfig;
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링 - 모든 커스텀 props 제거
|
||||
// domProps를 사용하지 않고 필요한 props만 명시적으로 전달
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: getAdaptiveLabelColor(component.style?.labelColor),
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={component.value || ""}
|
||||
placeholder={componentConfig.placeholder || ""}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
rows={componentConfig.rows || 3}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: component.style?.fontSize || "14px",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "#f97316";
|
||||
e.target.style.boxShadow = "0 0 0 3px rgba(249, 115, 22, 0.1)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "#d1d5db";
|
||||
e.target.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* TextareaBasic 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const TextareaBasicWrapper: React.FC<TextareaBasicComponentProps> = (props) => {
|
||||
return <TextareaBasicComponent {...props} />;
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { TextareaBasicConfig } from "./types";
|
||||
|
||||
export interface TextareaBasicConfigPanelProps {
|
||||
config: TextareaBasicConfig;
|
||||
onChange: (config: Partial<TextareaBasicConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TextareaBasic 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const TextareaBasicConfigPanel: React.FC<TextareaBasicConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof TextareaBasicConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
textarea-basic 설정
|
||||
</div>
|
||||
|
||||
{/* 텍스트영역 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rows">행 수</Label>
|
||||
<Input
|
||||
id="rows"
|
||||
type="number"
|
||||
value={config.rows || 3}
|
||||
onChange={(e) => handleChange("rows", parseInt(e.target.value) || 3)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { TextareaBasicDefinition } from "./index";
|
||||
import { TextareaBasicComponent } from "./TextareaBasicComponent";
|
||||
|
||||
/**
|
||||
* TextareaBasic 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class TextareaBasicRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = TextareaBasicDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <TextareaBasicComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// textarea 타입 특화 속성 처리
|
||||
protected getTextareaBasicProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// textarea 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 textarea 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
TextareaBasicRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
TextareaBasicRenderer.enableHotReload();
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TextareaBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* TextareaBasic 컴포넌트 기본 설정
|
||||
*/
|
||||
export const TextareaBasicDefaultConfig: TextareaBasicConfig = {
|
||||
placeholder: "내용을 입력하세요",
|
||||
rows: 3,
|
||||
maxLength: 1000,
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* TextareaBasic 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const TextareaBasicConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
rows: { type: "number", default: 3, min: 1, max: 20 },
|
||||
cols: { type: "number", min: 1 },
|
||||
maxLength: { type: "number", min: 1 },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { TextareaBasicWrapper } from "./TextareaBasicComponent";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { TextareaBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
* TextareaBasic 컴포넌트 정의
|
||||
* textarea-basic 컴포넌트입니다
|
||||
*/
|
||||
export const TextareaBasicDefinition = createComponentDefinition({
|
||||
id: "textarea-basic",
|
||||
name: "텍스트 영역",
|
||||
name_eng: "TextareaBasic Component",
|
||||
description: "여러 줄 텍스트 입력을 위한 텍스트 영역 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
web_type: "textarea",
|
||||
component: TextareaBasicWrapper,
|
||||
default_config: {
|
||||
kind: "input",
|
||||
type: "text",
|
||||
format: "free",
|
||||
placeholder: "내용을 입력하세요",
|
||||
rows: 3,
|
||||
maxLength: 1000,
|
||||
},
|
||||
default_size: { width: 400, height: 100 },
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/textarea-basic",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { TextareaBasicConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { TextareaBasicComponent } from "./TextareaBasicComponent";
|
||||
export { TextareaBasicRenderer } from "./TextareaBasicRenderer";
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* TextareaBasic 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface TextareaBasicConfig extends ComponentConfig {
|
||||
// 텍스트영역 관련 설정
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
maxLength?: number;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TextareaBasic 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface TextareaBasicProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: TextareaBasicConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
# ToggleSwitch 컴포넌트
|
||||
|
||||
toggle-switch 컴포넌트입니다
|
||||
|
||||
## 개요
|
||||
|
||||
- **ID**: `toggle-switch`
|
||||
- **카테고리**: input
|
||||
- **웹타입**: boolean
|
||||
- **작성자**: Developer
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 특징
|
||||
|
||||
- ✅ 자동 등록 시스템
|
||||
- ✅ 타입 안전성
|
||||
- ✅ Hot Reload 지원
|
||||
- ✅ 설정 패널 제공
|
||||
- ✅ 반응형 디자인
|
||||
|
||||
## 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { ToggleSwitchComponent } from "@/lib/registry/components/toggle-switch";
|
||||
|
||||
<ToggleSwitchComponent
|
||||
component={{
|
||||
id: "my-toggle-switch",
|
||||
type: "widget",
|
||||
web_type: "boolean",
|
||||
position: { x: 100, y: 100, z: 1 },
|
||||
size: { width: 200, height: 36 },
|
||||
config: {
|
||||
// 설정값들
|
||||
}
|
||||
}}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
```
|
||||
|
||||
### 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| placeholder | string | "" | 플레이스홀더 텍스트 |
|
||||
| disabled | boolean | false | 비활성화 여부 |
|
||||
| required | boolean | false | 필수 입력 여부 |
|
||||
| readonly | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 이벤트
|
||||
|
||||
- `onChange`: 값 변경 시
|
||||
- `onFocus`: 포커스 시
|
||||
- `onBlur`: 포커스 해제 시
|
||||
- `onClick`: 클릭 시
|
||||
|
||||
## 스타일링
|
||||
|
||||
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
|
||||
|
||||
- `variant`: "default" | "outlined" | "filled"
|
||||
- `size`: "sm" | "md" | "lg"
|
||||
|
||||
## 예시
|
||||
|
||||
```tsx
|
||||
// 기본 예시
|
||||
<ToggleSwitchComponent
|
||||
component={{
|
||||
id: "sample-toggle-switch",
|
||||
config: {
|
||||
placeholder: "입력하세요",
|
||||
required: true,
|
||||
variant: "outlined"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 개발자 정보
|
||||
|
||||
- **생성일**: 2025-09-11
|
||||
- **CLI 명령어**: `node scripts/create-component.js toggle-switch --category=input --webType=boolean`
|
||||
- **경로**: `lib/registry/components/toggle-switch/`
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [개발자 문서](https://docs.example.com/components/toggle-switch)
|
||||
@@ -1,211 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ToggleSwitchConfig } from "./types";
|
||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface ToggleSwitchComponentProps extends ComponentRendererProps {
|
||||
config?: ToggleSwitchConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToggleSwitch 컴포넌트
|
||||
* toggle-switch 컴포넌트입니다
|
||||
*/
|
||||
export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as ToggleSwitchConfig;
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig: _componentConfig,
|
||||
component: _component,
|
||||
isSelected: _isSelected,
|
||||
onClick: _onClick,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: getAdaptiveLabelColor(component.style?.labelColor),
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e5e7eb",
|
||||
backgroundColor: "#f9fafb",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#e5e7eb";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "48px",
|
||||
height: "24px",
|
||||
backgroundColor: component.value === true ? "#3b82f6" : "#d1d5db",
|
||||
borderRadius: "12px",
|
||||
transition: "background-color 0.2s",
|
||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
||||
opacity: componentConfig.disabled ? 0.5 : 1,
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={component.value === true || component.value === "true"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
opacity: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: "pointer",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: component.value === true ? "26px" : "2px",
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "50%",
|
||||
transition: "left 0.2s",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
color: "hsl(var(--foreground))",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{componentConfig.toggleLabel || (component.value ? "켜짐" : "꺼짐")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* ToggleSwitch 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const ToggleSwitchWrapper: React.FC<ToggleSwitchComponentProps> = (props) => {
|
||||
return <ToggleSwitchComponent {...props} />;
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ToggleSwitchConfig } from "./types";
|
||||
|
||||
export interface ToggleSwitchConfigPanelProps {
|
||||
config: ToggleSwitchConfig;
|
||||
onChange: (config: Partial<ToggleSwitchConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToggleSwitch 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const ToggleSwitchConfigPanel: React.FC<ToggleSwitchConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof ToggleSwitchConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
toggle-switch 설정
|
||||
</div>
|
||||
|
||||
{/* boolean 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ToggleSwitchDefinition } from "./index";
|
||||
import { ToggleSwitchComponent } from "./ToggleSwitchComponent";
|
||||
|
||||
/**
|
||||
* ToggleSwitch 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class ToggleSwitchRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = ToggleSwitchDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <ToggleSwitchComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// boolean 타입 특화 속성 처리
|
||||
protected getToggleSwitchProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// boolean 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 boolean 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
ToggleSwitchRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
ToggleSwitchRenderer.enableHotReload();
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ToggleSwitchConfig } from "./types";
|
||||
|
||||
/**
|
||||
* ToggleSwitch 컴포넌트 기본 설정
|
||||
*/
|
||||
export const ToggleSwitchDefaultConfig: ToggleSwitchConfig = {
|
||||
placeholder: "입력하세요",
|
||||
|
||||
// 공통 기본값
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
variant: "default",
|
||||
size: "md",
|
||||
};
|
||||
|
||||
/**
|
||||
* ToggleSwitch 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const ToggleSwitchConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
|
||||
// 공통 스키마
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
},
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { ToggleSwitchWrapper } from "./ToggleSwitchComponent";
|
||||
import { ToggleSwitchConfigPanel } from "./ToggleSwitchConfigPanel";
|
||||
import { ToggleSwitchConfig } from "./types";
|
||||
|
||||
/**
|
||||
* ToggleSwitch 컴포넌트 정의
|
||||
* toggle-switch 컴포넌트입니다
|
||||
*/
|
||||
export const ToggleSwitchDefinition = createComponentDefinition({
|
||||
id: "toggle-switch",
|
||||
name: "토글 스위치",
|
||||
name_eng: "ToggleSwitch Component",
|
||||
description: "ON/OFF 상태 전환을 위한 토글 스위치 컴포넌트",
|
||||
category: ComponentCategory.FORM,
|
||||
web_type: "boolean",
|
||||
component: ToggleSwitchWrapper,
|
||||
default_config: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
default_size: { width: 180, height: 40 },
|
||||
config_panel: ToggleSwitchConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "Developer",
|
||||
documentation: "https://docs.example.com/components/toggle-switch",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { ToggleSwitchConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { ToggleSwitchComponent } from "./ToggleSwitchComponent";
|
||||
export { ToggleSwitchRenderer } from "./ToggleSwitchRenderer";
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* ToggleSwitch 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface ToggleSwitchConfig extends ComponentConfig {
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
|
||||
// 스타일 관련
|
||||
variant?: "default" | "outlined" | "filled";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
// 이벤트 관련
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToggleSwitch 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface ToggleSwitchProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: ToggleSwitchConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export type LayoutType = "vertical" | "horizontal" | "grid";
|
||||
*/
|
||||
export interface SlotComponentConfig {
|
||||
id: string;
|
||||
/** 컴포넌트 타입 (예: "text-input", "text-display") */
|
||||
/** 컴포넌트 타입 (예: "input", "title") */
|
||||
componentType: string;
|
||||
/** 컴포넌트 라벨 */
|
||||
label?: string;
|
||||
|
||||
@@ -362,12 +362,8 @@ const v2RackStructureOverridesSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// v2-numbering-rule
|
||||
const v2NumberingRuleOverridesSchema = z
|
||||
.object({
|
||||
showPreview: z.boolean().default(true),
|
||||
})
|
||||
.passthrough();
|
||||
// v2-numbering-rule — Phase F.3 (2026-05-13) 폐기. 캔버스 컴포넌트 자체가
|
||||
// Phase D 이전에 제거되었고 schema/default 도 unreachable 이므로 entry 제거.
|
||||
|
||||
// v2-category-manager
|
||||
const v2CategoryManagerOverridesSchema = z
|
||||
@@ -620,7 +616,6 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
|
||||
"v2-divider-line": v2DividerLineOverridesSchema,
|
||||
"v2-repeat-container": v2RepeatContainerOverridesSchema,
|
||||
"v2-rack-structure": v2RackStructureOverridesSchema,
|
||||
"v2-numbering-rule": v2NumberingRuleOverridesSchema,
|
||||
"v2-category-manager": v2CategoryManagerOverridesSchema,
|
||||
"v2-location-swap-selector": v2LocationSwapSelectorOverridesSchema,
|
||||
"v2-aggregation-widget": v2AggregationWidgetOverridesSchema,
|
||||
@@ -725,9 +720,6 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
||||
showPreview: true,
|
||||
showTemplate: true,
|
||||
},
|
||||
"v2-numbering-rule": {
|
||||
showPreview: true,
|
||||
},
|
||||
"v2-category-manager": {
|
||||
viewMode: "tree",
|
||||
maxDepth: 3,
|
||||
|
||||
@@ -3991,7 +3991,7 @@ export class ButtonActionExecutor {
|
||||
const findNumberingRules = (components: any[]): void => {
|
||||
for (const comp of components) {
|
||||
const compConfig = comp.componentConfig || {};
|
||||
// text-input 컴포넌트의 채번 규칙 확인
|
||||
// canonical input 컴포넌트의 채번 규칙 확인
|
||||
if (
|
||||
compConfig.autoGeneration?.type === "numbering_rule" &&
|
||||
compConfig.autoGeneration?.options?.numberingRuleId
|
||||
|
||||
@@ -33,17 +33,9 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||
"v2-repeater": () => import("@/components/v2/config-panels/InvDataConfigPanel"),
|
||||
|
||||
// ========== 기본 입력 컴포넌트 ==========
|
||||
"text-input": () => import("@/lib/registry/components/text-input/TextInputConfigPanel"),
|
||||
"number-input": () => import("@/lib/registry/components/number-input/NumberInputConfigPanel"),
|
||||
"date-input": () => import("@/lib/registry/components/date-input/DateInputConfigPanel"),
|
||||
"textarea-basic": () => import("@/lib/registry/components/textarea-basic/TextareaBasicConfigPanel"),
|
||||
"select-basic": () => import("@/lib/registry/components/select-basic/SelectBasicConfigPanel"),
|
||||
"checkbox-basic": () => import("@/lib/registry/components/checkbox-basic/CheckboxBasicConfigPanel"),
|
||||
"radio-basic": () => import("@/lib/registry/components/radio-basic/RadioBasicConfigPanel"),
|
||||
"toggle-switch": () => import("@/lib/registry/components/toggle-switch/ToggleSwitchConfigPanel"),
|
||||
// file-upload — Phase D.5 폐기. canonical input 의 InvFieldConfigPanel 이 file 분기 처리.
|
||||
"slider-basic": () => import("@/lib/registry/components/slider-basic/SliderBasicConfigPanel"),
|
||||
"test-input": () => import("@/lib/registry/components/test-input/TestInputConfigPanel"),
|
||||
// Phase E / F.1 에서 기본 입력 6종 + radio-basic + toggle-switch 가 canonical
|
||||
// input 으로 흡수 — InvFieldConfigPanel ("input") 이 단일 진실의 원천.
|
||||
// file-upload — Phase D.5 폐기. test-input — Phase F.2 폐기 (CLI demo 잔재).
|
||||
|
||||
// ========== 버튼 ==========
|
||||
"button-primary": () => import("@/components/screen/config-panels/ButtonConfigPanel"),
|
||||
@@ -146,8 +138,6 @@ const CONFIG_PANEL_ALIAS: Record<string, string> = {
|
||||
"text-display": "title",
|
||||
"button-primary": "button",
|
||||
"v2-table-search-widget": "search", "table-search-widget": "search",
|
||||
"text-input": "input", "number-input": "input", "date-input": "input",
|
||||
"select-basic": "input", "checkbox-basic": "input", "textarea-basic": "input",
|
||||
"v2-aggregation-widget": "stats", "aggregation-widget": "stats",
|
||||
"v2-status-count": "stats",
|
||||
"v2-table-list": "table", "table-list": "table",
|
||||
@@ -174,7 +164,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
||||
const module = await importFn();
|
||||
|
||||
// 모듈에서 ConfigPanel 컴포넌트 추출 (우선순위):
|
||||
// 1차: PascalCase 변환된 이름 (예: text-input -> TextInputConfigPanel)
|
||||
// 1차: PascalCase 변환된 이름 (예: mail-recipient-selector -> MailRecipientSelectorConfigPanel)
|
||||
// 2차: v2- 접두사 제거 후 PascalCase (예: v2-table-list -> TableListConfigPanel)
|
||||
// 3차: *ConfigPanel로 끝나는 첫 번째 named export
|
||||
// 4차: default export
|
||||
|
||||
@@ -36,16 +36,8 @@ const LEGACY_TO_UNIFIED: Record<string, string> = {
|
||||
'button-primary': 'button',
|
||||
'v2-table-search-widget': 'search',
|
||||
'table-search-widget': 'search',
|
||||
// V2 입력/선택 폐기 (Phase D.2, 2026-05-12) — runtime fallback 매핑 제거.
|
||||
'text-input': 'input',
|
||||
'number-input': 'input',
|
||||
'date-input': 'input',
|
||||
'select-basic': 'input',
|
||||
'checkbox-basic': 'input',
|
||||
'textarea-basic': 'input',
|
||||
'slider-basic': 'input',
|
||||
'radio-basic': 'input',
|
||||
'toggle-switch': 'input',
|
||||
// V2 입력/선택 (Phase D.2) · 기본 입력 6종 (Phase E) · radio-basic/toggle-switch
|
||||
// (Phase F.1) 폐기 — runtime alias 모두 제거.
|
||||
'v2-aggregation-widget': 'stats',
|
||||
'aggregation-widget': 'stats',
|
||||
'v2-status-count': 'stats',
|
||||
|
||||
@@ -38,6 +38,10 @@ export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
|
||||
componentType: "input",
|
||||
config: { kind: "input", type: "text", format: "url" },
|
||||
},
|
||||
color: {
|
||||
componentType: "input",
|
||||
config: { kind: "input", type: "text", format: "color" },
|
||||
},
|
||||
textarea: {
|
||||
componentType: "input",
|
||||
config: { kind: "input", type: "textarea", format: "free", rows: 3 },
|
||||
@@ -52,6 +56,10 @@ export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
|
||||
componentType: "input",
|
||||
config: { kind: "input", type: "number", format: "decimal", step: 0.01 },
|
||||
},
|
||||
slider: {
|
||||
componentType: "input",
|
||||
config: { kind: "input", type: "number", format: "slider", min: 0, max: 100, step: 1 },
|
||||
},
|
||||
|
||||
// 날짜/시간 → InputComponent (type=date/datetime/time/daterange)
|
||||
date: {
|
||||
@@ -170,8 +178,10 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
|
||||
password: "input",
|
||||
tel: "input",
|
||||
url: "input",
|
||||
color: "input",
|
||||
number: "input",
|
||||
decimal: "input",
|
||||
slider: "input",
|
||||
textarea: "input",
|
||||
date: "input",
|
||||
datetime: "input",
|
||||
|
||||
Generated
+1
-1
@@ -71,7 +71,6 @@
|
||||
"jspdf": "^3.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lightningcss": "^1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "^1.32.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "^15.4.8",
|
||||
@@ -12226,6 +12225,7 @@
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
"jspdf": "^3.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lightningcss": "^1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "^1.32.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "^15.4.8",
|
||||
|
||||
@@ -347,10 +347,9 @@ export interface V2ConfigSchema {
|
||||
// ===== 레거시 컴포넌트 → V2 컴포넌트 매핑 =====
|
||||
|
||||
export const LEGACY_TO_V2_MAP: Record<string, V2ComponentType> = {
|
||||
// Input / Select 계열은 canonical `input` 으로 흡수됨 (Phase D.3) — 이 매핑에서 제거.
|
||||
// Input / Select 계열은 canonical `input` 으로 흡수됨 (Phase D.3, Phase E) — 이 매핑에서 제거.
|
||||
|
||||
// Text 계열
|
||||
"textarea-basic": "V2Text",
|
||||
// Text 계열 — textarea-basic 은 Phase E 에서 canonical input 으로 흡수, 매핑 제거.
|
||||
|
||||
// Media 계열 — Phase D.5 에서 canonical input 으로 흡수, 매핑 제거.
|
||||
|
||||
@@ -372,8 +371,8 @@ export const LEGACY_TO_V2_MAP: Record<string, V2ComponentType> = {
|
||||
"universal-form-modal": "V2Group",
|
||||
|
||||
// Biz 계열
|
||||
// numbering-rule canvas 컴포넌트는 2026-05-11 폐기 — 매핑 제거.
|
||||
"category-manager": "V2Biz",
|
||||
"numbering-rule": "V2Biz",
|
||||
"flow-widget": "V2Biz",
|
||||
|
||||
// Button (Input 모드) — canonical `input` 으로 흡수됨 (Phase D.3), 매핑 제거
|
||||
|
||||
Reference in New Issue
Block a user