+ {/* 날짜 형식 */}
+
+
+ 날짜 형식
+
+ {
+ console.log("📅 날짜 형식 변경:", {
+ oldFormat: localValues.format,
+ newFormat: value,
+ oldShowTime: localValues.showTime,
+ });
+
+ // format 변경 시 showTime도 자동 동기화
+ const hasTime = value.includes("HH:mm");
+
+ // 한 번에 두 값을 모두 업데이트 - 현재 로컬 상태 기반으로 생성
+ const newConfig = JSON.parse(
+ JSON.stringify({
+ ...localValues,
+ format: value,
+ showTime: hasTime,
+ }),
+ );
+
+ console.log("🔄 format+showTime 동시 업데이트:", {
+ newFormat: value,
+ newShowTime: hasTime,
+ newConfig,
+ });
+
+ // 로컬 상태도 동시 업데이트
+ setLocalValues((prev) => ({
+ ...prev,
+ format: value,
+ showTime: hasTime,
+ }));
+
+ // 한 번에 업데이트
+ setTimeout(() => {
+ onConfigChange(newConfig);
+ }, 0);
+ }}
+ >
+
+
+
+
+ YYYY-MM-DD
+ YYYY-MM-DD HH:mm
+ YYYY-MM-DD HH:mm:ss
+
+
+
+
+ {/* 시간 표시 여부 */}
+
+
+ 시간 표시
+
+ {
+ const newShowTime = !!checked;
+ console.log("⏰ 시간 표시 체크박스 변경:", {
+ oldShowTime: localValues.showTime,
+ newShowTime,
+ currentFormat: localValues.format,
+ });
+
+ // showTime 변경 시 format도 적절히 조정
+ let newFormat = localValues.format;
+ if (newShowTime && !localValues.format.includes("HH:mm")) {
+ // 시간 표시를 켰는데 format에 시간이 없으면 기본 시간 format으로 변경
+ newFormat = "YYYY-MM-DD HH:mm";
+ } else if (!newShowTime && localValues.format.includes("HH:mm")) {
+ // 시간 표시를 껐는데 format에 시간이 있으면 날짜만 format으로 변경
+ newFormat = "YYYY-MM-DD";
+ }
+
+ console.log("🔄 showTime+format 동시 업데이트:", {
+ newShowTime,
+ oldFormat: localValues.format,
+ newFormat,
+ });
+
+ // 한 번에 두 값을 모두 업데이트 - 현재 로컬 상태 기반으로 생성
+ const newConfig = JSON.parse(
+ JSON.stringify({
+ ...localValues,
+ showTime: newShowTime,
+ format: newFormat,
+ }),
+ );
+
+ // 로컬 상태도 동시 업데이트
+ setLocalValues((prev) => ({
+ ...prev,
+ showTime: newShowTime,
+ format: newFormat,
+ }));
+
+ // 한 번에 업데이트
+ setTimeout(() => {
+ onConfigChange(newConfig);
+ }, 0);
+ }}
+ />
+
+
+ {/* 플레이스홀더 */}
+
+
+ 플레이스홀더
+
+ updateConfig("placeholder", e.target.value)}
+ placeholder="날짜를 선택하세요"
+ className="mt-1"
+ />
+
+
+ {/* 최소 날짜 */}
+
+
+ 최소 날짜
+
+ updateConfig("minDate", e.target.value)}
+ className="mt-1"
+ />
+
+
+ {/* 최대 날짜 */}
+
+
+ 최대 날짜
+
+ updateConfig("maxDate", e.target.value)}
+ className="mt-1"
+ />
+
+
+ {/* 기본값 */}
+
+
+ 기본값
+
+ updateConfig("defaultValue", e.target.value)}
+ className="mt-1"
+ />
+
+
+ );
+};
diff --git a/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx
new file mode 100644
index 00000000..a3505430
--- /dev/null
+++ b/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Badge } from "@/components/ui/badge";
+import { Search, Database, Link, X, Plus } from "lucide-react";
+import { EntityTypeConfig } from "@/types/screen";
+
+interface EntityTypeConfigPanelProps {
+ config: EntityTypeConfig;
+ onConfigChange: (config: EntityTypeConfig) => void;
+}
+
+export const EntityTypeConfigPanel: React.FC = ({ config, onConfigChange }) => {
+ // 기본값이 설정된 config 사용
+ const safeConfig = {
+ entityName: "",
+ displayField: "name",
+ valueField: "id",
+ searchable: true,
+ multiple: false,
+ allowClear: true,
+ placeholder: "",
+ apiEndpoint: "",
+ filters: [],
+ displayFormat: "simple",
+ maxSelections: undefined,
+ ...config,
+ };
+
+ // 로컬 상태로 실시간 입력 관리
+ const [localValues, setLocalValues] = useState({
+ entityName: safeConfig.entityName,
+ displayField: safeConfig.displayField,
+ valueField: safeConfig.valueField,
+ searchable: safeConfig.searchable,
+ multiple: safeConfig.multiple,
+ allowClear: safeConfig.allowClear,
+ placeholder: safeConfig.placeholder,
+ apiEndpoint: safeConfig.apiEndpoint,
+ displayFormat: safeConfig.displayFormat,
+ maxSelections: safeConfig.maxSelections?.toString() || "",
+ });
+
+ const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" });
+
+ // 표시 형식 옵션
+ const displayFormats = [
+ { value: "simple", label: "단순 (이름만)" },
+ { value: "detailed", label: "상세 (이름 + 설명)" },
+ { value: "custom", label: "사용자 정의" },
+ ];
+
+ // 필터 연산자들
+ const operators = [
+ { value: "=", label: "같음 (=)" },
+ { value: "!=", label: "다름 (!=)" },
+ { value: "like", label: "포함 (LIKE)" },
+ { value: ">", label: "초과 (>)" },
+ { value: "<", label: "미만 (<)" },
+ { value: ">=", label: "이상 (>=)" },
+ { value: "<=", label: "이하 (<=)" },
+ { value: "in", label: "포함됨 (IN)" },
+ { value: "not_in", label: "포함안됨 (NOT IN)" },
+ ];
+
+ // config가 변경될 때 로컬 상태 동기화
+ useEffect(() => {
+ setLocalValues({
+ entityName: safeConfig.entityName,
+ displayField: safeConfig.displayField,
+ valueField: safeConfig.valueField,
+ searchable: safeConfig.searchable,
+ multiple: safeConfig.multiple,
+ allowClear: safeConfig.allowClear,
+ placeholder: safeConfig.placeholder,
+ apiEndpoint: safeConfig.apiEndpoint,
+ displayFormat: safeConfig.displayFormat,
+ maxSelections: safeConfig.maxSelections?.toString() || "",
+ });
+ }, [
+ safeConfig.entityName,
+ safeConfig.displayField,
+ safeConfig.valueField,
+ safeConfig.searchable,
+ safeConfig.multiple,
+ safeConfig.allowClear,
+ safeConfig.placeholder,
+ safeConfig.apiEndpoint,
+ safeConfig.displayFormat,
+ safeConfig.maxSelections,
+ ]);
+
+ const updateConfig = (key: keyof EntityTypeConfig, value: any) => {
+ // 로컬 상태 즉시 업데이트
+ if (key === "maxSelections") {
+ setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || "" }));
+ } else {
+ setLocalValues((prev) => ({ ...prev, [key]: value }));
+ }
+
+ // 실제 config 업데이트
+ const newConfig = { ...safeConfig, [key]: value };
+ console.log("🏢 EntityTypeConfig 업데이트:", {
+ key,
+ value,
+ oldConfig: safeConfig,
+ newConfig,
+ });
+ onConfigChange(newConfig);
+ };
+
+ const addFilter = () => {
+ if (newFilter.field.trim() && newFilter.value.trim()) {
+ const updatedFilters = [...(safeConfig.filters || []), { ...newFilter }];
+ updateConfig("filters", updatedFilters);
+ setNewFilter({ field: "", operator: "=", value: "" });
+ }
+ };
+
+ const removeFilter = (index: number) => {
+ const updatedFilters = (safeConfig.filters || []).filter((_, i) => i !== index);
+ updateConfig("filters", updatedFilters);
+ };
+
+ const updateFilter = (index: number, field: keyof typeof newFilter, value: string) => {
+ const updatedFilters = [...(safeConfig.filters || [])];
+ updatedFilters[index] = { ...updatedFilters[index], [field]: value };
+ updateConfig("filters", updatedFilters);
+ };
+
+ return (
+
+ {/* 엔터티 이름 */}
+
+
+ 엔터티 이름
+
+ updateConfig("entityName", e.target.value)}
+ placeholder="예: User, Company, Product"
+ className="mt-1"
+ />
+
+
+ {/* API 엔드포인트 */}
+
+
+ API 엔드포인트
+
+ updateConfig("apiEndpoint", e.target.value)}
+ placeholder="예: /api/users"
+ className="mt-1"
+ />
+
+
+ {/* 필드 설정 */}
+
+
+ {/* 표시 형식 */}
+
+
+ 표시 형식
+
+ updateConfig("displayFormat", value)}>
+
+
+
+
+ {displayFormats.map((format) => (
+
+ {format.label}
+
+ ))}
+
+
+
+
+ {/* 플레이스홀더 */}
+
+
+ 플레이스홀더
+
+ updateConfig("placeholder", e.target.value)}
+ placeholder="엔터티를 선택하세요"
+ className="mt-1"
+ />
+
+
+ {/* 옵션들 */}
+
+
+
+ 검색 가능
+
+ updateConfig("searchable", !!checked)}
+ />
+
+
+
+
+ 다중 선택
+
+ updateConfig("multiple", !!checked)}
+ />
+
+
+
+
+ 선택 해제 허용
+
+ updateConfig("allowClear", !!checked)}
+ />
+
+
+
+ {/* 최대 선택 개수 (다중 선택 시) */}
+ {localValues.multiple && (
+
+
+ 최대 선택 개수
+
+ updateConfig("maxSelections", e.target.value ? Number(e.target.value) : undefined)}
+ className="mt-1"
+ placeholder="제한 없음"
+ />
+
+ )}
+
+ {/* 필터 관리 */}
+
+
데이터 필터
+
+ {/* 기존 필터 목록 */}
+
+
+ {/* 새 필터 추가 */}
+
+
+
총 {(safeConfig.filters || []).length}개 필터
+
+
+ {/* 미리보기 */}
+
+
미리보기
+
+
+ {localValues.searchable &&
}
+
+ {localValues.placeholder || `${localValues.entityName || "엔터티"}를 선택하세요`}
+
+
+
+
+
+ 엔터티: {localValues.entityName || "없음"}, API: {localValues.apiEndpoint || "없음"}, 값필드:{" "}
+ {localValues.valueField}, 표시필드: {localValues.displayField}
+ {localValues.multiple && `, 다중선택`}
+ {localValues.searchable && `, 검색가능`}
+
+
+
+ {/* 안내 메시지 */}
+
+
엔터티 참조 설정
+
+ • 엔터티 참조는 다른 테이블의 데이터를 선택할 때 사용됩니다
+
+ • API 엔드포인트를 통해 데이터를 동적으로 로드합니다
+
+ • 필터를 사용하여 표시할 데이터를 제한할 수 있습니다
+ • 값 필드는 실제 저장되는 값, 표시 필드는 사용자에게 보여지는 값입니다
+
+
+
+ );
+};
+
+export default EntityTypeConfigPanel;
diff --git a/frontend/components/screen/panels/webtype-configs/FileTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/FileTypeConfigPanel.tsx
new file mode 100644
index 00000000..a6b30663
--- /dev/null
+++ b/frontend/components/screen/panels/webtype-configs/FileTypeConfigPanel.tsx
@@ -0,0 +1,301 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Slider } from "@/components/ui/slider";
+import { Textarea } from "@/components/ui/textarea";
+import { X, Upload, FileText, Image, FileVideo, FileAudio } from "lucide-react";
+import { FileTypeConfig } from "@/types/screen";
+
+interface FileTypeConfigPanelProps {
+ config: FileTypeConfig;
+ onConfigChange: (config: FileTypeConfig) => void;
+}
+
+export const FileTypeConfigPanel: React.FC = ({ config, onConfigChange }) => {
+ // 기본값이 설정된 config 사용
+ const safeConfig = {
+ accept: "",
+ multiple: false,
+ maxSize: 10, // MB
+ maxFiles: 1,
+ preview: true,
+ dragDrop: true,
+ allowedExtensions: [],
+ ...config,
+ };
+
+ // 로컬 상태로 실시간 입력 관리
+ const [localValues, setLocalValues] = useState({
+ accept: safeConfig.accept,
+ multiple: safeConfig.multiple,
+ maxSize: safeConfig.maxSize,
+ maxFiles: safeConfig.maxFiles,
+ preview: safeConfig.preview,
+ dragDrop: safeConfig.dragDrop,
+ });
+
+ const [newExtension, setNewExtension] = useState("");
+
+ // 미리 정의된 파일 타입들
+ const fileTypePresets = [
+ { label: "이미지", accept: "image/*", extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"], icon: Image },
+ {
+ label: "문서",
+ accept: ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx",
+ extensions: [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"],
+ icon: FileText,
+ },
+ { label: "비디오", accept: "video/*", extensions: [".mp4", ".avi", ".mov", ".mkv"], icon: FileVideo },
+ { label: "오디오", accept: "audio/*", extensions: [".mp3", ".wav", ".ogg", ".m4a"], icon: FileAudio },
+ ];
+
+ // config가 변경될 때 로컬 상태 동기화
+ useEffect(() => {
+ setLocalValues({
+ accept: safeConfig.accept,
+ multiple: safeConfig.multiple,
+ maxSize: safeConfig.maxSize,
+ maxFiles: safeConfig.maxFiles,
+ preview: safeConfig.preview,
+ dragDrop: safeConfig.dragDrop,
+ });
+ }, [
+ safeConfig.accept,
+ safeConfig.multiple,
+ safeConfig.maxSize,
+ safeConfig.maxFiles,
+ safeConfig.preview,
+ safeConfig.dragDrop,
+ ]);
+
+ const updateConfig = (key: keyof FileTypeConfig, value: any) => {
+ // 로컬 상태 즉시 업데이트
+ setLocalValues((prev) => ({ ...prev, [key]: value }));
+
+ // 실제 config 업데이트
+ const newConfig = { ...safeConfig, [key]: value };
+ console.log("📁 FileTypeConfig 업데이트:", {
+ key,
+ value,
+ oldConfig: safeConfig,
+ newConfig,
+ });
+ onConfigChange(newConfig);
+ };
+
+ const applyFileTypePreset = (preset: (typeof fileTypePresets)[0]) => {
+ updateConfig("accept", preset.accept);
+ updateConfig("allowedExtensions", preset.extensions);
+ };
+
+ const addExtension = () => {
+ if (newExtension.trim() && !newExtension.includes(" ")) {
+ const extension = newExtension.startsWith(".") ? newExtension : `.${newExtension}`;
+ const updatedExtensions = [...(safeConfig.allowedExtensions || []), extension];
+ updateConfig("allowedExtensions", updatedExtensions);
+ setNewExtension("");
+ }
+ };
+
+ const removeExtension = (index: number) => {
+ const updatedExtensions = (safeConfig.allowedExtensions || []).filter((_, i) => i !== index);
+ updateConfig("allowedExtensions", updatedExtensions);
+ };
+
+ const formatFileSize = (sizeInMB: number) => {
+ if (sizeInMB < 1) {
+ return `${Math.round(sizeInMB * 1024)} KB`;
+ }
+ return `${sizeInMB} MB`;
+ };
+
+ return (
+
+ {/* 파일 타입 프리셋 */}
+
+
빠른 설정
+
+ {fileTypePresets.map((preset) => {
+ const IconComponent = preset.icon;
+ return (
+ applyFileTypePreset(preset)}
+ className="flex items-center space-x-2"
+ >
+
+ {preset.label}
+
+ );
+ })}
+
+
+
+ {/* Accept 속성 */}
+
+
+ 허용 파일 타입 (accept)
+
+
updateConfig("accept", e.target.value)}
+ placeholder="예: image/*,.pdf,.docx"
+ className="mt-1"
+ />
+
MIME 타입 또는 파일 확장자를 쉼표로 구분하여 입력
+
+
+ {/* 허용 확장자 관리 */}
+
+
허용 확장자
+
+ {(safeConfig.allowedExtensions || []).map((extension, index) => (
+
+ {extension}
+ removeExtension(index)} />
+
+ ))}
+
+
+ setNewExtension(e.target.value)}
+ placeholder="확장자 입력 (예: jpg)"
+ className="flex-1"
+ />
+
+ 추가
+
+
+
+
+ {/* 다중 파일 선택 */}
+
+
+ 다중 파일 선택
+
+ updateConfig("multiple", !!checked)}
+ />
+
+
+ {/* 최대 파일 크기 */}
+
+
+ 최대 파일 크기: {formatFileSize(localValues.maxSize)}
+
+
+
updateConfig("maxSize", value[0])}
+ min={0.1}
+ max={100}
+ step={0.1}
+ className="w-full"
+ />
+
+ 100 KB
+ 100 MB
+
+
+
+
+ {/* 최대 파일 개수 (다중 선택 시) */}
+ {localValues.multiple && (
+
+
+ 최대 파일 개수: {localValues.maxFiles}
+
+
+
updateConfig("maxFiles", value[0])}
+ min={1}
+ max={20}
+ step={1}
+ className="w-full"
+ />
+
+ 1
+ 20
+
+
+
+ )}
+
+ {/* 미리보기 표시 */}
+
+
+ 미리보기 표시 (이미지)
+
+ updateConfig("preview", !!checked)}
+ />
+
+
+ {/* 드래그 앤 드롭 */}
+
+
+ 드래그 앤 드롭 지원
+
+ updateConfig("dragDrop", !!checked)}
+ />
+
+
+ {/* 미리보기 */}
+
+
미리보기
+
+
+
+
+ {localValues.dragDrop ? "파일을 드래그하여 놓거나 클릭하여 선택" : "클릭하여 파일 선택"}
+
+
+ {localValues.accept && `허용 타입: ${localValues.accept}`}
+ {(safeConfig.allowedExtensions || []).length > 0 && (
+
확장자: {(safeConfig.allowedExtensions || []).join(", ")}
+ )}
+ 최대 크기: {formatFileSize(localValues.maxSize)}
+ {localValues.multiple && `, 최대 ${localValues.maxFiles}개`}
+
+
+
+
+
+ {/* 안내 메시지 */}
+
+
파일 업로드 설정
+
+ • Accept 속성은 파일 선택 다이얼로그에서 필터링됩니다
+
+ • 허용 확장자는 추가 검증에 사용됩니다
+
+ • 미리보기는 이미지 파일에만 적용됩니다
+ • 실제 파일 업로드는 서버 설정이 필요합니다
+
+
+
+ );
+};
+
+export default FileTypeConfigPanel;
diff --git a/frontend/components/screen/panels/webtype-configs/NumberTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/NumberTypeConfigPanel.tsx
new file mode 100644
index 00000000..38efca77
--- /dev/null
+++ b/frontend/components/screen/panels/webtype-configs/NumberTypeConfigPanel.tsx
@@ -0,0 +1,234 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Checkbox } from "@/components/ui/checkbox";
+import { NumberTypeConfig } from "@/types/screen";
+
+interface NumberTypeConfigPanelProps {
+ config: NumberTypeConfig;
+ onConfigChange: (config: NumberTypeConfig) => void;
+}
+
+export const NumberTypeConfigPanel: React.FC = ({ config, onConfigChange }) => {
+ // 기본값이 설정된 config 사용
+ const safeConfig = {
+ format: "integer" as const,
+ min: undefined,
+ max: undefined,
+ step: undefined,
+ decimalPlaces: undefined,
+ thousandSeparator: false,
+ prefix: "",
+ suffix: "",
+ placeholder: "",
+ ...config,
+ };
+
+ // 로컬 상태로 실시간 입력 관리
+ const [localValues, setLocalValues] = useState({
+ format: safeConfig.format,
+ min: safeConfig.min?.toString() || "",
+ max: safeConfig.max?.toString() || "",
+ step: safeConfig.step?.toString() || "",
+ decimalPlaces: safeConfig.decimalPlaces?.toString() || "",
+ thousandSeparator: safeConfig.thousandSeparator,
+ prefix: safeConfig.prefix,
+ suffix: safeConfig.suffix,
+ placeholder: safeConfig.placeholder,
+ });
+
+ // config가 변경될 때 로컬 상태 동기화
+ useEffect(() => {
+ setLocalValues({
+ format: safeConfig.format,
+ min: safeConfig.min?.toString() || "",
+ max: safeConfig.max?.toString() || "",
+ step: safeConfig.step?.toString() || "",
+ decimalPlaces: safeConfig.decimalPlaces?.toString() || "",
+ thousandSeparator: safeConfig.thousandSeparator,
+ prefix: safeConfig.prefix,
+ suffix: safeConfig.suffix,
+ placeholder: safeConfig.placeholder,
+ });
+ }, [
+ safeConfig.format,
+ safeConfig.min,
+ safeConfig.max,
+ safeConfig.step,
+ safeConfig.decimalPlaces,
+ safeConfig.thousandSeparator,
+ safeConfig.prefix,
+ safeConfig.suffix,
+ safeConfig.placeholder,
+ ]);
+
+ const updateConfig = (key: keyof NumberTypeConfig, value: any) => {
+ // 로컬 상태 즉시 업데이트
+ if (key === "min" || key === "max" || key === "step" || key === "decimalPlaces") {
+ setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || "" }));
+ } else {
+ setLocalValues((prev) => ({ ...prev, [key]: value }));
+ }
+
+ // 실제 config 업데이트 - 깊은 복사로 새 객체 보장
+ const newConfig = JSON.parse(JSON.stringify({ ...safeConfig, [key]: value }));
+ console.log("🔢 NumberTypeConfig 업데이트:", {
+ key,
+ value,
+ oldConfig: safeConfig,
+ newConfig,
+ timestamp: new Date().toISOString(),
+ });
+
+ // 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
+ setTimeout(() => {
+ onConfigChange(newConfig);
+ }, 0);
+ };
+
+ return (
+
+ {/* 숫자 형식 */}
+
+
+ 숫자 형식
+
+ updateConfig("format", value)}>
+
+
+
+
+ 정수
+ 소수
+ 통화
+ 퍼센트
+
+
+
+
+ {/* 범위 설정 */}
+
+
+ {/* 단계값 */}
+
+
+ 단계값 (증감 단위)
+
+ updateConfig("step", e.target.value ? Number(e.target.value) : undefined)}
+ className="mt-1"
+ placeholder="1"
+ />
+
+
+ {/* 소수점 자릿수 (decimal 형식인 경우) */}
+ {localValues.format === "decimal" && (
+
+
+ 소수점 자릿수
+
+ updateConfig("decimalPlaces", e.target.value ? Number(e.target.value) : undefined)}
+ className="mt-1"
+ placeholder="2"
+ />
+
+ )}
+
+ {/* 천 단위 구분자 */}
+
+
+ 천 단위 구분자 사용
+
+ updateConfig("thousandSeparator", !!checked)}
+ />
+
+
+ {/* 접두사/접미사 */}
+
+
+ {/* 플레이스홀더 */}
+
+
+ 플레이스홀더
+
+ updateConfig("placeholder", e.target.value)}
+ placeholder="숫자를 입력하세요"
+ className="mt-1"
+ />
+
+
+ );
+};
diff --git a/frontend/components/screen/panels/webtype-configs/RadioTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/RadioTypeConfigPanel.tsx
new file mode 100644
index 00000000..f16bcef5
--- /dev/null
+++ b/frontend/components/screen/panels/webtype-configs/RadioTypeConfigPanel.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Checkbox } from "@/components/ui/checkbox";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Plus, X } from "lucide-react";
+import { RadioTypeConfig } from "@/types/screen";
+
+interface RadioTypeConfigPanelProps {
+ config: RadioTypeConfig;
+ onConfigChange: (config: RadioTypeConfig) => void;
+}
+
+export const RadioTypeConfigPanel: React.FC = ({ config, onConfigChange }) => {
+ // 기본값이 설정된 config 사용
+ const safeConfig = {
+ options: [],
+ layout: "vertical" as const,
+ defaultValue: "",
+ allowNone: false,
+ ...config,
+ };
+
+ // 로컬 상태로 실시간 입력 관리
+ const [localValues, setLocalValues] = useState({
+ layout: safeConfig.layout,
+ defaultValue: safeConfig.defaultValue,
+ allowNone: safeConfig.allowNone,
+ });
+
+ const [newOption, setNewOption] = useState({ label: "", value: "" });
+
+ // 옵션들의 로컬 편집 상태
+ const [localOptions, setLocalOptions] = useState(
+ (safeConfig.options || []).map((option) => ({
+ label: option.label || "",
+ value: option.value || "",
+ })),
+ );
+
+ // config가 변경될 때 로컬 상태 동기화
+ useEffect(() => {
+ setLocalValues({
+ layout: safeConfig.layout,
+ defaultValue: safeConfig.defaultValue,
+ allowNone: safeConfig.allowNone,
+ });
+
+ setLocalOptions(
+ (safeConfig.options || []).map((option) => ({
+ label: option.label || "",
+ value: option.value || "",
+ })),
+ );
+ }, [safeConfig.layout, safeConfig.defaultValue, safeConfig.allowNone, JSON.stringify(safeConfig.options)]);
+
+ const updateConfig = (key: keyof RadioTypeConfig, value: any) => {
+ // "__none__" 값을 빈 문자열로 변환
+ const processedValue = key === "defaultValue" && value === "__none__" ? "" : value;
+
+ // 로컬 상태 즉시 업데이트
+ setLocalValues((prev) => ({ ...prev, [key]: processedValue }));
+
+ // 실제 config 업데이트 - 깊은 복사로 새 객체 보장
+ const newConfig = JSON.parse(JSON.stringify({ ...safeConfig, [key]: processedValue }));
+ console.log("📻 RadioTypeConfig 업데이트:", {
+ key,
+ value,
+ processedValue,
+ oldConfig: safeConfig,
+ newConfig,
+ timestamp: new Date().toISOString(),
+ });
+
+ // 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
+ setTimeout(() => {
+ onConfigChange(newConfig);
+ }, 0);
+ };
+
+ const addOption = () => {
+ if (newOption.label.trim() && newOption.value.trim()) {
+ const newOptionData = { ...newOption };
+ const updatedOptions = [...(safeConfig.options || []), newOptionData];
+
+ console.log("➕ RadioType 옵션 추가:", {
+ newOption: newOptionData,
+ updatedOptions,
+ currentLocalOptions: localOptions,
+ timestamp: new Date().toISOString(),
+ });
+
+ // 로컬 상태 즉시 업데이트
+ setLocalOptions((prev) => {
+ const newLocalOptions = [
+ ...prev,
+ {
+ label: newOption.label,
+ value: newOption.value,
+ },
+ ];
+ console.log("📻 RadioType 로컬 옵션 업데이트:", newLocalOptions);
+ return newLocalOptions;
+ });
+
+ updateConfig("options", updatedOptions);
+ setNewOption({ label: "", value: "" });
+ }
+ };
+
+ const removeOption = (index: number) => {
+ console.log("➖ RadioType 옵션 삭제:", {
+ removeIndex: index,
+ currentOptions: safeConfig.options,
+ currentLocalOptions: localOptions,
+ });
+
+ // 로컬 상태 즉시 업데이트
+ setLocalOptions((prev) => {
+ const newLocalOptions = prev.filter((_, i) => i !== index);
+ console.log("📻 RadioType 로컬 옵션 삭제 후:", newLocalOptions);
+ return newLocalOptions;
+ });
+
+ const updatedOptions = (safeConfig.options || []).filter((_, i) => i !== index);
+ updateConfig("options", updatedOptions);
+ };
+
+ const updateOption = (index: number, field: "label" | "value", value: string) => {
+ // 로컬 상태 즉시 업데이트 (실시간 입력 반영)
+ const updatedLocalOptions = [...localOptions];
+ updatedLocalOptions[index] = { ...updatedLocalOptions[index], [field]: value };
+ setLocalOptions(updatedLocalOptions);
+
+ // 실제 config 업데이트
+ const updatedOptions = [...(safeConfig.options || [])];
+ updatedOptions[index] = { ...updatedOptions[index], [field]: value };
+ updateConfig("options", updatedOptions);
+ };
+
+ return (
+
+ {/* 레이아웃 방향 */}
+
+
+ 레이아웃 방향
+
+ updateConfig("layout", value)}>
+
+
+
+
+ 세로
+ 가로
+ 격자 (2열)
+
+
+
+
+ {/* 기본값 */}
+
+
+ 기본 선택값
+
+ updateConfig("defaultValue", value)}
+ >
+
+
+
+
+ 선택 안함
+ {(safeConfig.options || []).map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ {/* 선택 안함 허용 */}
+
+
+ 선택 해제 허용
+
+ updateConfig("allowNone", !!checked)}
+ />
+
+
+ {/* 옵션 관리 */}
+
+
옵션 관리
+
+ {/* 기존 옵션 목록 */}
+
+
+ {/* 새 옵션 추가 */}
+
+
+
총 {(safeConfig.options || []).length}개 옵션
+
+
+ {/* 미리보기 */}
+ {(safeConfig.options || []).length > 0 && (
+
+
미리보기
+
+
+ {(safeConfig.options || []).map((option) => (
+
+
+
+ {option.label}
+
+
+ ))}
+
+
+
+ 레이아웃:{" "}
+ {localValues.layout === "vertical" ? "세로" : localValues.layout === "horizontal" ? "가로" : "격자"},
+ 기본값: {localValues.defaultValue || "없음"}
+ {localValues.allowNone && ", 선택해제 가능"}
+
+
+ )}
+
+ {/* 안내 메시지 */}
+ {localValues.allowNone && (
+
+
선택 해제 허용
+
+ 사용자가 같은 라디오 버튼을 다시 클릭하여 선택을 해제할 수 있습니다.
+
+
+ )}
+
+ );
+};
+
+export default RadioTypeConfigPanel;
diff --git a/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx
new file mode 100644
index 00000000..ca8cdf1a
--- /dev/null
+++ b/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx
@@ -0,0 +1,290 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Plus, X } from "lucide-react";
+import { SelectTypeConfig } from "@/types/screen";
+
+interface SelectTypeConfigPanelProps {
+ config: SelectTypeConfig;
+ onConfigChange: (config: SelectTypeConfig) => void;
+}
+
+export const SelectTypeConfigPanel: React.FC = ({ config, onConfigChange }) => {
+ // 기본값이 설정된 config 사용
+ const safeConfig = {
+ options: [],
+ multiple: false,
+ searchable: false,
+ placeholder: "",
+ allowClear: false,
+ maxSelections: undefined,
+ ...config,
+ };
+
+ // 로컬 상태로 실시간 입력 관리
+ const [localValues, setLocalValues] = useState({
+ multiple: safeConfig.multiple,
+ searchable: safeConfig.searchable,
+ placeholder: safeConfig.placeholder,
+ allowClear: safeConfig.allowClear,
+ maxSelections: safeConfig.maxSelections?.toString() || "",
+ });
+
+ const [newOption, setNewOption] = useState({ label: "", value: "" });
+
+ // 옵션들의 로컬 편집 상태
+ const [localOptions, setLocalOptions] = useState(
+ (safeConfig.options || []).map((option) => ({
+ label: option.label || "",
+ value: option.value || "",
+ disabled: option.disabled || false,
+ })),
+ );
+
+ // config가 변경될 때 로컬 상태 동기화
+ useEffect(() => {
+ setLocalValues({
+ multiple: safeConfig.multiple,
+ searchable: safeConfig.searchable,
+ placeholder: safeConfig.placeholder,
+ allowClear: safeConfig.allowClear,
+ maxSelections: safeConfig.maxSelections?.toString() || "",
+ });
+
+ setLocalOptions(
+ (safeConfig.options || []).map((option) => ({
+ label: option.label || "",
+ value: option.value || "",
+ disabled: option.disabled || false,
+ })),
+ );
+ }, [
+ safeConfig.multiple,
+ safeConfig.searchable,
+ safeConfig.placeholder,
+ safeConfig.allowClear,
+ safeConfig.maxSelections,
+ JSON.stringify(safeConfig.options), // 옵션 배열의 전체 내용 변화 감지
+ ]);
+
+ const updateConfig = (key: keyof SelectTypeConfig, value: any) => {
+ // 로컬 상태 즉시 업데이트
+ if (key === "maxSelections") {
+ setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || "" }));
+ } else {
+ setLocalValues((prev) => ({ ...prev, [key]: value }));
+ }
+
+ // 실제 config 업데이트 - 깊은 복사로 새 객체 보장
+ const newConfig = JSON.parse(JSON.stringify({ ...safeConfig, [key]: value }));
+ console.log("📋 SelectTypeConfig 업데이트:", {
+ key,
+ value,
+ oldConfig: safeConfig,
+ newConfig,
+ timestamp: new Date().toISOString(),
+ });
+
+ // 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
+ setTimeout(() => {
+ onConfigChange(newConfig);
+ }, 0);
+ };
+
+ const addOption = () => {
+ if (newOption.label.trim() && newOption.value.trim()) {
+ const newOptionData = { ...newOption, disabled: false };
+ const updatedOptions = [...(safeConfig.options || []), newOptionData];
+
+ console.log("➕ SelectType 옵션 추가:", {
+ newOption: newOptionData,
+ updatedOptions,
+ currentLocalOptions: localOptions,
+ timestamp: new Date().toISOString(),
+ });
+
+ // 로컬 상태 즉시 업데이트
+ setLocalOptions((prev) => {
+ const newLocalOptions = [
+ ...prev,
+ {
+ label: newOption.label,
+ value: newOption.value,
+ disabled: false,
+ },
+ ];
+ console.log("📋 SelectType 로컬 옵션 업데이트:", newLocalOptions);
+ return newLocalOptions;
+ });
+
+ updateConfig("options", updatedOptions);
+ setNewOption({ label: "", value: "" });
+ }
+ };
+
+ const removeOption = (index: number) => {
+ console.log("➖ SelectType 옵션 삭제:", {
+ removeIndex: index,
+ currentOptions: safeConfig.options,
+ currentLocalOptions: localOptions,
+ });
+
+ // 로컬 상태 즉시 업데이트
+ setLocalOptions((prev) => {
+ const newLocalOptions = prev.filter((_, i) => i !== index);
+ console.log("📋 SelectType 로컬 옵션 삭제 후:", newLocalOptions);
+ return newLocalOptions;
+ });
+
+ const updatedOptions = (safeConfig.options || []).filter((_, i) => i !== index);
+ updateConfig("options", updatedOptions);
+ };
+
+ const updateOption = (index: number, field: "label" | "value" | "disabled", value: any) => {
+ // 로컬 상태 즉시 업데이트 (실시간 입력 반영)
+ const updatedLocalOptions = [...localOptions];
+ updatedLocalOptions[index] = { ...updatedLocalOptions[index], [field]: value };
+ setLocalOptions(updatedLocalOptions);
+
+ // 실제 config 업데이트
+ const updatedOptions = [...(safeConfig.options || [])];
+ updatedOptions[index] = { ...updatedOptions[index], [field]: value };
+ updateConfig("options", updatedOptions);
+ };
+
+ return (
+
+ {/* 기본 설정 */}
+
+ {/* 플레이스홀더 */}
+
+
+ 플레이스홀더
+
+ updateConfig("placeholder", e.target.value)}
+ placeholder="옵션을 선택하세요"
+ className="mt-1"
+ />
+
+
+ {/* 다중 선택 */}
+
+
+ 다중 선택 허용
+
+ updateConfig("multiple", !!checked)}
+ />
+
+
+ {/* 검색 가능 */}
+
+
+ 검색 가능
+
+ updateConfig("searchable", !!checked)}
+ />
+
+
+ {/* 클리어 허용 */}
+
+
+ 선택 해제 허용
+
+ updateConfig("allowClear", !!checked)}
+ />
+
+
+ {/* 최대 선택 개수 (다중 선택인 경우) */}
+ {localValues.multiple && (
+
+
+ 최대 선택 개수
+
+ updateConfig("maxSelections", e.target.value ? Number(e.target.value) : undefined)}
+ className="mt-1"
+ placeholder="제한 없음"
+ />
+
+ )}
+
+
+ {/* 옵션 관리 */}
+
+
옵션 관리
+
+ {/* 기존 옵션 목록 */}
+
+
+ {/* 새 옵션 추가 */}
+
+
+
+ );
+};
diff --git a/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx
new file mode 100644
index 00000000..5026af36
--- /dev/null
+++ b/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Textarea } from "@/components/ui/textarea";
+import { TextTypeConfig } from "@/types/screen";
+
+interface TextTypeConfigPanelProps {
+ config: TextTypeConfig;
+ onConfigChange: (config: TextTypeConfig) => void;
+}
+
+export const TextTypeConfigPanel: React.FC = ({ config, onConfigChange }) => {
+ // 기본값이 설정된 config 사용
+ const safeConfig = {
+ minLength: undefined,
+ maxLength: undefined,
+ pattern: "",
+ format: "none" as const,
+ placeholder: "",
+ multiline: false,
+ ...config,
+ };
+
+ // 로컬 상태로 실시간 입력 관리
+ const [localValues, setLocalValues] = useState({
+ minLength: safeConfig.minLength?.toString() || "",
+ maxLength: safeConfig.maxLength?.toString() || "",
+ pattern: safeConfig.pattern,
+ format: safeConfig.format,
+ placeholder: safeConfig.placeholder,
+ multiline: safeConfig.multiline,
+ });
+
+ // config가 변경될 때 로컬 상태 동기화
+ useEffect(() => {
+ setLocalValues({
+ minLength: safeConfig.minLength?.toString() || "",
+ maxLength: safeConfig.maxLength?.toString() || "",
+ pattern: safeConfig.pattern,
+ format: safeConfig.format,
+ placeholder: safeConfig.placeholder,
+ multiline: safeConfig.multiline,
+ });
+ }, [
+ safeConfig.minLength,
+ safeConfig.maxLength,
+ safeConfig.pattern,
+ safeConfig.format,
+ safeConfig.placeholder,
+ safeConfig.multiline,
+ ]);
+
+ const updateConfig = (key: keyof TextTypeConfig, value: any) => {
+ // 로컬 상태 즉시 업데이트
+ if (key === "minLength" || key === "maxLength") {
+ setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || "" }));
+ } else {
+ setLocalValues((prev) => ({ ...prev, [key]: value }));
+ }
+
+ // 실제 config 업데이트
+ const newConfig = { ...safeConfig, [key]: value };
+ console.log("📝 TextTypeConfig 업데이트:", {
+ key,
+ value,
+ oldConfig: safeConfig,
+ newConfig,
+ });
+ onConfigChange(newConfig);
+ };
+
+ return (
+
+ {/* 입력 형식 */}
+
+
+ 입력 형식
+
+ updateConfig("format", value)}>
+
+
+
+
+ 제한 없음
+ 이메일
+ 전화번호
+ URL
+ 한글만
+ 영어만
+ 영숫자만
+ 숫자만
+
+
+
+
+ {/* 길이 제한 */}
+
+
+ {/* 정규식 패턴 */}
+
+
+ 정규식 패턴
+
+
updateConfig("pattern", e.target.value)}
+ className="mt-1"
+ placeholder="예: ^[0-9]{3}-[0-9]{4}-[0-9]{4}$"
+ />
+
JavaScript 정규식 패턴을 입력하세요 (선택사항)
+
+
+ {/* 플레이스홀더 */}
+
+
+ 플레이스홀더
+
+ updateConfig("placeholder", e.target.value)}
+ placeholder="입력 힌트 텍스트"
+ className="mt-1"
+ />
+
+
+ {/* 여러 줄 입력 */}
+
+
+ 여러 줄 입력 (textarea)
+
+ updateConfig("multiline", !!checked)}
+ />
+
+
+ {/* 형식별 안내 메시지 */}
+ {localValues.format !== "none" && (
+
+
형식 안내
+
+ {localValues.format === "email" && "유효한 이메일 주소를 입력해야 합니다 (예: user@example.com)"}
+ {localValues.format === "phone" && "전화번호 형식으로 입력해야 합니다 (예: 010-1234-5678)"}
+ {localValues.format === "url" && "유효한 URL을 입력해야 합니다 (예: https://example.com)"}
+ {localValues.format === "korean" && "한글만 입력할 수 있습니다"}
+ {localValues.format === "english" && "영어만 입력할 수 있습니다"}
+ {localValues.format === "alphanumeric" && "영문자와 숫자만 입력할 수 있습니다"}
+ {localValues.format === "numeric" && "숫자만 입력할 수 있습니다"}
+
+
+ )}
+
+ );
+};
+
+export default TextTypeConfigPanel;
diff --git a/frontend/components/screen/panels/webtype-configs/TextareaTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/TextareaTypeConfigPanel.tsx
new file mode 100644
index 00000000..0f970f92
--- /dev/null
+++ b/frontend/components/screen/panels/webtype-configs/TextareaTypeConfigPanel.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Slider } from "@/components/ui/slider";
+import { TextareaTypeConfig } from "@/types/screen";
+
+interface TextareaTypeConfigPanelProps {
+ config: TextareaTypeConfig;
+ onConfigChange: (config: TextareaTypeConfig) => void;
+}
+
+export const TextareaTypeConfigPanel: React.FC = ({ config, onConfigChange }) => {
+ // 기본값이 설정된 config 사용
+ const safeConfig = {
+ rows: 3,
+ maxLength: undefined,
+ minLength: undefined,
+ placeholder: "",
+ resizable: true,
+ autoResize: false,
+ wordWrap: true,
+ ...config,
+ };
+
+ // 로컬 상태로 실시간 입력 관리
+ const [localValues, setLocalValues] = useState({
+ rows: safeConfig.rows,
+ maxLength: safeConfig.maxLength?.toString() || "",
+ minLength: safeConfig.minLength?.toString() || "",
+ placeholder: safeConfig.placeholder,
+ resizable: safeConfig.resizable,
+ autoResize: safeConfig.autoResize,
+ wordWrap: safeConfig.wordWrap,
+ });
+
+ // config가 변경될 때 로컬 상태 동기화
+ useEffect(() => {
+ setLocalValues({
+ rows: safeConfig.rows,
+ maxLength: safeConfig.maxLength?.toString() || "",
+ minLength: safeConfig.minLength?.toString() || "",
+ placeholder: safeConfig.placeholder,
+ resizable: safeConfig.resizable,
+ autoResize: safeConfig.autoResize,
+ wordWrap: safeConfig.wordWrap,
+ });
+ }, [
+ safeConfig.rows,
+ safeConfig.maxLength,
+ safeConfig.minLength,
+ safeConfig.placeholder,
+ safeConfig.resizable,
+ safeConfig.autoResize,
+ safeConfig.wordWrap,
+ ]);
+
+ const updateConfig = (key: keyof TextareaTypeConfig, value: any) => {
+ // 로컬 상태 즉시 업데이트
+ if (key === "maxLength" || key === "minLength") {
+ setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || "" }));
+ } else {
+ setLocalValues((prev) => ({ ...prev, [key]: value }));
+ }
+
+ // 실제 config 업데이트
+ const newConfig = { ...safeConfig, [key]: value };
+ console.log("📄 TextareaTypeConfig 업데이트:", {
+ key,
+ value,
+ oldConfig: safeConfig,
+ newConfig,
+ });
+ onConfigChange(newConfig);
+ };
+
+ return (
+
+ {/* 기본 행 수 */}
+
+
+ 기본 행 수: {localValues.rows}
+
+
+
updateConfig("rows", value[0])}
+ min={1}
+ max={20}
+ step={1}
+ className="w-full"
+ />
+
+ 1
+ 20
+
+
+
+
+ {/* 길이 제한 */}
+
+
+ {/* 플레이스홀더 */}
+
+
+ 플레이스홀더
+
+ updateConfig("placeholder", e.target.value)}
+ placeholder="입력 힌트 텍스트"
+ className="mt-1"
+ />
+
+
+ {/* 크기 조정 가능 */}
+
+
+ 사용자가 크기 조정 가능
+
+ updateConfig("resizable", !!checked)}
+ />
+
+
+ {/* 자동 크기 조정 */}
+
+
+ 내용에 따라 자동 크기 조정
+
+ updateConfig("autoResize", !!checked)}
+ />
+
+
+ {/* 단어 자동 줄바꿈 */}
+
+
+ 단어 자동 줄바꿈
+
+ updateConfig("wordWrap", !!checked)}
+ />
+
+
+ {/* 설정 미리보기 */}
+
+
미리보기
+
+
+
+
+ 행 수: {localValues.rows},{localValues.minLength && ` 최소: ${localValues.minLength}자,`}
+ {localValues.maxLength && ` 최대: ${localValues.maxLength}자,`}
+ {localValues.resizable ? " 크기조정 가능" : " 크기고정"}
+
+
+
+ );
+};
+
+export default TextareaTypeConfigPanel;
diff --git a/frontend/hooks/usePanelState.ts b/frontend/hooks/usePanelState.ts
index 6a571059..a2e08a2a 100644
--- a/frontend/hooks/usePanelState.ts
+++ b/frontend/hooks/usePanelState.ts
@@ -32,6 +32,32 @@ export const usePanelState = (panels: PanelConfig[]) => {
return initialStates;
});
+ // 패널 설정이 변경되었을 때 크기 업데이트
+ useEffect(() => {
+ setPanelStates((prev) => {
+ const newStates = { ...prev };
+
+ panels.forEach((panel) => {
+ if (newStates[panel.id]) {
+ // 기존 패널의 위치는 유지하고 크기만 업데이트
+ newStates[panel.id] = {
+ ...newStates[panel.id],
+ size: { width: panel.defaultWidth, height: panel.defaultHeight },
+ };
+ } else {
+ // 새로운 패널이면 전체 초기화
+ newStates[panel.id] = {
+ isOpen: false,
+ position: { x: 0, y: 0 },
+ size: { width: panel.defaultWidth, height: panel.defaultHeight },
+ };
+ }
+ });
+
+ return newStates;
+ });
+ }, [panels]);
+
// 패널 토글
const togglePanel = useCallback((panelId: string) => {
setPanelStates((prev) => ({
@@ -45,6 +71,10 @@ export const usePanelState = (panels: PanelConfig[]) => {
// 패널 열기
const openPanel = useCallback((panelId: string) => {
+ console.log("📂 패널 열기:", {
+ panelId,
+ timestamp: new Date().toISOString(),
+ });
setPanelStates((prev) => ({
...prev,
[panelId]: {
@@ -56,6 +86,10 @@ export const usePanelState = (panels: PanelConfig[]) => {
// 패널 닫기
const closePanel = useCallback((panelId: string) => {
+ console.log("📁 패널 닫기:", {
+ panelId,
+ timestamp: new Date().toISOString(),
+ });
setPanelStates((prev) => ({
...prev,
[panelId]: {
@@ -112,7 +146,7 @@ export const usePanelState = (panels: PanelConfig[]) => {
// 단축키 처리
panels.forEach((panel) => {
- if (panel.shortcutKey && e.key.toLowerCase() === panel.shortcutKey.toLowerCase()) {
+ if (panel.shortcutKey && e.key?.toLowerCase() === panel.shortcutKey?.toLowerCase()) {
// Ctrl/Cmd 키와 함께 사용
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
@@ -136,4 +170,3 @@ export const usePanelState = (panels: PanelConfig[]) => {
updatePanelSize,
};
};
-
diff --git a/frontend/lib/utils/gridUtils.ts b/frontend/lib/utils/gridUtils.ts
index 49f819d1..a65d1bee 100644
--- a/frontend/lib/utils/gridUtils.ts
+++ b/frontend/lib/utils/gridUtils.ts
@@ -113,6 +113,38 @@ export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, g
return columns * columnWidth + (columns - 1) * gap;
}
+/**
+ * gridColumns 속성을 기반으로 컴포넌트 크기 업데이트
+ */
+export function updateSizeFromGridColumns(
+ component: { gridColumns?: number; size: Size },
+ gridInfo: GridInfo,
+ gridSettings: GridSettings,
+): Size {
+ if (!component.gridColumns || component.gridColumns < 1) {
+ return component.size;
+ }
+
+ const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
+
+ return {
+ width: newWidth,
+ height: component.size.height, // 높이는 유지
+ };
+}
+
+/**
+ * 컴포넌트의 gridColumns를 자동으로 크기에 맞게 조정
+ */
+export function adjustGridColumnsFromSize(
+ component: { size: Size },
+ gridInfo: GridInfo,
+ gridSettings: GridSettings,
+): number {
+ const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
+ return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
+}
+
/**
* 너비에서 격자 컬럼 수 계산
*/
@@ -180,3 +212,170 @@ export function isOnGridBoundary(
return positionMatch && sizeMatch;
}
+
+/**
+ * 그룹 내부 컴포넌트들을 격자에 맞게 정렬
+ */
+export function alignGroupChildrenToGrid(
+ children: any[],
+ groupPosition: Position,
+ gridInfo: GridInfo,
+ gridSettings: GridSettings,
+): any[] {
+ if (!gridSettings.snapToGrid || children.length === 0) return children;
+
+ console.log("🔧 alignGroupChildrenToGrid 시작:", {
+ childrenCount: children.length,
+ groupPosition,
+ gridInfo,
+ gridSettings,
+ });
+
+ return children.map((child, index) => {
+ console.log(`📐 자식 ${index + 1} 처리 중:`, {
+ childId: child.id,
+ originalPosition: child.position,
+ originalSize: child.size,
+ });
+
+ const { columnWidth } = gridInfo;
+ const { gap } = gridSettings;
+
+ // 그룹 내부 패딩 고려한 격자 정렬
+ const padding = 16;
+ const effectiveX = child.position.x - padding;
+ const columnIndex = Math.round(effectiveX / (columnWidth + gap));
+ const snappedX = padding + columnIndex * (columnWidth + gap);
+
+ // Y 좌표는 20px 단위로 스냅
+ const effectiveY = child.position.y - padding;
+ const rowIndex = Math.round(effectiveY / 20);
+ const snappedY = padding + rowIndex * 20;
+
+ // 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
+ const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
+ const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
+ const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
+ const snappedHeight = Math.max(40, Math.round(child.size.height / 20) * 20);
+
+ const snappedChild = {
+ ...child,
+ position: {
+ x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
+ y: Math.max(padding, snappedY),
+ z: child.position.z || 1,
+ },
+ size: {
+ width: snappedWidth,
+ height: snappedHeight,
+ },
+ };
+
+ console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
+ childId: child.id,
+ calculation: {
+ effectiveX,
+ effectiveY,
+ columnIndex,
+ rowIndex,
+ widthInColumns,
+ originalX: child.position.x,
+ snappedX: snappedChild.position.x,
+ padding,
+ },
+ snappedPosition: snappedChild.position,
+ snappedSize: snappedChild.size,
+ deltaX: snappedChild.position.x - child.position.x,
+ deltaY: snappedChild.position.y - child.position.y,
+ });
+
+ return snappedChild;
+ });
+}
+
+/**
+ * 그룹 생성 시 최적화된 그룹 크기 계산
+ */
+export function calculateOptimalGroupSize(
+ children: Array<{ position: Position; size: Size }>,
+ gridInfo: GridInfo,
+ gridSettings: GridSettings,
+): Size {
+ if (children.length === 0) {
+ return { width: gridInfo.columnWidth * 2, height: 40 * 2 };
+ }
+
+ console.log("📏 calculateOptimalGroupSize 시작:", {
+ childrenCount: children.length,
+ children: children.map((c) => ({ pos: c.position, size: c.size })),
+ });
+
+ // 모든 자식 컴포넌트를 포함하는 최소 경계 계산
+ const bounds = children.reduce(
+ (acc, child) => ({
+ minX: Math.min(acc.minX, child.position.x),
+ minY: Math.min(acc.minY, child.position.y),
+ maxX: Math.max(acc.maxX, child.position.x + child.size.width),
+ maxY: Math.max(acc.maxY, child.position.y + child.size.height),
+ }),
+ { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+ );
+
+ console.log("📐 경계 계산:", bounds);
+
+ const contentWidth = bounds.maxX - bounds.minX;
+ const contentHeight = bounds.maxY - bounds.minY;
+
+ // 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
+ const padding = 16; // 그룹 내부 여백
+ const groupSize = {
+ width: contentWidth + padding * 2,
+ height: contentHeight + padding * 2,
+ };
+
+ console.log("✅ 자연스러운 그룹 크기:", {
+ contentSize: { width: contentWidth, height: contentHeight },
+ withPadding: groupSize,
+ strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
+ });
+
+ return groupSize;
+}
+
+/**
+ * 그룹 내 상대 좌표를 격자 기준으로 정규화
+ */
+export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
+ if (!gridSettings.snapToGrid || children.length === 0) return children;
+
+ console.log("🔄 normalizeGroupChildPositions 시작:", {
+ childrenCount: children.length,
+ originalPositions: children.map((c) => ({ id: c.id, pos: c.position })),
+ });
+
+ // 모든 자식의 최소 위치 찾기
+ const minX = Math.min(...children.map((child) => child.position.x));
+ const minY = Math.min(...children.map((child) => child.position.y));
+
+ console.log("📍 최소 위치:", { minX, minY });
+
+ // 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백)
+ const padding = 16;
+ const startX = padding;
+ const startY = padding;
+
+ const normalizedChildren = children.map((child) => ({
+ ...child,
+ position: {
+ x: child.position.x - minX + startX,
+ y: child.position.y - minY + startY,
+ z: child.position.z || 1,
+ },
+ }));
+
+ console.log("✅ 정규화 완료:", {
+ normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
+ });
+
+ return normalizedChildren;
+}
diff --git a/frontend/types/screen.ts b/frontend/types/screen.ts
index 18a05cb6..3b98c090 100644
--- a/frontend/types/screen.ts
+++ b/frontend/types/screen.ts
@@ -139,6 +139,7 @@ export interface BaseComponent {
style?: ComponentStyle; // 스타일 속성 추가
tableName?: string; // 테이블명 추가
label?: string; // 라벨 추가
+ gridColumns?: number; // 그리드에서 차지할 컬럼 수 (1-12)
}
// 컨테이너 컴포넌트
@@ -194,7 +195,8 @@ export interface WidgetComponent extends BaseComponent {
required: boolean;
readonly: boolean;
validationRules?: ValidationRule[];
- displayProperties?: Record;
+ displayProperties?: Record; // 레거시 지원용 (향후 제거 예정)
+ webTypeConfig?: WebTypeConfig; // 웹타입별 상세 설정
}
// 컴포넌트 유니온 타입
@@ -388,3 +390,115 @@ export interface PaginatedResponse {
size: number;
totalPages: number;
}
+
+// ===== 웹타입별 상세 설정 인터페이스 =====
+
+// 날짜/시간 타입 설정
+export interface DateTypeConfig {
+ format: "YYYY-MM-DD" | "YYYY-MM-DD HH:mm" | "YYYY-MM-DD HH:mm:ss";
+ showTime: boolean;
+ minDate?: string;
+ maxDate?: string;
+ defaultValue?: string;
+ placeholder?: string;
+}
+
+// 숫자 타입 설정
+export interface NumberTypeConfig {
+ min?: number;
+ max?: number;
+ step?: number;
+ format?: "integer" | "decimal" | "currency" | "percentage";
+ decimalPlaces?: number;
+ thousandSeparator?: boolean;
+ prefix?: string; // 접두사 (예: $, ₩)
+ suffix?: string; // 접미사 (예: %, kg)
+ placeholder?: string;
+}
+
+// 선택박스 타입 설정
+export interface SelectTypeConfig {
+ options: Array<{ label: string; value: string; disabled?: boolean }>;
+ multiple?: boolean;
+ searchable?: boolean;
+ placeholder?: string;
+ allowClear?: boolean;
+ maxSelections?: number; // 다중 선택 시 최대 선택 개수
+}
+
+// 텍스트 타입 설정
+export interface TextTypeConfig {
+ minLength?: number;
+ maxLength?: number;
+ pattern?: string; // 정규식 패턴
+ format?: "none" | "email" | "phone" | "url" | "korean" | "english";
+ placeholder?: string;
+ autocomplete?: string;
+ spellcheck?: boolean;
+}
+
+// 파일 타입 설정
+export interface FileTypeConfig {
+ accept?: string; // MIME 타입 또는 확장자 (예: ".jpg,.png" 또는 "image/*")
+ multiple?: boolean;
+ maxSize?: number; // bytes
+ maxFiles?: number; // 다중 업로드 시 최대 파일 개수
+ preview?: boolean; // 미리보기 표시 여부
+ dragDrop?: boolean; // 드래그 앤 드롭 지원 여부
+}
+
+// 텍스트 영역 타입 설정
+export interface TextareaTypeConfig extends TextTypeConfig {
+ rows?: number;
+ cols?: number;
+ resize?: "none" | "both" | "horizontal" | "vertical";
+ wrap?: "soft" | "hard" | "off";
+}
+
+// 체크박스 타입 설정
+export interface CheckboxTypeConfig {
+ defaultChecked?: boolean;
+ trueValue?: string | number | boolean; // 체크 시 값
+ falseValue?: string | number | boolean; // 미체크 시 값
+ indeterminate?: boolean; // 불확실한 상태 지원
+}
+
+// 라디오 타입 설정
+export interface RadioTypeConfig {
+ options: Array<{ label: string; value: string; disabled?: boolean }>;
+ inline?: boolean; // 가로 배치 여부
+ defaultValue?: string;
+}
+
+// 코드 타입 설정 (공통코드 연계)
+export interface CodeTypeConfig {
+ codeCategory: string; // 공통코드 카테고리
+ displayFormat?: "label" | "value" | "both"; // 표시 형식
+ searchable?: boolean;
+ placeholder?: string;
+ allowClear?: boolean;
+}
+
+// 엔티티 타입 설정 (참조 테이블 연계)
+export interface EntityTypeConfig {
+ referenceTable: string;
+ referenceColumn: string;
+ displayColumn?: string; // 표시할 컬럼명 (기본값: referenceColumn)
+ searchable?: boolean;
+ placeholder?: string;
+ allowClear?: boolean;
+ filters?: Record; // 추가 필터 조건
+}
+
+// 웹타입별 설정 유니온 타입
+export type WebTypeConfig =
+ | DateTypeConfig
+ | NumberTypeConfig
+ | SelectTypeConfig
+ | TextTypeConfig
+ | FileTypeConfig
+ | TextareaTypeConfig
+ | CheckboxTypeConfig
+ | RadioTypeConfig
+ | CodeTypeConfig
+ | EntityTypeConfig;