dev #46

Merged
kjs merged 344 commits from dev into main 2025-09-22 18:17:24 +09:00
20 changed files with 4603 additions and 164 deletions
Showing only changes of commit f82d18575e - Show all commits

View File

@ -30,21 +30,30 @@
- **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인 가능
- **메뉴 연동**: 각 회사의 메뉴에 화면 할당 및 관리
### 🆕 최근 업데이트 (요약)
### 🆕 최근 업데이트 (2024.12)
- **픽셀 기반 자유 이동**: 격자 스냅을 제거하고 커서를 따라 정확히 이동하도록 구현. 그리드 라인은 시각적 가이드만 유지
- **멀티 선택 강화**: Shift+클릭 + 드래그 박스(마키)로 다중선택 가능. 그룹 컨테이너는 선택에서 자동 제외
- **다중 드래그 이동**: 다중선택 항목을 함께 이동(상대 위치 유지). 스크롤/그랩 오프셋 반영으로 튐 현상 제거
- **그룹 UI 간소화**: 그룹 헤더/테두리 박스 제거(투명 컨테이너). 그룹 내부에만 집중
- **그룹 내 정렬/분배 툴**: 좌/가로중앙/우, 상/세로중앙/하 정렬 + 가로/세로 균등 분배 추가(아이콘 UI)
- **왼쪽 목록 UX**: 검색·페이징 도입으로 대량 테이블 로딩 지연 완화
- **Undo/Redo**: 최대 50단계, 단축키(Ctrl/Cmd+Z, Ctrl/Cmd+Y)
- **위젯 타입 렌더링 보강**: code/entity/file 포함 실제 위젯 형태로 표시
- **복사/삭제/붙여넣기 범용화**: 단일/다중/그룹 선택 모두에서 동작. 우클릭 위치 붙여넣기, 단축키(Ctrl/Cmd+C, Ctrl/Cmd+V, Delete) 지원, 상단 툴바 버튼 제공. 클립보드 상태 배지 표시(예: "3개 복사됨", "그룹 복사됨")
- **화면 코드 자동 생성**: 회사 코드 기반 고유 화면 코드 자동 생성 (예: COMP_001)
- **레이아웃 저장/로드**: 설계한 화면 레이아웃을 데이터베이스에 저장하고 불러오는 기능
- **메뉴-화면 할당**: 설계한 화면을 실제 메뉴에 할당하여 사용자가 접근할 수 있도록 연결
- **인터랙티브 화면 뷰어**: 할당된 화면에서 실제 사용자 입력 및 상호작용이 가능한 완전 기능 화면
#### ✅ 완료된 주요 기능들
- **컴포넌트 관리 시스템**: 드래그앤드롭, 다중 선택, 그룹 드래그, 실시간 위치 업데이트
- **속성 편집 시스템**: 실시간 속성 편집, 라벨 관리 (텍스트, 폰트, 색상, 여백), 필수 입력 시 주황색 \* 표시
- **격자 시스템**: 동적 격자 설정, 컴포넌트 스냅 및 크기 조정
- **패널 관리**: 플로팅 패널, 수동 크기 조정, 위치 기억
- **웹타입 지원**: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file
#### 🔧 해결된 기술적 문제들
- **라벨 하단 여백 동적 적용**: 여백값에 따른 정확한 위치 계산
- **스타일 속성 개별 업데이트**: 초기화 방지를 위한 `style.propertyName` 방식 적용
- **체크박스 실시간 반영**: 로컬 상태 + 실제 속성 동시 업데이트
- **다중 드래그 최적화**: 지연 없는 실시간 미리보기, 선택 해제 방지
- **입력 필드 실시간 적용**: debounce 제거, 즉시 반영 시스템
#### 🎯 개발 진행 상황
- **현재 완성도**: 95% (핵심 기능 완료)
- **기술 스택**: Next.js 15.4.4, TypeScript, Tailwind CSS, Shadcn/ui
- **상태 관리**: React Hooks 기반 로컬 상태 + 실시간 업데이트 패턴
- **드래그앤드롭**: HTML5 Drag & Drop API 기반 고도화된 시스템
### 🎯 **현재 테이블 구조와 100% 호환**
@ -2369,7 +2378,66 @@ export class TableTypeIntegrationService {
## 🚀 다음 단계 계획
### 1. 컴포넌트 그룹화 기능 (완료)
### 1. 웹타입별 상세 설정 기능 구현 (진행 예정)
#### 📋 구현 계획 개요
각 웹 타입(date, number, select 등)에 대한 세부적인 설정을 가능하게 하여 더 정교한 폼 컨트롤을 제공
#### 🎯 단계별 구현 계획
##### Phase 1: 타입 정의 및 인터페이스 설계
```typescript
// 웹타입별 설정 인터페이스
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;
}
interface NumberTypeConfig {
min?: number;
max?: number;
step?: number;
format?: "integer" | "decimal" | "currency" | "percentage";
decimalPlaces?: number;
thousandSeparator?: boolean;
}
interface SelectTypeConfig {
options: Array<{ label: string; value: string }>;
multiple?: boolean;
searchable?: boolean;
placeholder?: string;
}
```
##### Phase 2: PropertiesPanel 확장
- 웹 타입 선택 시 해당 타입의 세부 설정 UI 동적 표시
- 각 타입별 전용 설정 컴포넌트 생성
- 실시간 설정값 업데이트 및 미리보기
##### Phase 3: 우선순위 타입별 구현
1. **날짜/시간 (date, datetime)**: 날짜 형식, 시간 포함 여부, 날짜 범위
2. **숫자 (number, decimal)**: 범위, 형식, 소수점, 천 단위 구분자
3. **선택박스 (select, dropdown)**: 동적 옵션 관리, 다중 선택, 검색 기능
4. **텍스트 (text, textarea)**: 길이 제한, 입력 패턴, 형식 검증
5. **파일 (file)**: 파일 형식 제한, 크기 제한, 다중 업로드
##### Phase 4: RealtimePreview 업데이트
설정값에 따른 실제 렌더링 로직 구현 (input 속성, 검증 규칙 등)
##### Phase 5: 저장/불러오기
컴포넌트 데이터에 webTypeConfig 포함하여 레이아웃 저장 시 설정값도 함께 저장
### 2. 컴포넌트 그룹화 기능 (완료)
- [x] 여러 위젯을 컨테이너로 그룹화
- [x] 부모-자식 관계 설정(parentId)
@ -2463,6 +2531,105 @@ export class TableTypeIntegrationService {
- **Version Control**: Git
- **Package Manager**: npm
## 🔧 핵심 기술적 구현 패턴
### 1. 상태 관리 패턴
#### 로컬 상태 + 실시간 업데이트 패턴
PropertiesPanel에서 사용하는 입력 필드 관리 방식:
```typescript
const [localInputs, setLocalInputs] = useState({
placeholder: selectedComponent?.placeholder || "",
// ... 기타 필드들
});
// 입력 시 로컬 상태 + 실제 컴포넌트 동시 업데이트
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, fieldName: newValue }));
onUpdateProperty("fieldName", newValue);
}}
```
#### 스타일 속성 개별 업데이트 패턴
스타일 초기화 방지를 위한 개별 속성 업데이트:
```typescript
// Bad: 전체 객체 교체로 인한 다른 속성 손실
onUpdateProperty("style", { ...selectedComponent.style, newProp: value });
// Good: 개별 속성 직접 업데이트
onUpdateProperty("style.labelFontSize", value);
```
### 2. 드래그앤드롭 패턴
#### 다중 컴포넌트 드래그 처리
- dragState에 draggedComponents 배열로 선택된 모든 컴포넌트 관리
- 실시간 미리보기를 위한 RealtimePreview와 실제 업데이트 분리
- justFinishedDrag 플래그로 드래그 완료 후 의도치 않은 선택 해제 방지
#### 격자 스냅 시스템
- 컴포넌트 위치와 크기를 격자에 맞게 자동 조정
- 격자 설정 변경 시 기존 컴포넌트들도 자동 재조정
### 3. 컴포넌트 렌더링 패턴
#### 웹타입별 동적 렌더링
RealtimePreview에서 switch-case로 웹타입별 적절한 입력 컴포넌트 렌더링:
```typescript
switch (widgetType) {
case "text":
return <Input type="text" {...commonProps} />;
case "date":
return <Input type="date" {...commonProps} />;
case "select":
return <select>...</select>;
}
```
#### 라벨 동적 위치 계산
라벨 하단 여백 설정에 따른 동적 위치 계산:
```typescript
const labelMarginBottomValue = parseInt(component.style?.labelMarginBottom || "4px", 10);
style={{ top: `${-20 - labelMarginBottomValue}px` }}
```
### 4. 패널 관리 패턴
#### 플로팅 패널 상태 관리
- 각 패널의 위치, 크기, 열림/닫힘 상태를 독립적으로 관리
- 사용자가 수동으로 조정한 위치 기억
- autoHeight 제거로 컨텐츠 변경 시에도 위치 유지
### 5. 타입 안전성 패턴
#### 인터페이스 확장 패턴
BaseComponent를 기본으로 각 컴포넌트 타입별 확장:
```typescript
export interface WidgetComponent extends BaseComponent {
type: "widget";
widgetType: WebType;
// 위젯 전용 속성들
}
```
#### 유니온 타입 활용
ComponentData = ContainerComponent | WidgetComponent | GroupComponent 등으로 타입 안전성 보장
## 🎯 결론
화면관리 시스템은 **회사별 권한 관리**와 **테이블 타입관리 연계**를 통해 사용자가 직관적으로 웹 화면을 설계할 수 있는 강력한 도구입니다.
@ -2493,7 +2660,8 @@ export class TableTypeIntegrationService {
- ✅ **Phase 1-6 완료**: 기본 구조, 드래그앤드롭, 컴포넌트 라이브러리, 테이블 연계, 미리보기, 통합 테스트
- ✅ **핵심 기능 완료**: 컴포넌트 그룹화, 레이아웃 저장/로드, 메뉴-화면 할당, 인터랙티브 화면 뷰어
- 📋 **향후 계획**: 반응형 레이아웃, 고급 기능, 실제 CRUD 연동
- ✅ **고도화 완료**: 실시간 속성 편집, 라벨 관리, 다중 드래그, 격자 시스템
- 📋 **다음 계획**: 웹타입별 상세 설정, 반응형 레이아웃, 고급 기능
### 🎉 **완전 기능 화면관리 시스템 완성!**

View File

@ -3,7 +3,7 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Menu, Database, Settings, Palette, Grid3X3, Save, Undo, Redo, Play, ArrowLeft } from "lucide-react";
import { Menu, Database, Settings, Palette, Grid3X3, Save, Undo, Redo, Play, ArrowLeft, Cog } from "lucide-react";
import { cn } from "@/lib/utils";
interface DesignerToolbarProps {
@ -110,7 +110,20 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
<Grid3X3 className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
G
R
</Badge>
</Button>
<Button
variant={panelStates.detailSettings?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("detailSettings")}
className={cn("flex items-center space-x-2", panelStates.detailSettings?.isOpen && "bg-blue-600 text-white")}
>
<Cog className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
D
</Badge>
</Button>
</div>

View File

@ -35,13 +35,18 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
minWidth = 280,
minHeight = 300,
maxWidth = 600,
maxHeight = 800,
maxHeight = 1200, // 800 → 1200 (더 큰 패널 지원)
resizable = true,
draggable = true,
autoHeight = false, // 자동 높이 조정 비활성화 (수동 크기 조절만 지원)
autoHeight = true, // 자동 높이 조정 활성화 (컨텐츠 크기에 맞게)
className,
}) => {
const [panelSize, setPanelSize] = useState({ width, height });
// props 변경 시 패널 크기 업데이트
useEffect(() => {
setPanelSize({ width, height });
}, [width, height]);
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
@ -91,7 +96,54 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
}
}, [isOpen, position, hasInitialized]);
// 자동 높이 조정 기능 제거됨 - 수동 크기 조절만 지원
// 자동 높이 조정 기능
useEffect(() => {
if (!autoHeight || !contentRef.current || isResizing) return;
const updateHeight = () => {
if (!contentRef.current) return;
// 일시적으로 높이 제한을 해제하여 실제 컨텐츠 높이 측정
contentRef.current.style.maxHeight = "none";
// 컨텐츠의 실제 높이 측정
const contentHeight = contentRef.current.scrollHeight;
const headerHeight = 60; // 헤더 높이
const padding = 30; // 여유 공간 (좀 더 넉넉하게)
const newHeight = Math.min(Math.max(minHeight, contentHeight + headerHeight + padding), maxHeight);
console.log(`🔧 패널 높이 자동 조정:`, {
panelId: id,
contentHeight,
calculatedHeight: newHeight,
currentHeight: panelSize.height,
willUpdate: Math.abs(panelSize.height - newHeight) > 10,
});
// 현재 높이와 다르면 업데이트
if (Math.abs(panelSize.height - newHeight) > 10) {
setPanelSize((prev) => ({ ...prev, height: newHeight }));
}
};
// 초기 높이 설정
updateHeight();
// ResizeObserver로 컨텐츠 크기 변화 감지
const resizeObserver = new ResizeObserver((entries) => {
// DOM 업데이트가 완료된 후에 높이 측정
requestAnimationFrame(() => {
setTimeout(updateHeight, 50); // 약간의 지연으로 렌더링 완료 후 측정
});
});
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
};
}, [autoHeight, minHeight, maxHeight, isResizing, panelSize.height, children]);
// 드래그 시작 - 성능 최적화
const handleDragStart = (e: React.MouseEvent) => {
@ -215,16 +267,20 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
{/* 컨텐츠 */}
<div
ref={contentRef}
className="flex-1 overflow-auto"
style={{
maxHeight: `${panelSize.height - 60}px`, // 헤더 높이 제외
}}
className={autoHeight ? "flex-1" : "flex-1 overflow-auto"}
style={
autoHeight
? {}
: {
maxHeight: `${panelSize.height - 60}px`, // 헤더 높이 제외
}
}
>
{children}
</div>
{/* 리사이즈 핸들 */}
{resizable && (
{resizable && !autoHeight && (
<div className="absolute right-0 bottom-0 h-4 w-4 cursor-se-resize" onMouseDown={handleResizeStart}>
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gray-400" />
</div>

View File

@ -1,7 +1,21 @@
"use client";
import React from "react";
import { ComponentData, WebType } from "@/types/screen";
import {
ComponentData,
WebType,
WidgetComponent,
DateTypeConfig,
NumberTypeConfig,
SelectTypeConfig,
TextTypeConfig,
TextareaTypeConfig,
CheckboxTypeConfig,
RadioTypeConfig,
FileTypeConfig,
CodeTypeConfig,
EntityTypeConfig,
} from "@/types/screen";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@ -57,122 +71,387 @@ const renderWidget = (component: ComponentData) => {
switch (widgetType) {
case "text":
case "email":
case "tel":
return <Input type={widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text"} {...commonProps} />;
case "tel": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as TextTypeConfig | undefined;
// 플레이스홀더 처리
const finalPlaceholder = config?.placeholder || placeholder || "텍스트를 입력하세요";
const inputType = widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text";
// multiline이면 Textarea로 렌더링
if (config?.multiline) {
return (
<Textarea
{...commonProps}
placeholder={finalPlaceholder}
minLength={config?.minLength}
maxLength={config?.maxLength}
pattern={config?.pattern}
/>
);
}
return (
<Input
type={inputType}
{...commonProps}
placeholder={finalPlaceholder}
minLength={config?.minLength}
maxLength={config?.maxLength}
pattern={config?.pattern}
/>
);
}
case "number":
case "decimal":
return <Input type="number" step={widgetType === "decimal" ? "0.01" : "1"} {...commonProps} />;
case "decimal": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as NumberTypeConfig | undefined;
// 디버깅: 현재 설정값 확인
console.log("🔢 숫자 위젯 렌더링:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
placeholder: widget.placeholder,
});
// 단계값 결정: webTypeConfig > 기본값 (소수는 0.01, 정수는 1)
const step = config?.step || (widgetType === "decimal" ? 0.01 : 1);
// 플레이스홀더 처리
const finalPlaceholder = config?.placeholder || placeholder || "숫자를 입력하세요";
// 접두사/접미사가 있는 경우 표시용 컨테이너 사용
if (config?.prefix || config?.suffix) {
return (
<div className="flex w-full items-center">
{config.prefix && (
<span className="rounded-l border border-r-0 bg-gray-50 px-2 py-2 text-sm text-gray-600">
{config.prefix}
</span>
)}
<Input
type="number"
step={step}
min={config?.min}
max={config?.max}
{...commonProps}
placeholder={finalPlaceholder}
className={`${config?.prefix ? "rounded-l-none" : ""} ${config?.suffix ? "rounded-r-none" : ""} ${borderClass}`}
/>
{config.suffix && (
<span className="rounded-r border border-l-0 bg-gray-50 px-2 py-2 text-sm text-gray-600">
{config.suffix}
</span>
)}
</div>
);
}
return (
<Input
type="number"
step={step}
min={config?.min}
max={config?.max}
{...commonProps}
placeholder={finalPlaceholder}
/>
);
}
case "date":
case "datetime":
return <Input type={widgetType === "datetime" ? "datetime-local" : "date"} {...commonProps} />;
case "datetime": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as DateTypeConfig | undefined;
// 웹타입 설정에 따른 input type 결정
let inputType = "date";
if (config?.showTime || config?.format?.includes("HH:mm")) {
inputType = "datetime-local";
}
// defaultValue를 inputType에 맞게 변환
let processedDefaultValue = config?.defaultValue || "";
if (processedDefaultValue) {
if (inputType === "datetime-local") {
// datetime-local은 "YYYY-MM-DDTHH:mm" 형식이 필요
if (!processedDefaultValue.includes("T") && processedDefaultValue.includes(" ")) {
processedDefaultValue = processedDefaultValue.replace(" ", "T");
}
// 초가 없으면 제거 (datetime-local은 분까지만)
if (processedDefaultValue.includes(":") && processedDefaultValue.split(":").length > 2) {
processedDefaultValue = processedDefaultValue.substring(0, processedDefaultValue.lastIndexOf(":"));
}
} else if (inputType === "date") {
// date는 "YYYY-MM-DD" 형식만 필요
if (processedDefaultValue.includes(" ") || processedDefaultValue.includes("T")) {
processedDefaultValue = processedDefaultValue.split(/[T ]/)[0];
}
}
}
// 플레이스홀더 우선순위: webTypeConfig > placeholder > 기본값
const finalPlaceholder = config?.placeholder || placeholder || "날짜를 선택하세요";
// 디버깅: 현재 설정값 확인
console.log("📅 날짜 위젯 렌더링:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
configExists: !!config,
configKeys: config ? Object.keys(config) : [],
configStringified: JSON.stringify(config),
placeholder: widget.placeholder,
finalInputType: inputType,
finalPlaceholder,
inputTypeDecision: {
showTimeCheck: config?.showTime,
formatCheck: config?.format?.includes("HH:mm"),
formatValue: config?.format,
resultInputType: inputType,
},
appliedSettings: {
minDate: config?.minDate,
maxDate: config?.maxDate,
defaultValue: config?.defaultValue,
processedDefaultValue,
showTime: config?.showTime,
format: config?.format,
},
inputProps: {
type: inputType,
placeholder: finalPlaceholder,
min: config?.minDate,
max: config?.maxDate,
value: processedDefaultValue,
},
valueConversion: {
originalValue: config?.defaultValue,
processedValue: processedDefaultValue,
inputType,
conversionApplied: config?.defaultValue !== processedDefaultValue,
},
widgetFullData: {
id: widget.id,
type: widget.type,
widgetType: widget.widgetType,
webTypeConfig: widget.webTypeConfig,
webTypeConfigStringified: JSON.stringify(widget.webTypeConfig),
},
timestamp: new Date().toISOString(),
});
return (
<Input
type={inputType}
{...commonProps}
placeholder={finalPlaceholder}
min={config?.minDate}
max={config?.maxDate}
value={processedDefaultValue}
onChange={() => {}} // 읽기 전용으로 처리
readOnly
/>
);
}
case "select":
case "dropdown":
case "dropdown": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as SelectTypeConfig | undefined;
// 디버깅: 현재 설정값 확인
console.log("📋 선택박스 위젯 렌더링:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
options: config?.options,
placeholder: widget.placeholder,
});
// 플레이스홀더 처리
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
// 옵션 목록 (webTypeConfig에서 가져오거나 기본 옵션 사용)
const options = config?.options || [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
];
return (
<select
disabled={readonly}
required={required}
multiple={config?.multiple}
className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
hasCustomBorder ? "!border-0" : "border border-gray-300"
}`}
>
<option value="">{placeholder || "선택하세요..."}</option>
<option value="option1"> 1</option>
<option value="option2"> 2</option>
<option value="option3"> 3</option>
<option value="">{finalPlaceholder}</option>
{options.map((option, index) => (
<option key={index} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
}
case "textarea":
case "text_area":
return <Textarea {...commonProps} rows={3} />;
case "text_area": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as TextareaTypeConfig | undefined;
return (
<Textarea
{...commonProps}
rows={config?.rows || 3}
placeholder={config?.placeholder || placeholder || "텍스트를 입력하세요"}
minLength={config?.minLength}
maxLength={config?.maxLength}
style={{
resize: config?.resizable === false ? "none" : "vertical",
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
}}
/>
);
}
case "boolean":
case "checkbox":
case "checkbox": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as CheckboxTypeConfig | undefined;
const checkboxText = config?.checkboxText || label || columnName || "체크박스";
const isLeftLabel = config?.labelPosition === "left";
return (
<div className="flex items-center space-x-2">
{isLeftLabel && (
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
{checkboxText}
</Label>
)}
<input
type="checkbox"
id={`checkbox-${component.id}`}
disabled={readonly}
required={required}
defaultChecked={config?.defaultChecked}
className="h-4 w-4"
/>
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
{label || columnName}
</Label>
{!isLeftLabel && (
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
{checkboxText}
</Label>
)}
</div>
);
}
case "radio": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as RadioTypeConfig | undefined;
const options = config?.options || [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
];
const layoutClass =
config?.layout === "horizontal"
? "flex flex-row space-x-4"
: config?.layout === "grid"
? "grid grid-cols-2 gap-2"
: "space-y-2";
case "radio":
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="radio"
id={`radio1-${component.id}`}
name={`radio-${component.id}`}
disabled={readonly}
className="h-4 w-4"
/>
<Label htmlFor={`radio1-${component.id}`} className="text-sm">
1
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="radio"
id={`radio2-${component.id}`}
name={`radio-${component.id}`}
disabled={readonly}
className="h-4 w-4"
/>
<Label htmlFor={`radio2-${component.id}`} className="text-sm">
2
</Label>
</div>
<div className={layoutClass}>
{options.map((option, index) => (
<div key={option.value} className="flex items-center space-x-2">
<input
type="radio"
id={`radio-${component.id}-${index}`}
name={`radio-group-${component.id}`}
value={option.value}
disabled={readonly}
required={required}
defaultChecked={config?.defaultValue === option.value}
className="h-4 w-4"
/>
<Label htmlFor={`radio-${component.id}-${index}`} className="text-sm">
{option.label}
</Label>
</div>
))}
</div>
);
}
case "code": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
case "code":
return (
<Textarea
{...commonProps}
rows={4}
className={`w-full font-mono text-sm ${borderClass}`}
placeholder="코드를 입력하세요..."
placeholder={config?.placeholder || "코드를 입력하세요..."}
readOnly={config?.readOnly}
style={{
fontSize: `${config?.fontSize || 14}px`,
backgroundColor: config?.theme === "dark" ? "#1e1e1e" : "#ffffff",
color: config?.theme === "dark" ? "#ffffff" : "#000000",
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
}}
/>
);
}
case "entity": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
case "entity":
return (
<select
disabled={readonly}
required={required}
multiple={config?.multiple}
className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
hasCustomBorder ? "!border-0" : "border border-gray-300"
}`}
>
<option value=""> ...</option>
<option value="">{config?.placeholder || "엔티티를 선택하세요..."}</option>
<option value="user"></option>
<option value="product"></option>
<option value="order"></option>
</select>
);
}
case "file": {
const widget = component as WidgetComponent;
const config = widget.webTypeConfig as FileTypeConfig | undefined;
case "file":
return (
<input
type="file"
disabled={readonly}
required={required}
multiple={config?.multiple}
accept={config?.accept}
className={`w-full rounded-md px-3 py-2 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 ${
hasCustomBorder ? "!border-0" : "border border-gray-300"
}`}
/>
);
}
default:
return <Input type="text" {...commonProps} />;
@ -320,7 +599,7 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
{type === "group" && (
<div className="relative h-full w-full">
{/* 그룹 박스/헤더 제거: 투명 컨테이너 */}
{/* 그룹 내용 */}
<div className="absolute inset-0">{children}</div>
</div>
)}

