dev #46
|
|
@ -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 연동
|
||||
- ✅ **고도화 완료**: 실시간 속성 편집, 라벨 관리, 다중 드래그, 격자 시스템
|
||||
- 📋 **다음 계획**: 웹타입별 상세 설정, 반응형 레이아웃, 고급 기능
|
||||
|
||||
### 🎉 **완전 기능 화면관리 시스템 완성!**
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue