refactor(components): consolidate canonical input cleanup

This commit is contained in:
2026-05-13 02:38:29 +09:00
parent 4a8413000b
commit 7bd08dcf9d
95 changed files with 271 additions and 8597 deletions
@@ -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: "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;
+1 -1
View File
@@ -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;
}
+4 -12
View File
@@ -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;
+2 -10
View File
@@ -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,
+1 -1
View File
@@ -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
+4 -14
View File
@@ -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
+2 -10
View File
@@ -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',
+10
View File
@@ -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",
+1 -1
View File
@@ -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"
],
-1
View File
@@ -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",
+3 -4
View File
@@ -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), 매핑 제거