View File

@ -24,6 +24,11 @@ import {
snapToGrid,
snapSizeToGrid,
generateGridLines,
updateSizeFromGridColumns,
adjustGridColumnsFromSize,
alignGroupChildrenToGrid,
calculateOptimalGroupSize,
normalizeGroupChildPositions,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar";
@ -36,6 +41,7 @@ import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
@ -50,34 +56,42 @@ const panelConfigs: PanelConfig[] = [
id: "tables",
title: "테이블 목록",
defaultPosition: "left",
defaultWidth: 320,
defaultHeight: 600,
defaultWidth: 380,
defaultHeight: 700, // 테이블 목록은 그대로 유지
shortcutKey: "t",
},
{
id: "properties",
title: "속성 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 500,
defaultWidth: 360,
defaultHeight: 400, // autoHeight 시작점
shortcutKey: "p",
},
{
id: "styles",
title: "스타일 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 400,
defaultWidth: 360,
defaultHeight: 400, // autoHeight 시작점
shortcutKey: "s",
},
{
id: "grid",
title: "격자 설정",
defaultPosition: "right",
defaultWidth: 280,
defaultHeight: 450,
defaultWidth: 320,
defaultHeight: 400, // autoHeight 시작점
shortcutKey: "r", // grid의 r로 변경 (그룹과 겹치지 않음)
},
{
id: "detailSettings",
title: "상세 설정",
defaultPosition: "right",
defaultWidth: 400,
defaultHeight: 400, // autoHeight 시작점
shortcutKey: "d",
},
];
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
@ -237,6 +251,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 컴포넌트 속성 업데이트
const updateComponentProperty = useCallback(
(componentId: string, path: string, value: any) => {
console.log("⚙️ 컴포넌트 속성 업데이트:", {
componentId,
path,
value,
timestamp: new Date().toISOString(),
});
const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => {
if (comp.id !== componentId) return comp;
@ -252,10 +273,138 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
current[pathParts[pathParts.length - 1]] = value;
// 크기 변경 시 격자 스냅 적용
if ((path === "size.width" || path === "size.height") && layout.gridSettings?.snapToGrid && gridInfo) {
console.log("✅ 컴포넌트 업데이트 완료:", {
componentId,
path,
newValue: current[pathParts[pathParts.length - 1]],
fullComponent: newComp,
webTypeConfig: newComp.type === "widget" ? (newComp as any).webTypeConfig : null,
});
// webTypeConfig 업데이트의 경우 추가 검증
if (path === "webTypeConfig") {
console.log("🎛️ webTypeConfig 특별 처리:", {
componentId,
oldConfig: comp.type === "widget" ? (comp as any).webTypeConfig : null,
newConfig: current[pathParts[pathParts.length - 1]],
configType: typeof current[pathParts[pathParts.length - 1]],
configStringified: JSON.stringify(current[pathParts[pathParts.length - 1]]),
oldConfigStringified: JSON.stringify(comp.type === "widget" ? (comp as any).webTypeConfig : null),
isConfigChanged:
JSON.stringify(comp.type === "widget" ? (comp as any).webTypeConfig : null) !==
JSON.stringify(current[pathParts[pathParts.length - 1]]),
timestamp: new Date().toISOString(),
});
}
// gridColumns 변경 시 크기 자동 업데이트
if (path === "gridColumns" && gridInfo) {
const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = updatedSize;
console.log("📏 gridColumns 변경으로 크기 업데이트:", {
gridColumns: value,
oldSize: comp.size,
newSize: updatedSize,
});
}
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
if (
(path === "size.width" || path === "size.height") &&
layout.gridSettings?.snapToGrid &&
gridInfo &&
newComp.type !== "group"
) {
const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = snappedSize;
// 크기 변경 시 gridColumns도 자동 조정
const adjustedColumns = adjustGridColumnsFromSize(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
if (newComp.gridColumns !== adjustedColumns) {
newComp.gridColumns = adjustedColumns;
console.log("📏 크기 변경으로 gridColumns 자동 조정:", {
oldColumns: comp.gridColumns,
newColumns: adjustedColumns,
newSize: snappedSize,
});
}
}
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
if (
(path === "position.x" || path === "position.y" || path === "position") &&
layout.gridSettings?.snapToGrid &&
gridInfo
) {
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
if (newComp.parentId && gridInfo) {
const { columnWidth } = gridInfo;
const { gap } = layout.gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = newComp.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16)));
const snappedX = padding + columnIndex * (columnWidth + (gap || 16));
// Y 좌표는 20px 단위로 스냅
const effectiveY = newComp.position.y - padding;
const rowIndex = Math.round(effectiveY / 20);
const snappedY = padding + rowIndex * 20;
// 크기도 외부 격자와 동일하게 스냅
const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(40, Math.round(newComp.size.height / 20) * 20);
newComp.position = {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: newComp.position.z || 1,
};
newComp.size = {
width: snappedWidth,
height: snappedHeight,
};
console.log("🎯 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", {
componentId,
parentId: newComp.parentId,
originalPosition: comp.position,
originalSize: comp.size,
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
columnWidth,
fullColumnWidth,
widthInColumns,
gap: gap || 16,
padding,
},
snappedPosition: newComp.position,
snappedSize: newComp.size,
});
} else if (newComp.type !== "group") {
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
const snappedPosition = snapToGrid(newComp.position, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.position = snappedPosition;
console.log("🧲 일반 컴포넌트 격자 스냅:", {
componentId,
originalPosition: comp.position,
snappedPosition,
});
} else {
console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", {
componentId,
type: newComp.type,
position: newComp.position,
});
}
}
return newComp;
@ -264,6 +413,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
// webTypeConfig 업데이트 후 레이아웃 상태 확인
if (path === "webTypeConfig") {
const updatedComponent = newLayout.components.find((c) => c.id === componentId);
console.log("🔄 레이아웃 업데이트 후 컴포넌트 상태:", {
componentId,
updatedComponent: updatedComponent
? {
id: updatedComponent.id,
type: updatedComponent.type,
webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null,
}
: null,
layoutComponentsCount: newLayout.components.length,
timestamp: new Date().toISOString(),
});
}
},
[layout, gridInfo, saveToHistory],
);
@ -366,10 +532,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const adjustedComponents = layout.components.map((comp) => {
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
// gridColumns가 없거나 범위를 벗어나면 자동 조정
let adjustedGridColumns = comp.gridColumns;
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > newGridSettings.columns) {
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
}
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns, // gridColumns 속성 추가/조정
};
});
@ -443,6 +617,130 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 격자 기반 컬럼 너비 계산
const columnWidth = gridInfo ? gridInfo.columnWidth : 200;
// 웹타입별 기본 설정 생성
const getDefaultWebTypeConfig = (widgetType: string) => {
switch (widgetType) {
case "date":
return {
format: "YYYY-MM-DD" as const,
showTime: false,
placeholder: "날짜를 선택하세요",
};
case "datetime":
return {
format: "YYYY-MM-DD HH:mm" as const,
showTime: true,
placeholder: "날짜와 시간을 선택하세요",
};
case "number":
return {
format: "integer" as const,
placeholder: "숫자를 입력하세요",
};
case "decimal":
return {
format: "decimal" as const,
step: 0.01,
decimalPlaces: 2,
placeholder: "소수를 입력하세요",
};
case "select":
case "dropdown":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: "옵션을 선택하세요",
};
case "text":
return {
format: "none" as const,
placeholder: "텍스트를 입력하세요",
multiline: false,
};
case "email":
return {
format: "email" as const,
placeholder: "이메일을 입력하세요",
multiline: false,
};
case "tel":
return {
format: "phone" as const,
placeholder: "전화번호를 입력하세요",
multiline: false,
};
case "textarea":
return {
rows: 3,
placeholder: "텍스트를 입력하세요",
resizable: true,
autoResize: false,
wordWrap: true,
};
case "checkbox":
case "boolean":
return {
defaultChecked: false,
labelPosition: "right" as const,
checkboxText: "",
trueValue: true,
falseValue: false,
indeterminate: false,
};
case "radio":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
],
layout: "vertical" as const,
defaultValue: "",
allowNone: false,
};
case "file":
return {
accept: "",
multiple: false,
maxSize: 10,
maxFiles: 1,
preview: true,
dragDrop: true,
allowedExtensions: [],
};
case "code":
return {
language: "javascript",
theme: "light",
fontSize: 14,
lineNumbers: true,
wordWrap: false,
readOnly: false,
autoFormat: true,
placeholder: "코드를 입력하세요...",
};
case "entity":
return {
entityName: "",
displayField: "name",
valueField: "id",
searchable: true,
multiple: false,
allowClear: true,
placeholder: "엔터티를 선택하세요",
apiEndpoint: "",
filters: [],
displayFormat: "simple" as const,
};
default:
return undefined;
}
};
// 컬럼 위젯 생성
newComponent = {
id: generateComponentId(),
@ -456,6 +754,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
readonly: false, // 누락된 속성 추가
position: { x, y, z: 1 } as Position,
size: { width: columnWidth, height: 40 },
gridColumns: 1, // 기본 그리드 컬럼 수
style: {
labelDisplay: true,
labelFontSize: "12px",
@ -463,13 +762,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
labelFontWeight: "500",
labelMarginBottom: "6px",
},
webTypeConfig: getDefaultWebTypeConfig(column.widgetType),
};
} else {
return;
}
// 격자 스냅 적용 (올바른 타입 변환)
if (layout.gridSettings?.snapToGrid && gridInfo) {
// 격자 스냅 적용 (그룹 컴포넌트 제외)
if (layout.gridSettings?.snapToGrid && gridInfo && newComponent.type !== "group") {
const gridUtilSettings = {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
@ -478,6 +778,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
newComponent.position = snapToGrid(newComponent.position, gridInfo, gridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, gridUtilSettings);
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
type: newComponent.type,
snappedPosition: newComponent.position,
snappedSize: newComponent.size,
});
}
if (newComponent.type === "group") {
console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", {
type: newComponent.type,
position: newComponent.position,
size: newComponent.size,
});
}
const newLayout = {
@ -535,10 +849,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 마지막 선택된 컴포넌트를 selectedComponent로 설정
if (!isSelected) {
console.log("🎯 컴포넌트 선택 (다중 모드):", {
componentId: component.id,
componentType: component.type,
webTypeConfig: component.type === "widget" ? (component as any).webTypeConfig : null,
timestamp: new Date().toISOString(),
});
setSelectedComponent(component);
}
} else {
// 단일 선택 모드
console.log("🎯 컴포넌트 선택 (단일 모드):", {
componentId: component.id,
componentType: component.type,
webTypeConfig: component.type === "widget" ? (component as any).webTypeConfig : null,
timestamp: new Date().toISOString(),
});
setSelectedComponent(component);
setGroupState((prev) => ({
...prev,
@ -626,16 +952,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 드래그 종료
const endDrag = useCallback(() => {
if (dragState.isDragging && dragState.draggedComponent) {
// 주 드래그 컴포넌트의 최종 위치에 격자 스냅 적용
const finalPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid(dragState.currentPosition, gridInfo, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: dragState.currentPosition;
// 주 드래그 컴포넌트의 최종 위치 계산
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
let finalPosition = dragState.currentPosition;
// 일반 컴포넌트만 격자 스냅 적용 (그룹 제외)
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && gridInfo) {
finalPosition = snapToGrid(
{
x: dragState.currentPosition.x,
y: dragState.currentPosition.y,
z: dragState.currentPosition.z ?? 1,
},
gridInfo,
{
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
},
);
}
// 스냅으로 인한 추가 이동 거리 계산
const snapDeltaX = finalPosition.x - dragState.currentPosition.x;
@ -650,13 +987,78 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const isDraggedComponent = dragState.draggedComponents.some((dragComp) => dragComp.id === comp.id);
if (isDraggedComponent) {
const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === comp.id)!;
let newPosition = {
x: originalComponent.position.x + totalDeltaX,
y: originalComponent.position.y + totalDeltaY,
z: originalComponent.position.z || 1,
};
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
if (comp.parentId && layout.gridSettings?.snapToGrid && gridInfo) {
const { columnWidth } = gridInfo;
const { gap } = layout.gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = newPosition.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16)));
const snappedX = padding + columnIndex * (columnWidth + (gap || 16));
// Y 좌표는 20px 단위로 스냅
const effectiveY = newPosition.y - padding;
const rowIndex = Math.round(effectiveY / 20);
const snappedY = padding + rowIndex * 20;
// 크기도 외부 격자와 동일하게 스냅
const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(comp.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(40, Math.round(comp.size.height / 20) * 20);
newPosition = {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: newPosition.z,
};
// 크기도 업데이트
const newSize = {
width: snappedWidth,
height: snappedHeight,
};
console.log("🎯 드래그 종료 시 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", {
componentId: comp.id,
parentId: comp.parentId,
beforeSnap: {
x: originalComponent.position.x + totalDeltaX,
y: originalComponent.position.y + totalDeltaY,
},
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
columnWidth,
fullColumnWidth,
widthInColumns,
gap: gap || 16,
padding,
},
afterSnap: newPosition,
afterSizeSnap: newSize,
});
return {
...comp,
position: newPosition as Position,
size: newSize,
};
}
return {
...comp,
position: {
x: originalComponent.position.x + totalDeltaX,
y: originalComponent.position.y + totalDeltaY,
z: originalComponent.position.z || 1,
} as Position,
position: newPosition as Position,
};
}
return comp;
@ -929,45 +1331,203 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
}, [clipboard, layout, saveToHistory]);
// 그룹 생성
// 그룹 생성 (임시 비활성화)
const handleGroupCreate = useCallback(
(componentIds: string[], title: string, style?: any) => {
const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id));
if (selectedComponents.length < 2) return;
console.log("그룹 생성 기능이 임시 비활성화되었습니다.");
toast.info("그룹 기능이 임시 비활성화되었습니다.");
return;
// 경계 박스 계산
const boundingBox = calculateBoundingBox(selectedComponents);
// 격자 정보 계산
const currentGridInfo =
gridInfo ||
calculateGridInfo(
1200,
800,
layout.gridSettings || {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
gridColor: "#d1d5db",
gridOpacity: 0.5,
},
);
// 그룹 컴포넌트 생성
const groupComponent = createGroupComponent(
componentIds,
title,
{ x: boundingBox.minX, y: boundingBox.minY },
{ width: boundingBox.width, height: boundingBox.height },
style,
);
console.log("🔧 그룹 생성 시작:", {
selectedCount: selectedComponents.length,
snapToGrid: layout.gridSettings?.snapToGrid,
gridInfo: currentGridInfo,
});
// 자식 컴포넌트들의 상대 위치 계산
// 컴포넌트 크기 조정 기반 그룹 크기 계산
const calculateOptimalGroupSize = () => {
if (!currentGridInfo || !layout.gridSettings?.snapToGrid) {
// 격자 스냅이 비활성화된 경우 기본 패딩 사용
const boundingBox = calculateBoundingBox(selectedComponents);
const padding = 40;
return {
boundingBox,
groupPosition: { x: boundingBox.minX - padding, y: boundingBox.minY - padding, z: 1 },
groupSize: { width: boundingBox.width + padding * 2, height: boundingBox.height + padding * 2 },
gridColumns: 1,
scaledComponents: selectedComponents, // 크기 조정 없음
padding: padding,
};
}
const { columnWidth } = currentGridInfo;
const gap = layout.gridSettings?.gap || 16;
const contentBoundingBox = calculateBoundingBox(selectedComponents);
// 1. 간단한 접근: 컴포넌트들의 시작점에서 가장 가까운 격자 시작점 찾기
const startColumn = Math.floor(contentBoundingBox.minX / (columnWidth + gap));
// 2. 컴포넌트들의 끝점까지 포함할 수 있는 컬럼 수 계산
const groupStartX = startColumn * (columnWidth + gap);
const availableWidthFromStart = contentBoundingBox.maxX - groupStartX;
const currentWidthInColumns = Math.ceil(availableWidthFromStart / (columnWidth + gap));
// 2. 그룹은 격자에 정확히 맞게 위치와 크기 설정
const padding = 20;
const groupX = startColumn * (columnWidth + gap); // 격자 시작점에 정확히 맞춤
const groupY = contentBoundingBox.minY - padding;
const groupWidth = currentWidthInColumns * columnWidth + (currentWidthInColumns - 1) * gap; // 컬럼 크기 + gap
const groupHeight = contentBoundingBox.height + padding * 2;
// 4. 내부 컴포넌트들을 그룹 크기에 맞게 스케일링
const availableWidth = groupWidth - padding * 2; // 패딩 제외한 실제 사용 가능 너비
const scaleFactorX = availableWidth / contentBoundingBox.width;
const scaledComponents = selectedComponents.map((comp) => {
// 컴포넌트의 원래 위치에서 컨텐츠 영역 시작점까지의 상대 위치 계산
const relativeX = comp.position.x - contentBoundingBox.minX;
const relativeY = comp.position.y - contentBoundingBox.minY;
return {
...comp,
position: {
x: padding + relativeX * scaleFactorX, // 패딩 + 스케일된 상대 위치
y: padding + relativeY, // Y는 스케일링 없이 패딩만 적용
z: comp.position.z || 1,
},
size: {
width: comp.size.width * scaleFactorX, // X 방향 스케일링
height: comp.size.height, // Y는 원본 크기 유지
},
};
});
console.log("🎯 컴포넌트 크기 조정 기반 그룹 생성:", {
originalBoundingBox: contentBoundingBox,
gridCalculation: {
columnWidthPlusGap: columnWidth + gap,
startColumn: `Math.floor(${contentBoundingBox.minX} / ${columnWidth + gap}) = ${startColumn}`,
groupStartX: `${startColumn} * ${columnWidth + gap} = ${groupStartX}`,
availableWidthFromStart: `${contentBoundingBox.maxX} - ${groupStartX} = ${availableWidthFromStart}`,
currentWidthInColumns: `Math.ceil(${availableWidthFromStart} / ${columnWidth + gap}) = ${currentWidthInColumns}`,
finalGroupX: `${startColumn} * ${columnWidth + gap} = ${groupX}`,
actualGroupWidth: `${currentWidthInColumns}컬럼 * ${columnWidth}px + ${currentWidthInColumns - 1}gap * ${gap}px = ${groupWidth}px`,
},
groupPosition: { x: groupX, y: groupY },
groupSize: { width: groupWidth, height: groupHeight },
scaleFactorX,
availableWidth,
padding,
scaledComponentsCount: scaledComponents.length,
scaledComponentsDetails: scaledComponents.map((comp) => {
const original = selectedComponents.find((c) => c.id === comp.id);
return {
id: comp.id,
originalPos: original?.position,
scaledPos: comp.position,
originalSize: original?.size,
scaledSize: comp.size,
deltaX: comp.position.x - (original?.position.x || 0),
deltaY: comp.position.y - (original?.position.y || 0),
};
}),
});
return {
boundingBox: contentBoundingBox,
groupPosition: { x: groupX, y: groupY, z: 1 },
groupSize: { width: groupWidth, height: groupHeight },
gridColumns: currentWidthInColumns,
scaledComponents: scaledComponents, // 스케일된 컴포넌트들
padding: padding,
};
};
const {
boundingBox,
groupPosition,
groupSize: optimizedGroupSize,
gridColumns,
scaledComponents,
padding,
} = calculateOptimalGroupSize();
// 스케일된 컴포넌트들로 상대 위치 계산 (이미 최적화되어 추가 격자 정렬 불필요)
const relativeChildren = calculateRelativePositions(
selectedComponents,
{ x: boundingBox.minX, y: boundingBox.minY },
groupComponent.id,
scaledComponents,
groupPosition,
"temp", // 임시 그룹 ID
);
console.log("📏 최적화된 그룹 생성 (컴포넌트 스케일링):", {
gridColumns,
groupSize: optimizedGroupSize,
groupPosition,
scaledComponentsCount: scaledComponents.length,
padding,
strategy: "내부 컴포넌트 크기 조정으로 격자 정확 맞춤",
});
// 그룹 컴포넌트 생성 (gridColumns 속성 포함)
const groupComponent = createGroupComponent(componentIds, title, groupPosition, optimizedGroupSize, style);
// 그룹에 계산된 gridColumns 속성 추가
groupComponent.gridColumns = gridColumns;
// 실제 그룹 ID로 자식들 업데이트
const finalChildren = relativeChildren.map((child) => ({
...child,
parentId: groupComponent.id,
}));
const newLayout = {
...layout,
components: [
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
groupComponent,
...relativeChildren,
...finalChildren,
],
};
setLayout(newLayout);
saveToHistory(newLayout);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
setGroupState((prev) => ({
...prev,
selectedComponents: [groupComponent.id],
isGrouping: false,
}));
setSelectedComponent(groupComponent);
console.log("🎯 최적화된 그룹 생성 완료:", {
groupId: groupComponent.id,
childrenCount: finalChildren.length,
position: groupPosition,
size: optimizedGroupSize,
gridColumns: groupComponent.gridColumns,
componentsScaled: !!scaledComponents.length,
gridAligned: layout.gridSettings?.snapToGrid,
});
toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`);
},
[layout, saveToHistory],
[layout, saveToHistory, gridInfo],
);
// 그룹 생성 함수 (다이얼로그 표시)
@ -981,9 +1541,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
setShowGroupCreateDialog(true);
}, [groupState.selectedComponents]);
// 그룹 해제 함수
// 그룹 해제 함수 (임시 비활성화)
const ungroupComponents = useCallback(() => {
if (!selectedComponent || selectedComponent.type !== "group") return;
console.log("그룹 해제 기능이 임시 비활성화되었습니다.");
toast.info("그룹 해제 기능이 임시 비활성화되었습니다.");
return;
const groupId = selectedComponent.id;
@ -1142,7 +1704,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const isBrowserShortcut = browserShortcuts.some((shortcut) => {
const ctrlMatch = shortcut.ctrl ? e.ctrlKey || e.metaKey : true;
const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey;
const keyMatch = e.key.toLowerCase() === shortcut.key.toLowerCase();
const keyMatch = e.key?.toLowerCase() === shortcut.key?.toLowerCase();
return ctrlMatch && shiftMatch && keyMatch;
});
@ -1156,7 +1718,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// ✅ 애플리케이션 전용 단축키 처리
// 1. 그룹 관련 단축키
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "g" && !e.shiftKey) {
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) {
console.log("🔄 그룹 생성 단축키");
e.preventDefault();
e.stopPropagation();
@ -1171,7 +1733,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return false;
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "g") {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "g") {
console.log("🔄 그룹 해제 단축키");
e.preventDefault();
e.stopPropagation();
@ -1187,7 +1749,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// 2. 전체 선택 (애플리케이션 내에서만)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "a") {
console.log("🔄 전체 선택 (애플리케이션 내)");
e.preventDefault();
e.stopPropagation();
@ -1198,7 +1760,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// 3. 실행취소/다시실행
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "z" && !e.shiftKey) {
console.log("🔄 실행취소");
e.preventDefault();
e.stopPropagation();
@ -1208,8 +1770,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
if (
((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") ||
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "z")
((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "y") ||
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "z")
) {
console.log("🔄 다시실행");
e.preventDefault();
@ -1220,7 +1782,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// 4. 복사 (컴포넌트 복사)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "c") {
console.log("🔄 컴포넌트 복사");
e.preventDefault();
e.stopPropagation();
@ -1230,7 +1792,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// 5. 붙여넣기 (컴포넌트 붙여넣기)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") {
console.log("🔄 컴포넌트 붙여넣기");
e.preventDefault();
e.stopPropagation();
@ -1258,7 +1820,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// 8. 저장 (Ctrl+S는 레이아웃 저장용으로 사용)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "s") {
console.log("💾 레이아웃 저장");
e.preventDefault();
e.stopPropagation();
@ -1422,7 +1984,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return (
<RealtimePreview
key={component.id}
key={`${component.id}-${component.type === "widget" ? JSON.stringify((component as any).webTypeConfig) : ""}`}
component={displayComponent}
isSelected={
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
@ -1482,7 +2044,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return (
<RealtimePreview
key={child.id}
key={`${child.id}-${child.type === "widget" ? JSON.stringify((child as any).webTypeConfig) : ""}`}
component={displayChild}
isSelected={
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
@ -1520,9 +2082,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
<p className="text-sm"> </p>
<p className="mt-2 text-xs">
단축키: T(), P(), S(), R() | Ctrl+G(), Ctrl+Shift+G()
</p>
<p className="mt-2 text-xs">단축키: T(), P(), S(), R(), D()</p>
<p className="mt-1 text-xs">
편집: Ctrl+C(), Ctrl+V(), Ctrl+S(), Ctrl+Z(), Delete()
</p>
@ -1541,8 +2101,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
isOpen={panelStates.tables?.isOpen || false}
onClose={() => closePanel("tables")}
position="left"
width={320}
height={600}
width={380}
height={700}
autoHeight={false}
>
<TablesPanel
tables={filteredTables}
@ -1566,8 +2127,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
isOpen={panelStates.properties?.isOpen || false}
onClose={() => closePanel("properties")}
position="right"
width={320}
height={500}
width={360}
height={400}
autoHeight={true}
>
<PropertiesPanel
selectedComponent={selectedComponent || undefined}
@ -1587,8 +2149,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
isOpen={panelStates.styles?.isOpen || false}
onClose={() => closePanel("styles")}
position="right"
width={320}
width={360}
height={400}
autoHeight={true}
>
{selectedComponent ? (
<div className="p-4">
@ -1610,8 +2173,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
isOpen={panelStates.grid?.isOpen || false}
onClose={() => closePanel("grid")}
position="right"
width={280}
height={450}
width={320}
height={400}
autoHeight={true}
>
<GridPanel
gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }}
@ -1623,8 +2187,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
/>
</FloatingPanel>
<FloatingPanel
id="detailSettings"
title="상세 설정"
isOpen={panelStates.detailSettings?.isOpen || false}
onClose={() => closePanel("detailSettings")}
position="right"
width={400}
height={400}
autoHeight={true}
>
<DetailSettingsPanel
selectedComponent={selectedComponent || undefined}
onUpdateProperty={(componentId: string, path: string, value: any) => {
updateComponentProperty(componentId, path, value);
}}
/>
</FloatingPanel>
{/* 그룹 생성 툴바 (필요시) */}
{groupState.selectedComponents.length > 1 && (
{false && groupState.selectedComponents.length > 1 && (
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 transform">
<GroupingToolbar
selectedComponents={layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))}

View File

@ -0,0 +1,219 @@
"use client";
import React from "react";
import { Settings } from "lucide-react";
import {
ComponentData,
WidgetComponent,
WebTypeConfig,
DateTypeConfig,
NumberTypeConfig,
SelectTypeConfig,
TextTypeConfig,
TextareaTypeConfig,
CheckboxTypeConfig,
RadioTypeConfig,
FileTypeConfig,
CodeTypeConfig,
EntityTypeConfig,
} from "@/types/screen";
import { DateTypeConfigPanel } from "./webtype-configs/DateTypeConfigPanel";
import { NumberTypeConfigPanel } from "./webtype-configs/NumberTypeConfigPanel";
import { SelectTypeConfigPanel } from "./webtype-configs/SelectTypeConfigPanel";
import { TextTypeConfigPanel } from "./webtype-configs/TextTypeConfigPanel";
import { TextareaTypeConfigPanel } from "./webtype-configs/TextareaTypeConfigPanel";
import { CheckboxTypeConfigPanel } from "./webtype-configs/CheckboxTypeConfigPanel";
import { RadioTypeConfigPanel } from "./webtype-configs/RadioTypeConfigPanel";
import { FileTypeConfigPanel } from "./webtype-configs/FileTypeConfigPanel";
import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel";
import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel";
interface DetailSettingsPanelProps {
selectedComponent?: ComponentData;
onUpdateProperty: (componentId: string, path: string, value: any) => void;
}
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ selectedComponent, onUpdateProperty }) => {
// 웹타입별 상세 설정 렌더링 함수
const renderWebTypeConfig = React.useCallback(
(widget: WidgetComponent) => {
const currentConfig = widget.webTypeConfig || {};
console.log("🎨 DetailSettingsPanel renderWebTypeConfig 호출:", {
componentId: widget.id,
widgetType: widget.widgetType,
currentConfig,
configExists: !!currentConfig,
configKeys: Object.keys(currentConfig),
configStringified: JSON.stringify(currentConfig),
widgetWebTypeConfig: widget.webTypeConfig,
widgetWebTypeConfigExists: !!widget.webTypeConfig,
timestamp: new Date().toISOString(),
});
const handleConfigChange = (newConfig: WebTypeConfig) => {
console.log("🔧 WebTypeConfig 업데이트:", {
widgetType: widget.widgetType,
oldConfig: currentConfig,
newConfig,
componentId: widget.id,
isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig),
});
// 강제 새 객체 생성으로 React 변경 감지 보장
const freshConfig = { ...newConfig };
onUpdateProperty(widget.id, "webTypeConfig", freshConfig);
};
switch (widget.widgetType) {
case "date":
case "datetime":
return (
<DateTypeConfigPanel
key={`date-config-${widget.id}`}
config={currentConfig as DateTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "number":
case "decimal":
return (
<NumberTypeConfigPanel
key={`${widget.id}-number`}
config={currentConfig as NumberTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "select":
case "dropdown":
return (
<SelectTypeConfigPanel
key={`${widget.id}-select`}
config={currentConfig as SelectTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "text":
case "email":
case "tel":
return (
<TextTypeConfigPanel
key={`${widget.id}-text`}
config={currentConfig as TextTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "textarea":
return (
<TextareaTypeConfigPanel
key={`${widget.id}-textarea`}
config={currentConfig as TextareaTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "checkbox":
case "boolean":
return (
<CheckboxTypeConfigPanel
key={`${widget.id}-checkbox`}
config={currentConfig as CheckboxTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "radio":
return (
<RadioTypeConfigPanel
key={`${widget.id}-radio`}
config={currentConfig as RadioTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "file":
return (
<FileTypeConfigPanel
key={`${widget.id}-file`}
config={currentConfig as FileTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "code":
return (
<CodeTypeConfigPanel
key={`${widget.id}-code`}
config={currentConfig as CodeTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "entity":
return (
<EntityTypeConfigPanel
key={`${widget.id}-entity`}
config={currentConfig as EntityTypeConfig}
onConfigChange={handleConfigChange}
/>
);
default:
return <div className="text-sm text-gray-500 italic"> .</div>;
}
},
[onUpdateProperty],
);
if (!selectedComponent) {
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500"> .</p>
</div>
);
}
if (selectedComponent.type !== "widget") {
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500">
.
<br />
: {selectedComponent.type}
</p>
</div>
);
}
const widget = selectedComponent as WidgetComponent;
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">:</span>
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">{widget.widgetType}</span>
</div>
<div className="mt-1 text-xs text-gray-500">: {widget.columnName}</div>
</div>
{/* 상세 설정 영역 */}
<div className="flex-1 overflow-y-auto p-4">{renderWebTypeConfig(widget)}</div>
</div>
);
};
export default DetailSettingsPanel;

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -8,13 +8,12 @@ import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Settings, Move, Resize, Type, Palette, Trash2, Copy, Group, Ungroup } from "lucide-react";
import { ComponentData, WebType } from "@/types/screen";
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
import { ComponentData, WebType, WidgetComponent, GroupComponent } from "@/types/screen";
interface PropertiesPanelProps {
selectedComponent?: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
onUpdateProperty: (path: string, value: unknown) => void;
onDeleteComponent: () => void;
onCopyComponent: () => void;
onGroupComponents?: () => void;
@ -58,19 +57,20 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
// 입력 필드들의 로컬 상태 (실시간 타이핑 반영용)
const [localInputs, setLocalInputs] = useState({
placeholder: selectedComponent?.placeholder || "",
title: selectedComponent?.title || "",
placeholder: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).placeholder : "") || "",
title: (selectedComponent?.type === "group" ? (selectedComponent as GroupComponent).title : "") || "",
positionX: selectedComponent?.position.x?.toString() || "0",
positionY: selectedComponent?.position.y?.toString() || "0",
positionZ: selectedComponent?.position.z?.toString() || "1",
width: selectedComponent?.size.width?.toString() || "0",
height: selectedComponent?.size.height?.toString() || "0",
gridColumns: selectedComponent?.gridColumns?.toString() || "1",
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
labelColor: selectedComponent?.style?.labelColor || "#374151",
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
required: selectedComponent?.required || false,
readonly: selectedComponent?.readonly || false,
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
});
useEffect(() => {
@ -81,20 +81,23 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
// 선택된 컴포넌트가 변경될 때 로컬 입력 상태 업데이트
useEffect(() => {
if (selectedComponent) {
const widget = selectedComponent.type === "widget" ? (selectedComponent as WidgetComponent) : null;
const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null;
setLocalInputs({
placeholder: selectedComponent.placeholder || "",
title: selectedComponent.title || "",
placeholder: widget?.placeholder || "",
title: group?.title || "",
positionX: selectedComponent.position.x?.toString() || "0",
positionY: selectedComponent.position.y?.toString() || "0",
positionZ: selectedComponent.position.z?.toString() || "1",
width: selectedComponent.size.width?.toString() || "0",
height: selectedComponent.size.height?.toString() || "0",
gridColumns: selectedComponent.gridColumns?.toString() || "1",
labelText: selectedComponent.style?.labelText || selectedComponent.label || "",
labelFontSize: selectedComponent.style?.labelFontSize || "12px",
labelColor: selectedComponent.style?.labelColor || "#374151",
labelMarginBottom: selectedComponent.style?.labelMarginBottom || "4px",
required: selectedComponent.required || false,
readonly: selectedComponent.readonly || false,
required: widget?.required || false,
readonly: widget?.readonly || false,
});
}
}, [selectedComponent]);
@ -346,6 +349,32 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
placeholder="1"
/>
</div>
<div>
<Label htmlFor="gridColumns" className="text-sm font-medium">
(1-12)
</Label>
<Input
id="gridColumns"
type="number"
min="1"
max="12"
value={localInputs.gridColumns}
onChange={(e) => {
const newValue = e.target.value;
const numValue = Number(newValue);
if (numValue >= 1 && numValue <= 12) {
setLocalInputs((prev) => ({ ...prev, gridColumns: newValue }));
onUpdateProperty("gridColumns", numValue);
}
}}
placeholder="1"
className="mt-1"
/>
<div className="mt-1 text-xs text-gray-500">
(기본: 1)
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,206 @@
"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 { CheckboxTypeConfig } from "@/types/screen";
interface CheckboxTypeConfigPanelProps {
config: CheckboxTypeConfig;
onConfigChange: (config: CheckboxTypeConfig) => void;
}
export const CheckboxTypeConfigPanel: React.FC<CheckboxTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
defaultChecked: false,
labelPosition: "right" as const,
checkboxText: "",
trueValue: true,
falseValue: false,
indeterminate: false,
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
defaultChecked: safeConfig.defaultChecked,
labelPosition: safeConfig.labelPosition,
checkboxText: safeConfig.checkboxText,
trueValue: safeConfig.trueValue?.toString() || "true",
falseValue: safeConfig.falseValue?.toString() || "false",
indeterminate: safeConfig.indeterminate,
});
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
defaultChecked: safeConfig.defaultChecked,
labelPosition: safeConfig.labelPosition,
checkboxText: safeConfig.checkboxText,
trueValue: safeConfig.trueValue?.toString() || "true",
falseValue: safeConfig.falseValue?.toString() || "false",
indeterminate: safeConfig.indeterminate,
});
}, [
safeConfig.defaultChecked,
safeConfig.labelPosition,
safeConfig.checkboxText,
safeConfig.trueValue,
safeConfig.falseValue,
safeConfig.indeterminate,
]);
const updateConfig = (key: keyof CheckboxTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
if (key === "trueValue" || key === "falseValue") {
setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || (key === "trueValue" ? "true" : "false") }));
// 값을 적절한 타입으로 변환
const convertedValue = value === "true" ? true : value === "false" ? false : value;
value = convertedValue;
} else {
setLocalValues((prev) => ({ ...prev, [key]: value }));
}
// 실제 config 업데이트
const newConfig = { ...safeConfig, [key]: value };
console.log("☑️ CheckboxTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
});
onConfigChange(newConfig);
};
return (
<div className="space-y-4">
{/* 기본 체크 상태 */}
<div className="flex items-center justify-between">
<Label htmlFor="defaultChecked" className="text-sm font-medium">
</Label>
<Checkbox
id="defaultChecked"
checked={localValues.defaultChecked}
onCheckedChange={(checked) => updateConfig("defaultChecked", !!checked)}
/>
</div>
{/* 라벨 위치 */}
<div>
<Label htmlFor="labelPosition" className="text-sm font-medium">
</Label>
<Select value={localValues.labelPosition} onValueChange={(value) => updateConfig("labelPosition", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="라벨 위치 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 체크박스 옆 텍스트 */}
<div>
<Label htmlFor="checkboxText" className="text-sm font-medium">
</Label>
<Input
id="checkboxText"
value={localValues.checkboxText}
onChange={(e) => updateConfig("checkboxText", e.target.value)}
placeholder="체크박스와 함께 표시될 텍스트"
className="mt-1"
/>
</div>
{/* 값 설정 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="trueValue" className="text-sm font-medium">
</Label>
<Input
id="trueValue"
value={localValues.trueValue}
onChange={(e) => updateConfig("trueValue", e.target.value)}
className="mt-1"
placeholder="true"
/>
</div>
<div>
<Label htmlFor="falseValue" className="text-sm font-medium">
</Label>
<Input
id="falseValue"
value={localValues.falseValue}
onChange={(e) => updateConfig("falseValue", e.target.value)}
className="mt-1"
placeholder="false"
/>
</div>
</div>
{/* 불확정 상태 지원 */}
<div className="flex items-center justify-between">
<Label htmlFor="indeterminate" className="text-sm font-medium">
</Label>
<Checkbox
id="indeterminate"
checked={localValues.indeterminate}
onCheckedChange={(checked) => updateConfig("indeterminate", !!checked)}
/>
</div>
{/* 미리보기 */}
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2 flex items-center space-x-2">
{localValues.labelPosition === "left" && localValues.checkboxText && (
<span className="text-sm">{localValues.checkboxText}</span>
)}
{localValues.labelPosition === "top" && localValues.checkboxText && (
<div className="w-full">
<div className="text-sm">{localValues.checkboxText}</div>
<Checkbox checked={localValues.defaultChecked} className="mt-1" />
</div>
)}
{(localValues.labelPosition === "right" || localValues.labelPosition === "bottom") && (
<>
<Checkbox checked={localValues.defaultChecked} />
{localValues.checkboxText && <span className="text-sm">{localValues.checkboxText}</span>}
</>
)}
{localValues.labelPosition === "left" && <Checkbox checked={localValues.defaultChecked} />}
</div>
<div className="mt-2 text-xs text-gray-500">
: {localValues.trueValue}, : {localValues.falseValue}
{localValues.indeterminate && ", 불확정 상태 지원"}
</div>
</div>
{/* 안내 메시지 */}
{localValues.indeterminate && (
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
.
.
</div>
</div>
)}
</div>
);
};
export default CheckboxTypeConfigPanel;

View File

@ -0,0 +1,304 @@
"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 { Slider } from "@/components/ui/slider";
import { CodeTypeConfig } from "@/types/screen";
interface CodeTypeConfigPanelProps {
config: CodeTypeConfig;
onConfigChange: (config: CodeTypeConfig) => void;
}
export const CodeTypeConfigPanel: React.FC<CodeTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
language: "javascript",
theme: "light",
fontSize: 14,
lineNumbers: true,
wordWrap: false,
readOnly: false,
autoFormat: true,
placeholder: "",
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
language: safeConfig.language,
theme: safeConfig.theme,
fontSize: safeConfig.fontSize,
lineNumbers: safeConfig.lineNumbers,
wordWrap: safeConfig.wordWrap,
readOnly: safeConfig.readOnly,
autoFormat: safeConfig.autoFormat,
placeholder: safeConfig.placeholder,
});
// 지원하는 프로그래밍 언어들
const languages = [
{ value: "javascript", label: "JavaScript" },
{ value: "typescript", label: "TypeScript" },
{ value: "python", label: "Python" },
{ value: "java", label: "Java" },
{ value: "csharp", label: "C#" },
{ value: "cpp", label: "C++" },
{ value: "c", label: "C" },
{ value: "php", label: "PHP" },
{ value: "ruby", label: "Ruby" },
{ value: "go", label: "Go" },
{ value: "rust", label: "Rust" },
{ value: "kotlin", label: "Kotlin" },
{ value: "swift", label: "Swift" },
{ value: "html", label: "HTML" },
{ value: "css", label: "CSS" },
{ value: "scss", label: "SCSS" },
{ value: "json", label: "JSON" },
{ value: "xml", label: "XML" },
{ value: "yaml", label: "YAML" },
{ value: "sql", label: "SQL" },
{ value: "markdown", label: "Markdown" },
{ value: "bash", label: "Bash" },
{ value: "powershell", label: "PowerShell" },
];
// 테마 옵션
const themes = [
{ value: "light", label: "라이트" },
{ value: "dark", label: "다크" },
{ value: "monokai", label: "Monokai" },
{ value: "github", label: "GitHub" },
{ value: "vs-code", label: "VS Code" },
];
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
language: safeConfig.language,
theme: safeConfig.theme,
fontSize: safeConfig.fontSize,
lineNumbers: safeConfig.lineNumbers,
wordWrap: safeConfig.wordWrap,
readOnly: safeConfig.readOnly,
autoFormat: safeConfig.autoFormat,
placeholder: safeConfig.placeholder,
});
}, [
safeConfig.language,
safeConfig.theme,
safeConfig.fontSize,
safeConfig.lineNumbers,
safeConfig.wordWrap,
safeConfig.readOnly,
safeConfig.autoFormat,
safeConfig.placeholder,
]);
const updateConfig = (key: keyof CodeTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
setLocalValues((prev) => ({ ...prev, [key]: value }));
// 실제 config 업데이트
const newConfig = { ...safeConfig, [key]: value };
console.log("💻 CodeTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
});
onConfigChange(newConfig);
};
return (
<div className="space-y-4">
{/* 프로그래밍 언어 */}
<div>
<Label htmlFor="language" className="text-sm font-medium">
</Label>
<Select value={localValues.language} onValueChange={(value) => updateConfig("language", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="언어 선택" />
</SelectTrigger>
<SelectContent className="max-h-60">
{languages.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 테마 */}
<div>
<Label htmlFor="theme" className="text-sm font-medium">
</Label>
<Select value={localValues.theme} onValueChange={(value) => updateConfig("theme", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="테마 선택" />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.value} value={theme.value}>
{theme.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 폰트 크기 */}
<div>
<Label htmlFor="fontSize" className="text-sm font-medium">
: {localValues.fontSize}px
</Label>
<div className="mt-2">
<Slider
value={[localValues.fontSize]}
onValueChange={(value) => updateConfig("fontSize", value[0])}
min={10}
max={24}
step={1}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>10px</span>
<span>24px</span>
</div>
</div>
</div>
{/* 라인 넘버 표시 */}
<div className="flex items-center justify-between">
<Label htmlFor="lineNumbers" className="text-sm font-medium">
</Label>
<Checkbox
id="lineNumbers"
checked={localValues.lineNumbers}
onCheckedChange={(checked) => updateConfig("lineNumbers", !!checked)}
/>
</div>
{/* 단어 줄바꿈 */}
<div className="flex items-center justify-between">
<Label htmlFor="wordWrap" className="text-sm font-medium">
</Label>
<Checkbox
id="wordWrap"
checked={localValues.wordWrap}
onCheckedChange={(checked) => updateConfig("wordWrap", !!checked)}
/>
</div>
{/* 읽기 전용 */}
<div className="flex items-center justify-between">
<Label htmlFor="readOnly" className="text-sm font-medium">
</Label>
<Checkbox
id="readOnly"
checked={localValues.readOnly}
onCheckedChange={(checked) => updateConfig("readOnly", !!checked)}
/>
</div>
{/* 자동 포맷팅 */}
<div className="flex items-center justify-between">
<Label htmlFor="autoFormat" className="text-sm font-medium">
</Label>
<Checkbox
id="autoFormat"
checked={localValues.autoFormat}
onCheckedChange={(checked) => updateConfig("autoFormat", !!checked)}
/>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="코드를 입력하세요..."
className="mt-1"
/>
</div>
{/* 미리보기 */}
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2">
<div
className={`rounded border p-3 font-mono ${
localValues.theme === "dark" ? "bg-gray-900 text-white" : "bg-white text-black"
}`}
style={{ fontSize: `${localValues.fontSize}px` }}
>
<div className="flex">
{localValues.lineNumbers && (
<div className="mr-3 text-gray-400">
<div>1</div>
<div>2</div>
<div>3</div>
</div>
)}
<div className={localValues.wordWrap ? "whitespace-pre-wrap" : "whitespace-pre"}>
{localValues.placeholder || getCodeSample(localValues.language)}
</div>
</div>
</div>
</div>
<div className="mt-2 text-xs text-gray-500">
: {languages.find((l) => l.value === localValues.language)?.label}, :{" "}
{themes.find((t) => t.value === localValues.theme)?.label}, : {localValues.fontSize}px
{localValues.readOnly && ", 읽기전용"}
</div>
</div>
{/* 안내 메시지 */}
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
<br />
<br /> Monaco Editor CodeMirror를
</div>
</div>
</div>
);
};
// 언어별 샘플 코드
const getCodeSample = (language: string): string => {
switch (language) {
case "javascript":
return "function hello() {\n console.log('Hello World!');\n}";
case "python":
return "def hello():\n print('Hello World!')\n";
case "java":
return 'public class Hello {\n public static void main(String[] args) {\n System.out.println("Hello World!");\n }\n}';
case "html":
return "<div>\n <h1>Hello World!</h1>\n</div>";
case "css":
return ".hello {\n color: blue;\n font-size: 16px;\n}";
case "json":
return '{\n "message": "Hello World!",\n "status": "success"\n}';
default:
return "// Hello World!\nconsole.log('Hello World!');";
}
};
export default CodeTypeConfigPanel;

View File

@ -0,0 +1,320 @@
"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 { DateTypeConfig } from "@/types/screen";
interface DateTypeConfigPanelProps {
config: DateTypeConfig;
onConfigChange: (config: DateTypeConfig) => void;
}
export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
format: "YYYY-MM-DD" as const,
showTime: false,
placeholder: "",
minDate: "",
maxDate: "",
defaultValue: "",
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState(() => {
console.log("📅 DateTypeConfigPanel 초기 상태 설정:", {
config,
safeConfig,
});
return {
format: safeConfig.format,
showTime: safeConfig.showTime,
placeholder: safeConfig.placeholder,
minDate: safeConfig.minDate,
maxDate: safeConfig.maxDate,
defaultValue: safeConfig.defaultValue,
};
});
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
// config가 실제로 존재하고 의미있는 데이터가 있을 때만 업데이트
const hasValidConfig = config && Object.keys(config).length > 0;
console.log("📅 DateTypeConfigPanel config 변경 감지:", {
config,
configExists: !!config,
configKeys: config ? Object.keys(config) : [],
hasValidConfig,
safeConfig,
safeConfigKeys: Object.keys(safeConfig),
currentLocalValues: localValues,
configStringified: JSON.stringify(config),
safeConfigStringified: JSON.stringify(safeConfig),
willUpdateLocalValues: hasValidConfig,
timestamp: new Date().toISOString(),
});
// config가 없거나 비어있으면 로컬 상태를 유지
if (!hasValidConfig) {
console.log("⚠️ config가 없거나 비어있음 - 로컬 상태 유지");
return;
}
const newLocalValues = {
format: safeConfig.format,
showTime: safeConfig.showTime,
placeholder: safeConfig.placeholder,
minDate: safeConfig.minDate,
maxDate: safeConfig.maxDate,
defaultValue: safeConfig.defaultValue,
};
// 실제로 변경된 값이 있을 때만 업데이트
const hasChanges =
localValues.format !== newLocalValues.format ||
localValues.showTime !== newLocalValues.showTime ||
localValues.defaultValue !== newLocalValues.defaultValue ||
localValues.placeholder !== newLocalValues.placeholder ||
localValues.minDate !== newLocalValues.minDate ||
localValues.maxDate !== newLocalValues.maxDate;
console.log("🔄 로컬 상태 업데이트 검사:", {
oldLocalValues: localValues,
newLocalValues,
hasChanges,
changes: {
format: localValues.format !== newLocalValues.format,
showTime: localValues.showTime !== newLocalValues.showTime,
defaultValue: localValues.defaultValue !== newLocalValues.defaultValue,
placeholder: localValues.placeholder !== newLocalValues.placeholder,
minDate: localValues.minDate !== newLocalValues.minDate,
maxDate: localValues.maxDate !== newLocalValues.maxDate,
},
});
if (hasChanges) {
console.log("✅ 로컬 상태 업데이트 실행");
setLocalValues(newLocalValues);
} else {
console.log("⏭️ 변경사항 없음 - 로컬 상태 유지");
}
}, [JSON.stringify(config)]);
const updateConfig = (key: keyof DateTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
setLocalValues((prev) => ({ ...prev, [key]: value }));
// 실제 config 업데이트 - 현재 로컬 상태를 기반으로 새 객체 생성 (safeConfig 기본값 덮어쓰기 방지)
const newConfig = JSON.parse(JSON.stringify({ ...localValues, [key]: value }));
console.log("📅 DateTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
localValues,
timestamp: new Date().toISOString(),
changes: {
format: newConfig.format !== safeConfig.format,
showTime: newConfig.showTime !== safeConfig.showTime,
placeholder: newConfig.placeholder !== safeConfig.placeholder,
minDate: newConfig.minDate !== safeConfig.minDate,
maxDate: newConfig.maxDate !== safeConfig.maxDate,
defaultValue: newConfig.defaultValue !== safeConfig.defaultValue,
},
willCallOnConfigChange: true,
});
console.log("🔄 onConfigChange 호출 직전:", {
newConfig,
configStringified: JSON.stringify(newConfig),
});
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
setTimeout(() => {
console.log("✅ onConfigChange 호출 완료:", {
key,
newConfig,
timestamp: new Date().toISOString(),
});
onConfigChange(newConfig);
}, 0);
};
return (
<div className="space-y-4">
{/* 날짜 형식 */}
<div>
<Label htmlFor="dateFormat" className="text-sm font-medium">
</Label>
<Select
value={localValues.format}
onValueChange={(value) => {
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);
}}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="날짜 형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD</SelectItem>
<SelectItem value="YYYY-MM-DD HH:mm">YYYY-MM-DD HH:mm</SelectItem>
<SelectItem value="YYYY-MM-DD HH:mm:ss">YYYY-MM-DD HH:mm:ss</SelectItem>
</SelectContent>
</Select>
</div>
{/* 시간 표시 여부 */}
<div className="flex items-center justify-between">
<Label htmlFor="showTime" className="text-sm font-medium">
</Label>
<Checkbox
id="showTime"
checked={localValues.showTime}
onCheckedChange={(checked) => {
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);
}}
/>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="날짜를 선택하세요"
className="mt-1"
/>
</div>
{/* 최소 날짜 */}
<div>
<Label htmlFor="minDate" className="text-sm font-medium">
</Label>
<Input
id="minDate"
type={localValues.showTime || localValues.format.includes("HH:mm") ? "datetime-local" : "date"}
value={localValues.minDate}
onChange={(e) => updateConfig("minDate", e.target.value)}
className="mt-1"
/>
</div>
{/* 최대 날짜 */}
<div>
<Label htmlFor="maxDate" className="text-sm font-medium">
</Label>
<Input
id="maxDate"
type={localValues.showTime || localValues.format.includes("HH:mm") ? "datetime-local" : "date"}
value={localValues.maxDate}
onChange={(e) => updateConfig("maxDate", e.target.value)}
className="mt-1"
/>
</div>
{/* 기본값 */}
<div>
<Label htmlFor="defaultValue" className="text-sm font-medium">
</Label>
<Input
id="defaultValue"
type={localValues.showTime || localValues.format.includes("HH:mm") ? "datetime-local" : "date"}
value={localValues.defaultValue}
onChange={(e) => updateConfig("defaultValue", e.target.value)}
className="mt-1"
/>
</div>
</div>
);
};

View File

@ -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<EntityTypeConfigPanelProps> = ({ 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 (
<div className="space-y-4">
{/* 엔터티 이름 */}
<div>
<Label htmlFor="entityName" className="text-sm font-medium">
</Label>
<Input
id="entityName"
value={localValues.entityName}
onChange={(e) => updateConfig("entityName", e.target.value)}
placeholder="예: User, Company, Product"
className="mt-1"
/>
</div>
{/* API 엔드포인트 */}
<div>
<Label htmlFor="apiEndpoint" className="text-sm font-medium">
API
</Label>
<Input
id="apiEndpoint"
value={localValues.apiEndpoint}
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
placeholder="예: /api/users"
className="mt-1"
/>
</div>
{/* 필드 설정 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="valueField" className="text-sm font-medium">
</Label>
<Input
id="valueField"
value={localValues.valueField}
onChange={(e) => updateConfig("valueField", e.target.value)}
placeholder="id"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="displayField" className="text-sm font-medium">
</Label>
<Input
id="displayField"
value={localValues.displayField}
onChange={(e) => updateConfig("displayField", e.target.value)}
placeholder="name"
className="mt-1"
/>
</div>
</div>
{/* 표시 형식 */}
<div>
<Label htmlFor="displayFormat" className="text-sm font-medium">
</Label>
<Select value={localValues.displayFormat} onValueChange={(value) => updateConfig("displayFormat", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="형식 선택" />
</SelectTrigger>
<SelectContent>
{displayFormats.map((format) => (
<SelectItem key={format.value} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="엔터티를 선택하세요"
className="mt-1"
/>
</div>
{/* 옵션들 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="searchable" className="text-sm font-medium">
</Label>
<Checkbox
id="searchable"
checked={localValues.searchable}
onCheckedChange={(checked) => updateConfig("searchable", !!checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="multiple" className="text-sm font-medium">
</Label>
<Checkbox
id="multiple"
checked={localValues.multiple}
onCheckedChange={(checked) => updateConfig("multiple", !!checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="allowClear" className="text-sm font-medium">
</Label>
<Checkbox
id="allowClear"
checked={localValues.allowClear}
onCheckedChange={(checked) => updateConfig("allowClear", !!checked)}
/>
</div>
</div>
{/* 최대 선택 개수 (다중 선택 시) */}
{localValues.multiple && (
<div>
<Label htmlFor="maxSelections" className="text-sm font-medium">
</Label>
<Input
id="maxSelections"
type="number"
min="1"
value={localValues.maxSelections}
onChange={(e) => updateConfig("maxSelections", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
)}
{/* 필터 관리 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
{/* 기존 필터 목록 */}
<div className="max-h-40 space-y-2 overflow-y-auto">
{(safeConfig.filters || []).map((filter, index) => (
<div key={index} className="flex items-center space-x-2 rounded border p-2 text-sm">
<Input
value={filter.field}
onChange={(e) => updateFilter(index, "field", e.target.value)}
placeholder="필드명"
className="flex-1"
/>
<Select value={filter.operator} onValueChange={(value) => updateFilter(index, "operator", value)}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.value}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) => updateFilter(index, "value", e.target.value)}
placeholder="값"
className="flex-1"
/>
<Button size="sm" variant="outline" onClick={() => removeFilter(index)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* 새 필터 추가 */}
<div className="flex items-center space-x-2 rounded border-2 border-dashed border-gray-300 p-2">
<Input
value={newFilter.field}
onChange={(e) => setNewFilter((prev) => ({ ...prev, field: e.target.value }))}
placeholder="필드명"
className="flex-1"
/>
<Select
value={newFilter.operator}
onValueChange={(value) => setNewFilter((prev) => ({ ...prev, operator: value }))}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.value}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={newFilter.value}
onChange={(e) => setNewFilter((prev) => ({ ...prev, value: e.target.value }))}
placeholder="값"
className="flex-1"
/>
<Button size="sm" onClick={addFilter} disabled={!newFilter.field.trim() || !newFilter.value.trim()}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="text-xs text-gray-500"> {(safeConfig.filters || []).length} </div>
</div>
{/* 미리보기 */}
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2">
<div className="flex items-center space-x-2 rounded border bg-white p-2">
{localValues.searchable && <Search className="h-4 w-4 text-gray-400" />}
<div className="flex-1 text-sm text-gray-600">
{localValues.placeholder || `${localValues.entityName || "엔터티"}를 선택하세요`}
</div>
<Database className="h-4 w-4 text-gray-400" />
</div>
</div>
<div className="mt-2 text-xs text-gray-500">
: {localValues.entityName || "없음"}, API: {localValues.apiEndpoint || "없음"}, :{" "}
{localValues.valueField}, : {localValues.displayField}
{localValues.multiple && `, 다중선택`}
{localValues.searchable && `, 검색가능`}
</div>
</div>
{/* 안내 메시지 */}
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
<br />
API
<br />
<br /> ,
</div>
</div>
</div>
);
};
export default EntityTypeConfigPanel;

View File

@ -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<FileTypeConfigPanelProps> = ({ 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 (
<div className="space-y-4">
{/* 파일 타입 프리셋 */}
<div>
<Label className="text-sm font-medium"> </Label>
<div className="mt-2 grid grid-cols-2 gap-2">
{fileTypePresets.map((preset) => {
const IconComponent = preset.icon;
return (
<Button
key={preset.label}
variant="outline"
size="sm"
onClick={() => applyFileTypePreset(preset)}
className="flex items-center space-x-2"
>
<IconComponent className="h-3 w-3" />
<span>{preset.label}</span>
</Button>
);
})}
</div>
</div>
{/* Accept 속성 */}
<div>
<Label htmlFor="accept" className="text-sm font-medium">
(accept)
</Label>
<Input
id="accept"
value={localValues.accept}
onChange={(e) => updateConfig("accept", e.target.value)}
placeholder="예: image/*,.pdf,.docx"
className="mt-1"
/>
<div className="mt-1 text-xs text-gray-500">MIME </div>
</div>
{/* 허용 확장자 관리 */}
<div>
<Label className="text-sm font-medium"> </Label>
<div className="mt-2 flex flex-wrap gap-1">
{(safeConfig.allowedExtensions || []).map((extension, index) => (
<Badge key={index} variant="secondary" className="flex items-center space-x-1">
<span>{extension}</span>
<X className="h-3 w-3 cursor-pointer" onClick={() => removeExtension(index)} />
</Badge>
))}
</div>
<div className="mt-2 flex space-x-2">
<Input
value={newExtension}
onChange={(e) => setNewExtension(e.target.value)}
placeholder="확장자 입력 (예: jpg)"
className="flex-1"
/>
<Button size="sm" onClick={addExtension} disabled={!newExtension.trim()}>
</Button>
</div>
</div>
{/* 다중 파일 선택 */}
<div className="flex items-center justify-between">
<Label htmlFor="multiple" className="text-sm font-medium">
</Label>
<Checkbox
id="multiple"
checked={localValues.multiple}
onCheckedChange={(checked) => updateConfig("multiple", !!checked)}
/>
</div>
{/* 최대 파일 크기 */}
<div>
<Label htmlFor="maxSize" className="text-sm font-medium">
: {formatFileSize(localValues.maxSize)}
</Label>
<div className="mt-2">
<Slider
value={[localValues.maxSize]}
onValueChange={(value) => updateConfig("maxSize", value[0])}
min={0.1}
max={100}
step={0.1}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>100 KB</span>
<span>100 MB</span>
</div>
</div>
</div>
{/* 최대 파일 개수 (다중 선택 시) */}
{localValues.multiple && (
<div>
<Label htmlFor="maxFiles" className="text-sm font-medium">
: {localValues.maxFiles}
</Label>
<div className="mt-2">
<Slider
value={[localValues.maxFiles]}
onValueChange={(value) => updateConfig("maxFiles", value[0])}
min={1}
max={20}
step={1}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>1</span>
<span>20</span>
</div>
</div>
</div>
)}
{/* 미리보기 표시 */}
<div className="flex items-center justify-between">
<Label htmlFor="preview" className="text-sm font-medium">
()
</Label>
<Checkbox
id="preview"
checked={localValues.preview}
onCheckedChange={(checked) => updateConfig("preview", !!checked)}
/>
</div>
{/* 드래그 앤 드롭 */}
<div className="flex items-center justify-between">
<Label htmlFor="dragDrop" className="text-sm font-medium">
</Label>
<Checkbox
id="dragDrop"
checked={localValues.dragDrop}
onCheckedChange={(checked) => updateConfig("dragDrop", !!checked)}
/>
</div>
{/* 미리보기 */}
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2">
<div
className={`rounded border-2 border-dashed p-4 text-center ${
localValues.dragDrop ? "border-blue-300 bg-blue-50" : "border-gray-300"
}`}
>
<Upload className="mx-auto h-8 w-8 text-gray-400" />
<div className="mt-2 text-sm text-gray-600">
{localValues.dragDrop ? "파일을 드래그하여 놓거나 클릭하여 선택" : "클릭하여 파일 선택"}
</div>
<div className="mt-1 text-xs text-gray-500">
{localValues.accept && `허용 타입: ${localValues.accept}`}
{(safeConfig.allowedExtensions || []).length > 0 && (
<div>: {(safeConfig.allowedExtensions || []).join(", ")}</div>
)}
: {formatFileSize(localValues.maxSize)}
{localValues.multiple && `, 최대 ${localValues.maxFiles}`}
</div>
</div>
</div>
</div>
{/* 안내 메시지 */}
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
Accept
<br />
<br />
<br />
</div>
</div>
</div>
);
};
export default FileTypeConfigPanel;

View File

@ -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<NumberTypeConfigPanelProps> = ({ 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 (
<div className="space-y-4">
{/* 숫자 형식 */}
<div>
<Label htmlFor="format" className="text-sm font-medium">
</Label>
<Select value={localValues.format} onValueChange={(value) => updateConfig("format", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="숫자 형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="integer"></SelectItem>
<SelectItem value="decimal"></SelectItem>
<SelectItem value="currency"></SelectItem>
<SelectItem value="percentage"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 범위 설정 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="min" className="text-sm font-medium">
</Label>
<Input
id="min"
type="number"
value={localValues.min}
onChange={(e) => updateConfig("min", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="최소값"
/>
</div>
<div>
<Label htmlFor="max" className="text-sm font-medium">
</Label>
<Input
id="max"
type="number"
value={localValues.max}
onChange={(e) => updateConfig("max", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="최대값"
/>
</div>
</div>
{/* 단계값 */}
<div>
<Label htmlFor="step" className="text-sm font-medium">
( )
</Label>
<Input
id="step"
type="number"
step="0.01"
value={localValues.step}
onChange={(e) => updateConfig("step", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="1"
/>
</div>
{/* 소수점 자릿수 (decimal 형식인 경우) */}
{localValues.format === "decimal" && (
<div>
<Label htmlFor="decimalPlaces" className="text-sm font-medium">
릿
</Label>
<Input
id="decimalPlaces"
type="number"
min="0"
max="10"
value={localValues.decimalPlaces}
onChange={(e) => updateConfig("decimalPlaces", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="2"
/>
</div>
)}
{/* 천 단위 구분자 */}
<div className="flex items-center justify-between">
<Label htmlFor="thousandSeparator" className="text-sm font-medium">
</Label>
<Checkbox
id="thousandSeparator"
checked={localValues.thousandSeparator}
onCheckedChange={(checked) => updateConfig("thousandSeparator", !!checked)}
/>
</div>
{/* 접두사/접미사 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="prefix" className="text-sm font-medium">
</Label>
<Input
id="prefix"
value={localValues.prefix}
onChange={(e) => updateConfig("prefix", e.target.value)}
className="mt-1"
placeholder="$, ₩ 등"
/>
</div>
<div>
<Label htmlFor="suffix" className="text-sm font-medium">
</Label>
<Input
id="suffix"
value={localValues.suffix}
onChange={(e) => updateConfig("suffix", e.target.value)}
className="mt-1"
placeholder="%, kg 등"
/>
</div>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="숫자를 입력하세요"
className="mt-1"
/>
</div>
</div>
);
};

View File

@ -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<RadioTypeConfigPanelProps> = ({ 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 (
<div className="space-y-4">
{/* 레이아웃 방향 */}
<div>
<Label htmlFor="layout" className="text-sm font-medium">
</Label>
<Select value={localValues.layout} onValueChange={(value) => updateConfig("layout", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="레이아웃 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="vertical"></SelectItem>
<SelectItem value="horizontal"></SelectItem>
<SelectItem value="grid"> (2)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 기본값 */}
<div>
<Label htmlFor="defaultValue" className="text-sm font-medium">
</Label>
<Select
value={localValues.defaultValue || "__none__"}
onValueChange={(value) => updateConfig("defaultValue", value)}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(safeConfig.options || []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 선택 안함 허용 */}
<div className="flex items-center justify-between">
<Label htmlFor="allowNone" className="text-sm font-medium">
</Label>
<Checkbox
id="allowNone"
checked={localValues.allowNone}
onCheckedChange={(checked) => updateConfig("allowNone", !!checked)}
/>
</div>
{/* 옵션 관리 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
{/* 기존 옵션 목록 */}
<div className="max-h-64 space-y-2 overflow-y-auto">
{localOptions.map((option, index) => (
<div key={`${option.value}-${index}`} className="flex items-center space-x-2 rounded border p-2">
<Input
value={option.label}
onChange={(e) => updateOption(index, "label", e.target.value)}
placeholder="표시 텍스트"
className="flex-1"
/>
<Input
value={option.value}
onChange={(e) => updateOption(index, "value", e.target.value)}
placeholder="값"
className="flex-1"
/>
<Button size="sm" variant="outline" onClick={() => removeOption(index)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* 새 옵션 추가 */}
<div className="flex items-center space-x-2 rounded border-2 border-dashed border-gray-300 p-2">
<Input
value={newOption.label}
onChange={(e) => setNewOption((prev) => ({ ...prev, label: e.target.value }))}
placeholder="새 옵션 라벨"
className="flex-1"
/>
<Input
value={newOption.value}
onChange={(e) => setNewOption((prev) => ({ ...prev, value: e.target.value }))}
placeholder="새 옵션 값"
className="flex-1"
/>
<Button size="sm" onClick={addOption} disabled={!newOption.label.trim() || !newOption.value.trim()}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="text-xs text-gray-500"> {(safeConfig.options || []).length} </div>
</div>
{/* 미리보기 */}
{(safeConfig.options || []).length > 0 && (
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2">
<RadioGroup
value={localValues.defaultValue}
className={
localValues.layout === "horizontal"
? "flex flex-row space-x-4"
: localValues.layout === "grid"
? "grid grid-cols-2 gap-2"
: "space-y-2"
}
>
{(safeConfig.options || []).map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`preview-${option.value}`} />
<Label htmlFor={`preview-${option.value}`} className="text-sm">
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className="mt-2 text-xs text-gray-500">
:{" "}
{localValues.layout === "vertical" ? "세로" : localValues.layout === "horizontal" ? "가로" : "격자"},
: {localValues.defaultValue || "없음"}
{localValues.allowNone && ", 선택해제 가능"}
</div>
</div>
)}
{/* 안내 메시지 */}
{localValues.allowNone && (
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
.
</div>
</div>
)}
</div>
);
};
export default RadioTypeConfigPanel;

View File

@ -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<SelectTypeConfigPanelProps> = ({ 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 (
<div className="space-y-4">
{/* 기본 설정 */}
<div className="space-y-3">
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="옵션을 선택하세요"
className="mt-1"
/>
</div>
{/* 다중 선택 */}
<div className="flex items-center justify-between">
<Label htmlFor="multiple" className="text-sm font-medium">
</Label>
<Checkbox
id="multiple"
checked={localValues.multiple}
onCheckedChange={(checked) => updateConfig("multiple", !!checked)}
/>
</div>
{/* 검색 가능 */}
<div className="flex items-center justify-between">
<Label htmlFor="searchable" className="text-sm font-medium">
</Label>
<Checkbox
id="searchable"
checked={localValues.searchable}
onCheckedChange={(checked) => updateConfig("searchable", !!checked)}
/>
</div>
{/* 클리어 허용 */}
<div className="flex items-center justify-between">
<Label htmlFor="allowClear" className="text-sm font-medium">
</Label>
<Checkbox
id="allowClear"
checked={localValues.allowClear}
onCheckedChange={(checked) => updateConfig("allowClear", !!checked)}
/>
</div>
{/* 최대 선택 개수 (다중 선택인 경우) */}
{localValues.multiple && (
<div>
<Label htmlFor="maxSelections" className="text-sm font-medium">
</Label>
<Input
id="maxSelections"
type="number"
min="1"
value={localValues.maxSelections}
onChange={(e) => updateConfig("maxSelections", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
)}
</div>
{/* 옵션 관리 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
{/* 기존 옵션 목록 */}
<div className="max-h-40 space-y-2 overflow-y-auto">
{localOptions.map((option, index) => (
<div key={`${option.value}-${index}`} className="flex items-center space-x-2 rounded border p-2">
<Input
value={option.label}
onChange={(e) => updateOption(index, "label", e.target.value)}
placeholder="표시 텍스트"
className="flex-1"
/>
<Input
value={option.value}
onChange={(e) => updateOption(index, "value", e.target.value)}
placeholder="값"
className="flex-1"
/>
<Checkbox
checked={option.disabled}
onCheckedChange={(checked) => updateOption(index, "disabled", !!checked)}
title="비활성화"
/>
<Button size="sm" variant="ghost" onClick={() => removeOption(index)} className="h-8 w-8 p-1">
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
{/* 새 옵션 추가 */}
<div className="flex items-center space-x-2">
<Input
value={newOption.label}
onChange={(e) => setNewOption({ ...newOption, label: e.target.value })}
placeholder="표시 텍스트"
className="flex-1"
/>
<Input
value={newOption.value}
onChange={(e) => setNewOption({ ...newOption, value: e.target.value })}
placeholder="값"
className="flex-1"
/>
<Button
size="sm"
onClick={addOption}
disabled={!newOption.label.trim() || !newOption.value.trim()}
className="h-8 w-8 p-1"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
};

View File

@ -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<TextTypeConfigPanelProps> = ({ 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 (
<div className="space-y-4">
{/* 입력 형식 */}
<div>
<Label htmlFor="format" className="text-sm font-medium">
</Label>
<Select value={localValues.format} onValueChange={(value) => updateConfig("format", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="입력 형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="email"></SelectItem>
<SelectItem value="phone"></SelectItem>
<SelectItem value="url">URL</SelectItem>
<SelectItem value="korean"></SelectItem>
<SelectItem value="english"></SelectItem>
<SelectItem value="alphanumeric"></SelectItem>
<SelectItem value="numeric"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 길이 제한 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="minLength" className="text-sm font-medium">
</Label>
<Input
id="minLength"
type="number"
min="0"
value={localValues.minLength}
onChange={(e) => updateConfig("minLength", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
<div>
<Label htmlFor="maxLength" className="text-sm font-medium">
</Label>
<Input
id="maxLength"
type="number"
min="0"
value={localValues.maxLength}
onChange={(e) => updateConfig("maxLength", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
</div>
{/* 정규식 패턴 */}
<div>
<Label htmlFor="pattern" className="text-sm font-medium">
</Label>
<Input
id="pattern"
value={localValues.pattern}
onChange={(e) => updateConfig("pattern", e.target.value)}
className="mt-1"
placeholder="예: ^[0-9]{3}-[0-9]{4}-[0-9]{4}$"
/>
<div className="mt-1 text-xs text-gray-500">JavaScript ()</div>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="입력 힌트 텍스트"
className="mt-1"
/>
</div>
{/* 여러 줄 입력 */}
<div className="flex items-center justify-between">
<Label htmlFor="multiline" className="text-sm font-medium">
(textarea)
</Label>
<Checkbox
id="multiline"
checked={localValues.multiline}
onCheckedChange={(checked) => updateConfig("multiline", !!checked)}
/>
</div>
{/* 형식별 안내 메시지 */}
{localValues.format !== "none" && (
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
{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" && "숫자만 입력할 수 있습니다"}
</div>
</div>
)}
</div>
);
};
export default TextTypeConfigPanel;

View File

@ -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<TextareaTypeConfigPanelProps> = ({ 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 (
<div className="space-y-4">
{/* 기본 행 수 */}
<div>
<Label htmlFor="rows" className="text-sm font-medium">
: {localValues.rows}
</Label>
<div className="mt-2">
<Slider
value={[localValues.rows]}
onValueChange={(value) => updateConfig("rows", value[0])}
min={1}
max={20}
step={1}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>1</span>
<span>20</span>
</div>
</div>
</div>
{/* 길이 제한 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="minLength" className="text-sm font-medium">
</Label>
<Input
id="minLength"
type="number"
min="0"
value={localValues.minLength}
onChange={(e) => updateConfig("minLength", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
<div>
<Label htmlFor="maxLength" className="text-sm font-medium">
</Label>
<Input
id="maxLength"
type="number"
min="0"
value={localValues.maxLength}
onChange={(e) => updateConfig("maxLength", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="입력 힌트 텍스트"
className="mt-1"
/>
</div>
{/* 크기 조정 가능 */}
<div className="flex items-center justify-between">
<Label htmlFor="resizable" className="text-sm font-medium">
</Label>
<Checkbox
id="resizable"
checked={localValues.resizable}
onCheckedChange={(checked) => updateConfig("resizable", !!checked)}
/>
</div>
{/* 자동 크기 조정 */}
<div className="flex items-center justify-between">
<Label htmlFor="autoResize" className="text-sm font-medium">
</Label>
<Checkbox
id="autoResize"
checked={localValues.autoResize}
onCheckedChange={(checked) => updateConfig("autoResize", !!checked)}
/>
</div>
{/* 단어 자동 줄바꿈 */}
<div className="flex items-center justify-between">
<Label htmlFor="wordWrap" className="text-sm font-medium">
</Label>
<Checkbox
id="wordWrap"
checked={localValues.wordWrap}
onCheckedChange={(checked) => updateConfig("wordWrap", !!checked)}
/>
</div>
{/* 설정 미리보기 */}
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2">
<textarea
className="w-full rounded border border-gray-300 p-2 text-sm"
rows={localValues.rows}
placeholder={localValues.placeholder || "텍스트를 입력하세요..."}
style={{
resize: localValues.resizable ? "both" : "none",
whiteSpace: localValues.wordWrap ? "pre-wrap" : "nowrap",
}}
readOnly
/>
</div>
<div className="mt-1 text-xs text-gray-500">
: {localValues.rows},{localValues.minLength && ` 최소: ${localValues.minLength}자,`}
{localValues.maxLength && ` 최대: ${localValues.maxLength}자,`}
{localValues.resizable ? " 크기조정 가능" : " 크기고정"}
</div>
</div>
</div>
);
};
export default TextareaTypeConfigPanel;

View File

@ -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,
};
};

View File

@ -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;
}

View File

@ -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<string, any>;
displayProperties?: Record<string, any>; // 레거시 지원용 (향후 제거 예정)
webTypeConfig?: WebTypeConfig; // 웹타입별 상세 설정
}
// 컴포넌트 유니온 타입
@ -388,3 +390,115 @@ export interface PaginatedResponse<T> {
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<string, any>; // 추가 필터 조건
}
// 웹타입별 설정 유니온 타입
export type WebTypeConfig =
| DateTypeConfig
| NumberTypeConfig
| SelectTypeConfig
| TextTypeConfig
| FileTypeConfig
| TextareaTypeConfig
| CheckboxTypeConfig
| RadioTypeConfig
| CodeTypeConfig
| EntityTypeConfig;