feature/screen-management #100
|
|
@ -0,0 +1,168 @@
|
|||
import { query } from "../src/database/db";
|
||||
import { logger } from "../src/utils/logger";
|
||||
|
||||
/**
|
||||
* input_type을 web_type으로 마이그레이션하는 스크립트
|
||||
*
|
||||
* 목적:
|
||||
* - column_labels 테이블의 input_type 값을 읽어서
|
||||
* - 해당하는 기본 web_type 값으로 변환
|
||||
* - web_type이 null인 경우에만 업데이트
|
||||
*/
|
||||
|
||||
// input_type → 기본 web_type 매핑
|
||||
const INPUT_TYPE_TO_WEB_TYPE: Record<string, string> = {
|
||||
text: "text", // 일반 텍스트
|
||||
number: "number", // 정수
|
||||
date: "date", // 날짜
|
||||
code: "code", // 코드 선택박스
|
||||
entity: "entity", // 엔티티 참조
|
||||
select: "select", // 선택박스
|
||||
checkbox: "checkbox", // 체크박스
|
||||
radio: "radio", // 라디오버튼
|
||||
direct: "text", // direct는 text로 매핑
|
||||
};
|
||||
|
||||
async function migrateInputTypeToWebType() {
|
||||
try {
|
||||
logger.info("=".repeat(60));
|
||||
logger.info("input_type → web_type 마이그레이션 시작");
|
||||
logger.info("=".repeat(60));
|
||||
|
||||
// 1. 현재 상태 확인
|
||||
const stats = await query<{
|
||||
total: string;
|
||||
has_input_type: string;
|
||||
has_web_type: string;
|
||||
needs_migration: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(input_type) FILTER (WHERE input_type IS NOT NULL) as has_input_type,
|
||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type,
|
||||
COUNT(*) FILTER (WHERE input_type IS NOT NULL AND web_type IS NULL) as needs_migration
|
||||
FROM column_labels`
|
||||
);
|
||||
|
||||
const stat = stats[0];
|
||||
logger.info("\n📊 현재 상태:");
|
||||
logger.info(` - 전체 컬럼: ${stat.total}개`);
|
||||
logger.info(` - input_type 있음: ${stat.has_input_type}개`);
|
||||
logger.info(` - web_type 있음: ${stat.has_web_type}개`);
|
||||
logger.info(` - 마이그레이션 필요: ${stat.needs_migration}개`);
|
||||
|
||||
if (parseInt(stat.needs_migration) === 0) {
|
||||
logger.info("\n✅ 마이그레이션이 필요한 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. input_type별 분포 확인
|
||||
const distribution = await query<{
|
||||
input_type: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
input_type,
|
||||
COUNT(*) as count
|
||||
FROM column_labels
|
||||
WHERE input_type IS NOT NULL AND web_type IS NULL
|
||||
GROUP BY input_type
|
||||
ORDER BY input_type`
|
||||
);
|
||||
|
||||
logger.info("\n📋 input_type별 분포:");
|
||||
distribution.forEach((item) => {
|
||||
const webType =
|
||||
INPUT_TYPE_TO_WEB_TYPE[item.input_type] || item.input_type;
|
||||
logger.info(` - ${item.input_type} → ${webType}: ${item.count}개`);
|
||||
});
|
||||
|
||||
// 3. 마이그레이션 실행
|
||||
logger.info("\n🔄 마이그레이션 실행 중...");
|
||||
|
||||
let totalUpdated = 0;
|
||||
|
||||
for (const [inputType, webType] of Object.entries(INPUT_TYPE_TO_WEB_TYPE)) {
|
||||
const result = await query(
|
||||
`UPDATE column_labels
|
||||
SET
|
||||
web_type = $1,
|
||||
updated_date = NOW()
|
||||
WHERE input_type = $2
|
||||
AND web_type IS NULL
|
||||
RETURNING id, table_name, column_name`,
|
||||
[webType, inputType]
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
logger.info(
|
||||
` ✓ ${inputType} → ${webType}: ${result.length}개 업데이트`
|
||||
);
|
||||
totalUpdated += result.length;
|
||||
|
||||
// 처음 5개만 출력
|
||||
result.slice(0, 5).forEach((row: any) => {
|
||||
logger.info(` - ${row.table_name}.${row.column_name}`);
|
||||
});
|
||||
if (result.length > 5) {
|
||||
logger.info(` ... 외 ${result.length - 5}개`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 결과 확인
|
||||
const afterStats = await query<{
|
||||
total: string;
|
||||
has_web_type: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type
|
||||
FROM column_labels`
|
||||
);
|
||||
|
||||
const afterStat = afterStats[0];
|
||||
|
||||
logger.info("\n" + "=".repeat(60));
|
||||
logger.info("✅ 마이그레이션 완료!");
|
||||
logger.info("=".repeat(60));
|
||||
logger.info(`📊 최종 통계:`);
|
||||
logger.info(` - 전체 컬럼: ${afterStat.total}개`);
|
||||
logger.info(` - web_type 설정됨: ${afterStat.has_web_type}개`);
|
||||
logger.info(` - 업데이트된 컬럼: ${totalUpdated}개`);
|
||||
logger.info("=".repeat(60));
|
||||
|
||||
// 5. 샘플 데이터 출력
|
||||
logger.info("\n📝 샘플 데이터 (check_report_mng 테이블):");
|
||||
const samples = await query<{
|
||||
column_name: string;
|
||||
input_type: string;
|
||||
web_type: string;
|
||||
detail_settings: string;
|
||||
}>(
|
||||
`SELECT
|
||||
column_name,
|
||||
input_type,
|
||||
web_type,
|
||||
detail_settings
|
||||
FROM column_labels
|
||||
WHERE table_name = 'check_report_mng'
|
||||
ORDER BY column_name
|
||||
LIMIT 10`
|
||||
);
|
||||
|
||||
samples.forEach((sample) => {
|
||||
logger.info(
|
||||
` ${sample.column_name}: ${sample.input_type} → ${sample.web_type}`
|
||||
);
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error("❌ 마이그레이션 실패:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
migrateInputTypeToWebType();
|
||||
|
|
@ -75,6 +75,8 @@ const getCorsOrigin = (): string[] | boolean => {
|
|||
"http://localhost:9771", // 로컬 개발 환경
|
||||
"http://192.168.0.70:5555", // 내부 네트워크 접근
|
||||
"http://39.117.244.52:5555", // 외부 네트워크 접근
|
||||
"https://v1.vexplor.com", // 운영 프론트엔드
|
||||
"https://api.vexplor.com", // 운영 백엔드
|
||||
];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1018,14 +1018,14 @@ export class ScreenManagementService {
|
|||
[tableName]
|
||||
);
|
||||
|
||||
// column_labels 테이블에서 웹타입 정보 조회 (있는 경우)
|
||||
// column_labels 테이블에서 입력타입 정보 조회 (있는 경우)
|
||||
const webTypeInfo = await query<{
|
||||
column_name: string;
|
||||
web_type: string | null;
|
||||
input_type: string | null;
|
||||
column_label: string | null;
|
||||
detail_settings: any;
|
||||
}>(
|
||||
`SELECT column_name, web_type, column_label, detail_settings
|
||||
`SELECT column_name, input_type, column_label, detail_settings
|
||||
FROM column_labels
|
||||
WHERE table_name = $1`,
|
||||
[tableName]
|
||||
|
|
@ -1045,7 +1045,7 @@ export class ScreenManagementService {
|
|||
this.getColumnLabel(column.column_name),
|
||||
dataType: column.data_type,
|
||||
webType:
|
||||
(webTypeData?.web_type as WebType) ||
|
||||
(webTypeData?.input_type as WebType) ||
|
||||
this.inferWebType(column.data_type),
|
||||
isNullable: column.is_nullable,
|
||||
columnDefault: column.column_default || undefined,
|
||||
|
|
@ -1522,7 +1522,7 @@ export class ScreenManagementService {
|
|||
c.column_name,
|
||||
COALESCE(cl.column_label, c.column_name) as column_label,
|
||||
c.data_type,
|
||||
COALESCE(cl.web_type, 'text') as web_type,
|
||||
COALESCE(cl.input_type, 'text') as web_type,
|
||||
c.is_nullable,
|
||||
c.column_default,
|
||||
c.character_maximum_length,
|
||||
|
|
@ -1548,7 +1548,7 @@ export class ScreenManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 웹 타입 설정 (✅ Raw Query 전환 완료)
|
||||
* 입력 타입 설정 (✅ Raw Query 전환 완료)
|
||||
*/
|
||||
async setColumnWebType(
|
||||
tableName: string,
|
||||
|
|
@ -1556,16 +1556,16 @@ export class ScreenManagementService {
|
|||
webType: WebType,
|
||||
additionalSettings?: Partial<ColumnWebTypeSetting>
|
||||
): Promise<void> {
|
||||
// UPSERT를 INSERT ... ON CONFLICT로 변환
|
||||
// UPSERT를 INSERT ... ON CONFLICT로 변환 (input_type 사용)
|
||||
await query(
|
||||
`INSERT INTO column_labels (
|
||||
table_name, column_name, column_label, web_type, detail_settings,
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
code_category, reference_table, reference_column, display_column,
|
||||
is_visible, display_order, description, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
web_type = $4,
|
||||
input_type = $4,
|
||||
column_label = $3,
|
||||
detail_settings = $5,
|
||||
code_category = $6,
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@ export class TableManagementService {
|
|||
columnName: string
|
||||
): Promise<{ isCodeType: boolean; codeCategory?: string }> {
|
||||
try {
|
||||
// column_labels 테이블에서 해당 컬럼의 web_type이 'code'인지 확인
|
||||
// column_labels 테이블에서 해당 컬럼의 input_type이 'code'인지 확인
|
||||
const result = await query(
|
||||
`SELECT web_type, code_category
|
||||
`SELECT input_type, code_category
|
||||
FROM column_labels
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND web_type = 'code'`,
|
||||
AND input_type = 'code'`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
|
|
@ -167,7 +167,7 @@ export class TableManagementService {
|
|||
COALESCE(cl.column_label, c.column_name) as "displayName",
|
||||
c.data_type as "dataType",
|
||||
c.data_type as "dbType",
|
||||
COALESCE(cl.web_type, 'text') as "webType",
|
||||
COALESCE(cl.input_type, 'text') as "webType",
|
||||
COALESCE(cl.input_type, 'direct') as "inputType",
|
||||
COALESCE(cl.detail_settings, '') as "detailSettings",
|
||||
COALESCE(cl.description, '') as "description",
|
||||
|
|
@ -483,7 +483,7 @@ export class TableManagementService {
|
|||
table_name: string;
|
||||
column_name: string;
|
||||
column_label: string | null;
|
||||
web_type: string | null;
|
||||
input_type: string | null;
|
||||
detail_settings: any;
|
||||
description: string | null;
|
||||
display_order: number | null;
|
||||
|
|
@ -495,7 +495,7 @@ export class TableManagementService {
|
|||
created_date: Date | null;
|
||||
updated_date: Date | null;
|
||||
}>(
|
||||
`SELECT id, table_name, column_name, column_label, web_type, detail_settings,
|
||||
`SELECT id, table_name, column_name, column_label, input_type, detail_settings,
|
||||
description, display_order, is_visible, code_category, code_value,
|
||||
reference_table, reference_column, created_date, updated_date
|
||||
FROM column_labels
|
||||
|
|
@ -512,7 +512,7 @@ export class TableManagementService {
|
|||
tableName: columnLabel.table_name || "",
|
||||
columnName: columnLabel.column_name || "",
|
||||
columnLabel: columnLabel.column_label || undefined,
|
||||
webType: columnLabel.web_type || undefined,
|
||||
webType: columnLabel.input_type || undefined,
|
||||
detailSettings: columnLabel.detail_settings || undefined,
|
||||
description: columnLabel.description || undefined,
|
||||
displayOrder: columnLabel.display_order || undefined,
|
||||
|
|
@ -539,7 +539,7 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 컬럼 웹 타입 설정
|
||||
* 컬럼 입력 타입 설정 (web_type → input_type 통합)
|
||||
*/
|
||||
async updateColumnWebType(
|
||||
tableName: string,
|
||||
|
|
@ -550,7 +550,7 @@ export class TableManagementService {
|
|||
): Promise<void> {
|
||||
try {
|
||||
logger.info(
|
||||
`컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType}`
|
||||
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${webType}`
|
||||
);
|
||||
|
||||
// 웹 타입별 기본 상세 설정 생성
|
||||
|
|
@ -562,35 +562,28 @@ export class TableManagementService {
|
|||
...detailSettings,
|
||||
};
|
||||
|
||||
// column_labels UPSERT로 업데이트 또는 생성
|
||||
// column_labels UPSERT로 업데이트 또는 생성 (input_type만 사용)
|
||||
await query(
|
||||
`INSERT INTO column_labels (
|
||||
table_name, column_name, web_type, detail_settings, input_type, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
table_name, column_name, input_type, detail_settings, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
web_type = EXCLUDED.web_type,
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
input_type = COALESCE(EXCLUDED.input_type, column_labels.input_type),
|
||||
updated_date = NOW()`,
|
||||
[
|
||||
tableName,
|
||||
columnName,
|
||||
webType,
|
||||
JSON.stringify(finalDetailSettings),
|
||||
inputType || null,
|
||||
]
|
||||
[tableName, columnName, webType, JSON.stringify(finalDetailSettings)]
|
||||
);
|
||||
logger.info(
|
||||
`컬럼 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
|
||||
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`컬럼 웹 타입 설정 중 오류 발생: ${tableName}.${columnName}`,
|
||||
`컬럼 입력 타입 설정 중 오류 발생: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`컬럼 웹 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
`컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,304 @@
|
|||
# 입력 타입과 세부 타입 시스템 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
화면 관리 시스템에서 사용자가 **입력 타입**과 **세부 타입**을 2단계로 선택할 수 있는 시스템입니다.
|
||||
|
||||
### 구조
|
||||
|
||||
1. **입력 타입 (Input Type)**: 테이블 타입 관리에서 정의한 8개 핵심 타입
|
||||
2. **세부 타입 (Detail Type)**: 입력 타입의 구체적인 구현 방식 (웹타입)
|
||||
|
||||
```
|
||||
입력 타입 (PropertiesPanel에서 선택)
|
||||
↓
|
||||
세부 타입 (DetailSettingsPanel에서 선택)
|
||||
↓
|
||||
세부 설정 (DetailSettingsPanel에서 설정)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 8개 핵심 입력 타입과 세부 타입
|
||||
|
||||
### 1. **텍스트 (text)**
|
||||
|
||||
사용 가능한 세부 타입:
|
||||
|
||||
- `text` - 일반 텍스트 입력
|
||||
- `email` - 이메일 주소 입력
|
||||
- `tel` - 전화번호 입력
|
||||
- `url` - 웹사이트 주소 입력
|
||||
- `textarea` - 여러 줄 텍스트 입력
|
||||
- `password` - 비밀번호 입력 (마스킹)
|
||||
|
||||
### 2. **숫자 (number)**
|
||||
|
||||
사용 가능한 세부 타입:
|
||||
|
||||
- `number` - 정수 숫자 입력
|
||||
- `decimal` - 소수점 포함 숫자 입력
|
||||
|
||||
### 3. **날짜 (date)**
|
||||
|
||||
사용 가능한 세부 타입:
|
||||
|
||||
- `date` - 날짜 선택 (YYYY-MM-DD)
|
||||
- `datetime` - 날짜와 시간 선택
|
||||
- `time` - 시간 선택 (HH:mm)
|
||||
|
||||
### 4. **코드 (code)**
|
||||
|
||||
세부 타입:
|
||||
|
||||
- `code` - 공통 코드 선택 (세부 타입 고정)
|
||||
- 코드 카테고리는 상세 설정에서 선택
|
||||
|
||||
### 5. **엔티티 (entity)**
|
||||
|
||||
세부 타입:
|
||||
|
||||
- `entity` - 다른 테이블 참조 (세부 타입 고정)
|
||||
- 참조 테이블은 상세 설정에서 선택
|
||||
|
||||
### 6. **선택박스 (select)**
|
||||
|
||||
사용 가능한 세부 타입:
|
||||
|
||||
- `select` - 기본 드롭다운 선택
|
||||
- `dropdown` - 검색 기능이 있는 드롭다운
|
||||
|
||||
### 7. **체크박스 (checkbox)**
|
||||
|
||||
사용 가능한 세부 타입:
|
||||
|
||||
- `checkbox` - 단일 체크박스
|
||||
- `boolean` - On/Off 스위치
|
||||
|
||||
### 8. **라디오버튼 (radio)**
|
||||
|
||||
세부 타입:
|
||||
|
||||
- `radio` - 라디오 버튼 그룹 (세부 타입 고정)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 사용 방법
|
||||
|
||||
### 1. PropertiesPanel - 입력 타입 선택
|
||||
|
||||
위젯 컴포넌트를 선택하면 **속성 편집** 패널에서 입력 타입을 선택할 수 있습니다.
|
||||
|
||||
```typescript
|
||||
// 입력 타입 선택
|
||||
<select value={getBaseInputType(widget.widgetType)}>
|
||||
<option value="text">텍스트</option>
|
||||
<option value="number">숫자</option>
|
||||
<option value="date">날짜</option>
|
||||
// ...
|
||||
</select>
|
||||
```
|
||||
|
||||
**동작:**
|
||||
|
||||
- 입력 타입을 선택하면 해당 타입의 **기본 세부 타입**이 자동으로 설정됩니다
|
||||
- 예: `text` 입력 타입 선택 → `text` 세부 타입 자동 설정
|
||||
|
||||
### 2. DetailSettingsPanel - 세부 타입 선택
|
||||
|
||||
**상세 설정** 패널에서 선택한 입력 타입의 세부 타입을 선택할 수 있습니다.
|
||||
|
||||
```typescript
|
||||
// 세부 타입 선택
|
||||
<Select value={widget.widgetType}>
|
||||
<SelectItem value="text">일반 텍스트</SelectItem>
|
||||
<SelectItem value="email">이메일</SelectItem>
|
||||
<SelectItem value="tel">전화번호</SelectItem>
|
||||
// ...
|
||||
</Select>
|
||||
```
|
||||
|
||||
**동작:**
|
||||
|
||||
- 입력 타입에 해당하는 세부 타입만 표시됩니다
|
||||
- 세부 타입을 변경하면 `widgetType` 속성이 업데이트됩니다
|
||||
|
||||
### 3. DetailSettingsPanel - 세부 설정
|
||||
|
||||
세부 타입을 선택한 후, 해당 타입의 상세 설정을 할 수 있습니다.
|
||||
|
||||
예:
|
||||
|
||||
- **날짜 (date)**: 날짜 형식, 최소/최대 날짜 등
|
||||
- **숫자 (number)**: 최소/최대값, 소수점 자리수 등
|
||||
- **코드 (code)**: 코드 카테고리 선택
|
||||
- **엔티티 (entity)**: 참조 테이블, 표시 컬럼 선택
|
||||
|
||||
---
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
### 새로 추가된 파일
|
||||
|
||||
#### `frontend/types/input-type-mapping.ts`
|
||||
|
||||
입력 타입과 세부 타입 매핑 정의
|
||||
|
||||
```typescript
|
||||
// 8개 핵심 입력 타입
|
||||
export type BaseInputType = "text" | "number" | "date" | ...;
|
||||
|
||||
// 입력 타입별 세부 타입 매핑
|
||||
export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]>;
|
||||
|
||||
// 유틸리티 함수들
|
||||
export function getBaseInputType(webType: WebType): BaseInputType;
|
||||
export function getDetailTypes(baseInputType: BaseInputType): DetailTypeOption[];
|
||||
export function getDefaultDetailType(baseInputType: BaseInputType): WebType;
|
||||
```
|
||||
|
||||
### 수정된 파일
|
||||
|
||||
#### `frontend/components/screen/panels/PropertiesPanel.tsx`
|
||||
|
||||
- 입력 타입 선택 UI 추가
|
||||
- 웹타입 선택 → 입력 타입 선택으로 변경
|
||||
|
||||
#### `frontend/components/screen/panels/DetailSettingsPanel.tsx`
|
||||
|
||||
- 세부 타입 선택 UI 추가
|
||||
- 입력 타입 표시
|
||||
- 세부 타입 목록 동적 생성
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI 레이아웃
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 속성 편집 (PropertiesPanel) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 입력 타입: [텍스트 ▼] ← 8개 중 선택 │
|
||||
│ 세부 타입은 "상세 설정"에서 선택하세요 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 상세 설정 (DetailSettingsPanel) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 입력 타입: [text] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 세부 타입 선택: │
|
||||
│ [일반 텍스트 ▼] ← 입력 타입에 따라 동적으로 변경 │
|
||||
│ - 일반 텍스트 │
|
||||
│ - 이메일 │
|
||||
│ - 전화번호 │
|
||||
│ - URL │
|
||||
│ - 여러 줄 텍스트 │
|
||||
│ - 비밀번호 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [세부 설정 영역] │
|
||||
│ (선택한 세부 타입에 맞는 설정 패널) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 데이터 흐름
|
||||
|
||||
### 1. 새 컴포넌트 생성 시
|
||||
|
||||
```
|
||||
테이블 컬럼 드래그
|
||||
→ 컬럼의 dataType 분석
|
||||
→ 입력 타입 자동 선택 (text, number, date 등)
|
||||
→ 기본 세부 타입 자동 설정 (text, number, date 등)
|
||||
```
|
||||
|
||||
### 2. 입력 타입 변경 시
|
||||
|
||||
```
|
||||
PropertiesPanel에서 입력 타입 선택
|
||||
→ 해당 입력 타입의 기본 세부 타입 설정
|
||||
→ DetailSettingsPanel 세부 타입 목록 업데이트
|
||||
```
|
||||
|
||||
### 3. 세부 타입 변경 시
|
||||
|
||||
```
|
||||
DetailSettingsPanel에서 세부 타입 선택
|
||||
→ widgetType 업데이트
|
||||
→ 해당 세부 타입의 설정 패널 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 확장 가능성
|
||||
|
||||
### 세부 타입 추가
|
||||
|
||||
새로운 세부 타입을 추가하려면:
|
||||
|
||||
1. `frontend/types/input-type-mapping.ts`의 `INPUT_TYPE_DETAIL_TYPES`에 추가
|
||||
2. 해당 세부 타입의 설정 패널 구현
|
||||
3. DB의 `web_types` 테이블에 레코드 추가
|
||||
|
||||
### 입력 타입 추가
|
||||
|
||||
새로운 입력 타입을 추가하려면:
|
||||
|
||||
1. `BaseInputType` 타입에 추가
|
||||
2. `BASE_INPUT_TYPE_OPTIONS`에 옵션 추가
|
||||
3. `INPUT_TYPE_DETAIL_TYPES`에 세부 타입 목록 정의
|
||||
4. 테이블 타입 관리 시스템 업데이트
|
||||
|
||||
---
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
- [x] 8개 핵심 입력 타입 정의
|
||||
- [x] 입력 타입별 세부 타입 매핑
|
||||
- [x] PropertiesPanel에 입력 타입 선택 UI 추가
|
||||
- [x] DetailSettingsPanel에 세부 타입 선택 UI 추가
|
||||
- [x] 입력 타입 변경 시 기본 세부 타입 자동 설정
|
||||
- [x] 세부 타입 변경 시 widgetType 업데이트
|
||||
- [x] 타입 안전성 보장 (TypeScript)
|
||||
|
||||
---
|
||||
|
||||
## 📝 사용 예시
|
||||
|
||||
### 텍스트 입력 필드 생성
|
||||
|
||||
1. **PropertiesPanel**에서 입력 타입을 "텍스트"로 선택
|
||||
2. **DetailSettingsPanel**로 이동
|
||||
3. 세부 타입에서 "이메일" 선택
|
||||
4. 이메일 형식 검증 등 세부 설정 입력
|
||||
|
||||
### 날짜 입력 필드 생성
|
||||
|
||||
1. **PropertiesPanel**에서 입력 타입을 "날짜"로 선택
|
||||
2. **DetailSettingsPanel**로 이동
|
||||
3. 세부 타입에서 "날짜+시간" 선택
|
||||
4. 날짜 형식, 최소/최대 날짜 등 설정
|
||||
|
||||
---
|
||||
|
||||
## 🐛 문제 해결
|
||||
|
||||
### 세부 타입이 표시되지 않음
|
||||
|
||||
- 입력 타입이 올바르게 설정되었는지 확인
|
||||
- `getDetailTypes()` 함수가 올바른 값을 반환하는지 확인
|
||||
|
||||
### 입력 타입 변경 시 세부 타입이 초기화되지 않음
|
||||
|
||||
- `getDefaultDetailType()` 함수 확인
|
||||
- `onUpdateProperty("widgetType", ...)` 호출 확인
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 자료
|
||||
|
||||
- [테이블 타입 관리 개선 계획서](../테이블_타입_관리_개선_계획서.md)
|
||||
- [테이블 타입 관리 개선 사용 가이드](../테이블_타입_관리_개선_사용_가이드.md)
|
||||
- [화면관리 시스템 개요](./screen-management-system.md)
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
# 그리드 컬럼 수 옵션 통합
|
||||
|
||||
## 개요
|
||||
|
||||
"그리드 컬럼 수" 옵션과 "컴포넌트 너비" 옵션이 중복된 기능을 제공하여 혼란을 야기했습니다.
|
||||
사용자 편의성을 위해 **"컴포넌트 너비" 옵션만 사용**하도록 통합하고, 내부적으로 `gridColumns` 값을 자동 계산하도록 변경했습니다.
|
||||
|
||||
## 문제점
|
||||
|
||||
### 기존 상황
|
||||
|
||||
1. **그리드 컬럼 수 옵션**: 1-12 숫자 입력
|
||||
2. **컴포넌트 너비 옵션**: 1/12 ~ 12/12 선택 (퍼센트로 변환)
|
||||
|
||||
→ 같은 기능을 두 가지 방식으로 제공하여 사용자 혼란 발생
|
||||
|
||||
### 예시
|
||||
|
||||
- 사용자가 "그리드 컬럼 수"를 6으로 설정
|
||||
- 하지만 "컴포넌트 너비"가 1/4 (3컬럼)로 설정되어 있음
|
||||
- 두 설정이 충돌하여 예상과 다른 결과 발생
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 1. UI 단순화
|
||||
|
||||
**제거된 옵션**:
|
||||
|
||||
- ❌ PropertiesPanel의 "그리드 컬럼 수 (1-12)" 입력 필드
|
||||
- ❌ DataTableConfigPanel의 "그리드 컬럼 수" 선택 상자
|
||||
|
||||
**유지된 옵션**:
|
||||
|
||||
- ✅ PropertiesPanel의 "컴포넌트 너비" 선택 상자 (1/12 ~ 12/12)
|
||||
|
||||
### 2. 자동 계산 로직
|
||||
|
||||
컴포넌트 너비 선택 시 `gridColumns` 자동 계산:
|
||||
|
||||
```typescript
|
||||
// PropertiesPanel.tsx (764-788줄)
|
||||
const columnsMap: Record<string, number> = {
|
||||
twelfth: 1, // 1/12
|
||||
small: 2, // 2/12
|
||||
quarter: 3, // 3/12 (1/4)
|
||||
third: 4, // 4/12 (1/3)
|
||||
"five-twelfths": 5, // 5/12
|
||||
half: 6, // 6/12 (절반)
|
||||
"seven-twelfths": 7, // 7/12
|
||||
twoThirds: 8, // 8/12 (2/3)
|
||||
threeQuarters: 9, // 9/12 (3/4)
|
||||
"five-sixths": 10, // 10/12
|
||||
"eleven-twelfths": 11, // 11/12
|
||||
full: 12, // 12/12 (전체)
|
||||
};
|
||||
|
||||
// 컴포넌트 너비 변경 시
|
||||
onUpdateProperty("style.width", newWidth); // 퍼센트 값 저장
|
||||
const gridColumns = columnsMap[value] || 6;
|
||||
onUpdateProperty("gridColumns", gridColumns); // 컬럼 수 자동 계산
|
||||
```
|
||||
|
||||
### 3. 컴포넌트 생성 시 동작
|
||||
|
||||
```typescript
|
||||
// ScreenDesigner.tsx (1756-1772줄)
|
||||
// 일반 컴포넌트: defaultSize.width를 기준으로 그리드 컬럼 수 계산
|
||||
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||||
const columnWidth = gridInfo.columnWidth + gridInfo.gap;
|
||||
const estimatedColumns = Math.round(
|
||||
component.defaultSize.width / columnWidth
|
||||
);
|
||||
gridColumns = Math.max(1, Math.min(12, estimatedColumns)); // 1-12 범위
|
||||
}
|
||||
```
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 파일 수정
|
||||
|
||||
#### 1. PropertiesPanel.tsx
|
||||
|
||||
- ❌ 삭제: "그리드 컬럼 수" 입력 필드 (916-940줄)
|
||||
- ❌ 삭제: `localInputs.gridColumns` 상태 (206-213줄)
|
||||
- ✅ 추가: 컴포넌트 너비 변경 시 `gridColumns` 자동 계산 (764-788줄)
|
||||
|
||||
#### 2. DataTableConfigPanel.tsx
|
||||
|
||||
- ❌ 삭제: "그리드 컬럼 수" 선택 상자 (1437-1456줄)
|
||||
- ❌ 삭제: `localValues.gridColumns` 초기화 (72줄, 182줄)
|
||||
|
||||
#### 3. ScreenDesigner.tsx
|
||||
|
||||
- ✅ 개선: 컴포넌트 드롭 시 `defaultSize.width` 기반으로 `gridColumns` 자동 계산 (1756-1772줄)
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 컴포넌트 너비 조정
|
||||
|
||||
#### 방법 1: 드롭다운 선택
|
||||
|
||||
1. 컴포넌트 선택
|
||||
2. 속성 패널 > "컴포넌트 너비" 드롭다운
|
||||
3. 원하는 너비 선택 (예: "절반 (6/12)")
|
||||
4. 자동으로 `style.width`와 `gridColumns` 모두 업데이트됨
|
||||
|
||||
#### 방법 2: 컴포넌트 생성 시
|
||||
|
||||
1. 컴포넌트 팔레트에서 드래그
|
||||
2. 캔버스에 드롭
|
||||
3. `defaultSize.width`를 기준으로 적절한 `gridColumns` 자동 설정
|
||||
|
||||
### 너비 옵션 설명
|
||||
|
||||
| 옵션 | 컬럼 수 | 퍼센트 | 설명 |
|
||||
| ------------ | ------- | ------ | ----------- |
|
||||
| 1/12 | 1 | 8.33% | 최소 |
|
||||
| 작게 (2/12) | 2 | 16.67% | 매우 작음 |
|
||||
| 1/4 (3/12) | 3 | 25% | 4등분의 1 |
|
||||
| 1/3 (4/12) | 4 | 33.33% | 3등분의 1 |
|
||||
| 5/12 | 5 | 41.67% | |
|
||||
| 절반 (6/12) | 6 | 50% | 정확히 절반 |
|
||||
| 7/12 | 7 | 58.33% | |
|
||||
| 2/3 (8/12) | 8 | 66.67% | 3등분의 2 |
|
||||
| 3/4 (9/12) | 9 | 75% | 4등분의 3 |
|
||||
| 10/12 | 10 | 83.33% | |
|
||||
| 11/12 | 11 | 91.67% | |
|
||||
| 전체 (12/12) | 12 | 100% | 최대 |
|
||||
|
||||
## 적용 효과
|
||||
|
||||
### 1. 사용자 경험 개선
|
||||
|
||||
- ✅ 단일 옵션으로 간소화
|
||||
- ✅ 직관적인 분수 표현 (1/4, 절반, 2/3 등)
|
||||
- ✅ 설정 충돌 제거
|
||||
|
||||
### 2. 일관성 보장
|
||||
|
||||
- ✅ 컴포넌트 너비와 gridColumns 항상 동기화
|
||||
- ✅ 그리드 시스템과 자연스러운 통합
|
||||
|
||||
### 3. 개발자 편의
|
||||
|
||||
- ✅ 내부적으로 gridColumns는 여전히 사용 가능
|
||||
- ✅ 기존 데이터 호환성 유지 (gridColumns 필드 존재)
|
||||
|
||||
## 내부 동작
|
||||
|
||||
### gridColumns 사용처
|
||||
|
||||
`gridColumns` 값은 사용자에게 직접 노출되지 않지만, 내부적으로 여전히 중요한 역할을 합니다:
|
||||
|
||||
1. **그리드 레이아웃 계산**: 컴포넌트가 차지할 그리드 셀 수 결정
|
||||
2. **자동 배치**: 컴포넌트 자동 정렬 시 참조
|
||||
3. **반응형 조정**: 화면 크기 변경 시 비율 유지
|
||||
|
||||
### 값 동기화 흐름
|
||||
|
||||
```
|
||||
사용자 선택: "절반 (6/12)"
|
||||
↓
|
||||
1. style.width = "50%" 저장
|
||||
↓
|
||||
2. gridColumns = 6 자동 계산
|
||||
↓
|
||||
3. 그리드 시스템에서 6컬럼 너비로 렌더링
|
||||
↓
|
||||
4. 실제 픽셀 너비 계산 및 적용
|
||||
```
|
||||
|
||||
## 마이그레이션 가이드
|
||||
|
||||
### 기존 화면 데이터
|
||||
|
||||
- **영향 없음**: 기존에 저장된 `gridColumns` 값은 그대로 유지
|
||||
- **자동 변환**: 컴포넌트 편집 시 `style.width`로부터 재계산
|
||||
|
||||
### 사용자 교육
|
||||
|
||||
1. "그리드 컬럼 수" 설정이 제거되었음을 안내
|
||||
2. "컴포넌트 너비"로 동일한 기능 사용 가능
|
||||
3. 더 직관적인 분수 표현 (1/4, 1/2 등) 강조
|
||||
|
||||
## 테스트 체크리스트
|
||||
|
||||
### UI 확인
|
||||
|
||||
- [ ] PropertiesPanel에 "그리드 컬럼 수" 입력 필드가 없는지 확인
|
||||
- [ ] DataTableConfigPanel에 "그리드 컬럼 수" 선택 상자가 없는지 확인
|
||||
- [ ] "컴포넌트 너비" 드롭다운이 정상 작동하는지 확인
|
||||
|
||||
### 기능 확인
|
||||
|
||||
- [ ] 컴포넌트 너비 변경 시 시각적으로 제대로 반영되는지 확인
|
||||
- [ ] 새 컴포넌트 생성 시 적절한 초기 너비로 생성되는지 확인
|
||||
- [ ] 그리드 ON/OFF 시 너비가 올바르게 적용되는지 확인
|
||||
|
||||
### 데이터 확인
|
||||
|
||||
- [ ] 컴포넌트 너비 변경 후 저장/불러오기 테스트
|
||||
- [ ] 기존 화면 데이터가 정상적으로 로드되는지 확인
|
||||
- [ ] `gridColumns` 값이 자동으로 계산되는지 확인
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 수정된 파일
|
||||
|
||||
- `/frontend/components/screen/panels/PropertiesPanel.tsx`
|
||||
- `/frontend/components/screen/panels/DataTableConfigPanel.tsx`
|
||||
- `/frontend/components/screen/ScreenDesigner.tsx`
|
||||
|
||||
### 관련 문서
|
||||
|
||||
- [컴포넌트*기본*너비*설정*가이드.md](./컴포넌트_기본_너비_설정_가이드.md)
|
||||
|
||||
## 버전 히스토리
|
||||
|
||||
### v1.0.0 (2025-10-14)
|
||||
|
||||
- "그리드 컬럼 수" 옵션 제거
|
||||
- "컴포넌트 너비" 옵션으로 통합
|
||||
- `gridColumns` 자동 계산 로직 추가
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
# 컴포넌트 기본 너비 설정 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
화면 관리에서 각 컴포넌트 타입별로 적절한 기본 너비를 설정하고, 컴포넌트가 지정된 너비를 벗어나지 않도록 스타일을 적용했습니다.
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 1. 인풋 컴포넌트 기본 너비 조정
|
||||
|
||||
각 인풋 타입별로 적절한 기본 크기를 설정했습니다:
|
||||
|
||||
#### 텍스트 입력 계열
|
||||
|
||||
- **텍스트 입력** (`text-input`): 300px × 40px
|
||||
- **숫자 입력** (`number-input`): 200px × 40px
|
||||
- **텍스트 영역** (`textarea-basic`): 400px × 100px
|
||||
|
||||
#### 선택 입력 계열
|
||||
|
||||
- **선택상자** (`select-basic`): 250px × 40px
|
||||
- **날짜 선택** (`date-input`): 220px × 40px
|
||||
- **체크박스** (`checkbox-basic`): 150px × 32px
|
||||
- **라디오 버튼** (`radio-basic`): 150px × 32px
|
||||
- **슬라이더** (`slider-basic`): 250px × 40px
|
||||
- **토글 스위치** (`toggle-switch`): 180px × 40px
|
||||
|
||||
#### 파일 및 기타
|
||||
|
||||
- **파일 업로드** (`file-upload`): 350px × 40px
|
||||
|
||||
#### 표시 컴포넌트
|
||||
|
||||
- **기본 버튼** (`button-primary`): 120px × 40px
|
||||
- **텍스트 표시** (`text-display`): 150px × 24px
|
||||
- **이미지 표시** (`image-display`): 200px × 200px
|
||||
- **구분선** (`divider-line`): 400px × 2px
|
||||
- **아코디언** (`accordion-basic`): 400px × 200px
|
||||
|
||||
#### 데이터 컴포넌트
|
||||
|
||||
- **테이블 리스트** (`table-list`): 120px × 600px
|
||||
- **카드 표시** (`card-display`): 기존 유지
|
||||
|
||||
### 2. 공통 스타일 적용
|
||||
|
||||
`/frontend/lib/registry/components/common/inputStyles.ts` 파일의 모든 스타일 클래스에 다음을 추가:
|
||||
|
||||
- `max-w-full`: 최대 너비를 부모 컨테이너로 제한
|
||||
- `overflow-hidden`: 내용이 넘칠 경우 숨김 처리
|
||||
|
||||
적용된 클래스:
|
||||
|
||||
- `INPUT_CLASSES.base`
|
||||
- `INPUT_CLASSES.container`
|
||||
- `INPUT_CLASSES.textarea`
|
||||
- `INPUT_CLASSES.select`
|
||||
- `INPUT_CLASSES.flexContainer`
|
||||
|
||||
### 3. 개별 컴포넌트 스타일 적용
|
||||
|
||||
#### TextInputComponent
|
||||
|
||||
- 컨테이너 div: `max-w-full overflow-hidden` 추가
|
||||
- input 요소: `max-w-full` 추가
|
||||
- textarea 요소: `max-w-full` 추가
|
||||
|
||||
#### RealtimePreviewDynamic
|
||||
|
||||
- 컴포넌트 렌더링 컨테이너: `max-w-full overflow-hidden` 추가
|
||||
|
||||
## 적용 효과
|
||||
|
||||
### 1. 일관된 초기 크기
|
||||
|
||||
- 컴포넌트 드래그 앤 드롭 시 각 타입별로 적절한 기본 크기로 생성됨
|
||||
- 사용자가 별도로 크기를 조정할 필요 없이 바로 사용 가능
|
||||
|
||||
### 2. 그리드 시스템과의 통합
|
||||
|
||||
- **그리드 활성화 시**: `defaultSize.width`를 기준으로 적절한 그리드 컬럼 수 자동 계산
|
||||
- 예: 300px 너비 → 약 3-4 컬럼 (그리드 설정에 따라 다름)
|
||||
- 계산된 컬럼 수에 맞춰 정확한 너비로 재조정
|
||||
- **그리드 비활성화 시**: `defaultSize`의 픽셀 값을 그대로 사용
|
||||
- 일관된 사용자 경험 제공
|
||||
|
||||
### 3. 너비 제한
|
||||
|
||||
- 컴포넌트가 설정된 너비를 벗어나지 않음
|
||||
- 부모 컨테이너 크기에 맞춰 자동으로 조정됨
|
||||
- 레이아웃 깨짐 방지
|
||||
|
||||
### 4. 반응형 대응
|
||||
|
||||
- `max-w-full` 속성으로 부모 컨테이너에 맞춰 자동 축소
|
||||
- `overflow-hidden`으로 내용 넘침 방지
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 새 컴포넌트 생성 시
|
||||
|
||||
1. 컴포넌트 팔레트에서 원하는 타입 선택
|
||||
2. 캔버스에 드래그 앤 드롭
|
||||
3. 자동으로 적절한 기본 크기로 생성됨
|
||||
|
||||
### 크기 조정
|
||||
|
||||
1. 컴포넌트 선택
|
||||
2. 속성 패널에서 "컴포넌트 너비" 선택
|
||||
3. 드롭다운에서 원하는 너비 선택 (1/12 ~ 12/12)
|
||||
4. 또는 직접 픽셀 값 입력
|
||||
|
||||
## 주의 사항
|
||||
|
||||
### 기존 화면에 미치는 영향
|
||||
|
||||
- 이미 생성된 컴포넌트는 영향 받지 않음
|
||||
- 새로 생성되는 컴포넌트만 새로운 기본값 적용
|
||||
|
||||
### 스타일 우선순위
|
||||
|
||||
1. 인라인 style 속성
|
||||
2. componentConfig에서 설정한 크기
|
||||
3. defaultSize (새로 적용된 기본값)
|
||||
|
||||
### 커스터마이징
|
||||
|
||||
- 각 컴포넌트의 `index.ts` 파일에서 `defaultSize` 수정 가능
|
||||
- 프로젝트 요구사항에 맞춰 조정 가능
|
||||
|
||||
## 테스트 방법
|
||||
|
||||
### 기본 크기 테스트
|
||||
|
||||
```
|
||||
1. 화면 디자이너 열기
|
||||
2. 각 인풋 타입 컴포넌트를 캔버스에 드롭
|
||||
3. 기본 크기가 적절한지 확인
|
||||
4. 여러 컴포넌트를 나란히 배치하여 일관성 확인
|
||||
```
|
||||
|
||||
### 너비 제한 테스트
|
||||
|
||||
```
|
||||
1. 컴포넌트 생성 후 선택
|
||||
2. 속성 패널에서 너비를 작은 값으로 설정 (예: 100px)
|
||||
3. 컴포넌트 내부의 input이 너비를 벗어나지 않는지 확인
|
||||
4. 긴 텍스트 입력 시 overflow 처리 확인
|
||||
```
|
||||
|
||||
### 반응형 테스트
|
||||
|
||||
```
|
||||
1. 레이아웃 컨테이너 내부에 컴포넌트 배치
|
||||
2. 레이아웃 크기를 조정하여 컴포넌트가 적절히 축소되는지 확인
|
||||
3. 다양한 화면 해상도에서 테스트
|
||||
```
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 컴포넌트 정의 파일
|
||||
|
||||
- `/frontend/lib/registry/components/text-input/index.ts`
|
||||
- `/frontend/lib/registry/components/number-input/index.ts`
|
||||
- `/frontend/lib/registry/components/select-basic/index.ts`
|
||||
- `/frontend/lib/registry/components/date-input/index.ts`
|
||||
- `/frontend/lib/registry/components/textarea-basic/index.ts`
|
||||
- `/frontend/lib/registry/components/checkbox-basic/index.ts`
|
||||
- `/frontend/lib/registry/components/radio-basic/index.ts`
|
||||
- `/frontend/lib/registry/components/file-upload/index.ts`
|
||||
- `/frontend/lib/registry/components/slider-basic/index.ts`
|
||||
- `/frontend/lib/registry/components/toggle-switch/index.ts`
|
||||
- `/frontend/lib/registry/components/button-primary/index.ts`
|
||||
- `/frontend/lib/registry/components/text-display/index.ts`
|
||||
- `/frontend/lib/registry/components/image-display/index.ts`
|
||||
- `/frontend/lib/registry/components/divider-line/index.ts`
|
||||
- `/frontend/lib/registry/components/accordion-basic/index.ts`
|
||||
- `/frontend/lib/registry/components/table-list/index.ts`
|
||||
|
||||
### 공통 스타일 파일
|
||||
|
||||
- `/frontend/lib/registry/components/common/inputStyles.ts`
|
||||
|
||||
### 렌더링 관련 파일
|
||||
|
||||
- `/frontend/components/screen/RealtimePreviewDynamic.tsx`
|
||||
- `/frontend/lib/registry/components/text-input/TextInputComponent.tsx`
|
||||
|
||||
### 화면 디자이너
|
||||
|
||||
- `/frontend/components/screen/ScreenDesigner.tsx`
|
||||
- `handleComponentDrop` 함수 (1751-1800줄): 컴포넌트 드롭 시 그리드 컬럼 수 자동 계산
|
||||
- 그리드 활성화 시: `defaultSize.width` 기반으로 gridColumns 계산 후 너비 재조정
|
||||
- 그리드 비활성화 시: `defaultSize` 그대로 사용
|
||||
|
||||
## 향후 개선 사항
|
||||
|
||||
### 1. 반응형 기본값
|
||||
|
||||
- 화면 크기에 따라 다른 기본값 적용
|
||||
- 모바일, 태블릿, 데스크톱 각각 최적화
|
||||
|
||||
### 2. 사용자 정의 기본값
|
||||
|
||||
- 사용자가 자주 사용하는 크기를 기본값으로 저장
|
||||
- 프로젝트별 기본값 설정 기능
|
||||
|
||||
### 3. 스마트 크기 조정
|
||||
|
||||
- 주변 컴포넌트에 맞춰 자동으로 크기 조정
|
||||
- 레이블 길이에 따른 동적 너비 계산
|
||||
|
||||
### 4. 프리셋 제공
|
||||
|
||||
- 폼 레이아웃 프리셋 (라벨-입력 쌍)
|
||||
- 검색 바 프리셋
|
||||
- 로그인 폼 프리셋
|
||||
|
||||
## 버전 히스토리
|
||||
|
||||
### v1.0.0 (2025-10-14)
|
||||
|
||||
- 초기 기본 너비 설정 적용
|
||||
- 공통 스타일에 max-w-full, overflow-hidden 추가
|
||||
- 모든 인풋 컴포넌트 기본 크기 조정
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
# 테이블 패널 컴포넌트 기본 너비 설정
|
||||
|
||||
## 개요
|
||||
|
||||
테이블 패널에서 컬럼과 필터를 드래그 드롭으로 추가할 때, 각 웹타입별로 적절한 기본 너비(gridColumns)가 자동으로 설정되도록 개선했습니다.
|
||||
|
||||
## 문제점
|
||||
|
||||
### 기존 방식
|
||||
|
||||
- **모든 컬럼**: `gridColumns: 2` (2/12, 16.67%) 고정
|
||||
- **모든 필터**: `gridColumns: 3` (3/12, 25%) 고정
|
||||
- 웹타입에 관계없이 동일한 너비 적용
|
||||
- 긴 텍스트 입력이나 짧은 숫자 입력 모두 같은 크기
|
||||
|
||||
### 문제 사례
|
||||
|
||||
```
|
||||
❌ text (긴 텍스트) → 2컬럼 (너무 좁음)
|
||||
❌ textarea (여러 줄) → 2컬럼 (너무 좁음)
|
||||
❌ checkbox (체크박스) → 2컬럼 (너무 넓음)
|
||||
```
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 웹타입별 기본 너비 함수 추가
|
||||
|
||||
```typescript
|
||||
// DataTableConfigPanel.tsx (891-929줄)
|
||||
const getDefaultGridColumns = (webType: WebType): number => {
|
||||
const widthMap: Record<WebType, number> = {
|
||||
// 텍스트 입력 계열 (넓게)
|
||||
text: 4, // 1/3 (33%)
|
||||
email: 4, // 1/3 (33%)
|
||||
tel: 3, // 1/4 (25%)
|
||||
url: 4, // 1/3 (33%)
|
||||
textarea: 6, // 절반 (50%)
|
||||
|
||||
// 숫자/날짜 입력 (중간)
|
||||
number: 2, // 2/12 (16.67%)
|
||||
decimal: 2, // 2/12 (16.67%)
|
||||
date: 3, // 1/4 (25%)
|
||||
datetime: 3, // 1/4 (25%)
|
||||
time: 2, // 2/12 (16.67%)
|
||||
|
||||
// 선택 입력 (중간)
|
||||
select: 3, // 1/4 (25%)
|
||||
radio: 3, // 1/4 (25%)
|
||||
checkbox: 2, // 2/12 (16.67%)
|
||||
boolean: 2, // 2/12 (16.67%)
|
||||
|
||||
// 코드/참조 (넓게)
|
||||
code: 3, // 1/4 (25%)
|
||||
entity: 4, // 1/3 (33%)
|
||||
|
||||
// 파일/이미지 (넓게)
|
||||
file: 4, // 1/3 (33%)
|
||||
image: 3, // 1/4 (25%)
|
||||
|
||||
// 기타
|
||||
button: 2, // 2/12 (16.67%)
|
||||
label: 2, // 2/12 (16.67%)
|
||||
};
|
||||
|
||||
return widthMap[webType] || 3; // 기본값 3 (1/4, 25%)
|
||||
};
|
||||
```
|
||||
|
||||
## 적용된 함수
|
||||
|
||||
### 1. addColumn (컬럼 추가)
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const newColumn: DataTableColumn = {
|
||||
// ...
|
||||
gridColumns: 2, // ❌ 모든 타입에 2 고정
|
||||
// ...
|
||||
};
|
||||
|
||||
// After
|
||||
const newColumn: DataTableColumn = {
|
||||
// ...
|
||||
gridColumns: getDefaultGridColumns(widgetType), // ✅ 웹타입별 자동 계산
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 2. addFilter (필터 추가)
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const newFilter: DataTableFilter = {
|
||||
// ...
|
||||
gridColumns: 3, // ❌ 모든 타입에 3 고정
|
||||
// ...
|
||||
};
|
||||
|
||||
// After
|
||||
const newFilter: DataTableFilter = {
|
||||
// ...
|
||||
gridColumns: getDefaultGridColumns(widgetType), // ✅ 웹타입별 자동 계산
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 3. addVirtualFileColumn (가상 파일 컬럼 추가)
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const newColumn: DataTableColumn = {
|
||||
// ...
|
||||
widgetType: "file",
|
||||
gridColumns: 2, // ❌ 파일 타입에 2 고정
|
||||
// ...
|
||||
};
|
||||
|
||||
// After
|
||||
const newColumn: DataTableColumn = {
|
||||
// ...
|
||||
widgetType: "file",
|
||||
gridColumns: getDefaultGridColumns("file"), // ✅ 파일 타입 기본값 (4컬럼, 33%)
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
## 웹타입별 기본 너비 상세
|
||||
|
||||
### 텍스트 입력 계열 (넓게 설정)
|
||||
|
||||
| 웹타입 | 컬럼 수 | 퍼센트 | 설명 |
|
||||
| -------- | ------- | ------ | -------------------------- |
|
||||
| text | 4 | 33% | 일반 텍스트 입력 |
|
||||
| email | 4 | 33% | 이메일 주소 (길이 필요) |
|
||||
| tel | 3 | 25% | 전화번호 (중간 길이) |
|
||||
| url | 4 | 33% | URL 주소 (길이 필요) |
|
||||
| textarea | 6 | 50% | 여러 줄 텍스트 (가장 넓게) |
|
||||
|
||||
### 숫자/날짜 입력 (중간 설정)
|
||||
|
||||
| 웹타입 | 컬럼 수 | 퍼센트 | 설명 |
|
||||
| -------- | ------- | ------ | -------------- |
|
||||
| number | 2 | 16.67% | 정수 입력 |
|
||||
| decimal | 2 | 16.67% | 소수 입력 |
|
||||
| date | 3 | 25% | 날짜 선택 |
|
||||
| datetime | 3 | 25% | 날짜+시간 선택 |
|
||||
| time | 2 | 16.67% | 시간 선택 |
|
||||
|
||||
### 선택 입력 (중간 설정)
|
||||
|
||||
| 웹타입 | 컬럼 수 | 퍼센트 | 설명 |
|
||||
| -------- | ------- | ------ | --------------- |
|
||||
| select | 3 | 25% | 드롭다운 선택 |
|
||||
| radio | 3 | 25% | 라디오 버튼 |
|
||||
| checkbox | 2 | 16.67% | 체크박스 (작게) |
|
||||
| boolean | 2 | 16.67% | 참/거짓 (작게) |
|
||||
|
||||
### 코드/참조 (넓게 설정)
|
||||
|
||||
| 웹타입 | 컬럼 수 | 퍼센트 | 설명 |
|
||||
| ------ | ------- | ------ | ----------------------- |
|
||||
| code | 3 | 25% | 코드 선택 |
|
||||
| entity | 4 | 33% | 엔티티 참조 (길이 필요) |
|
||||
|
||||
### 파일/이미지 (넓게 설정)
|
||||
|
||||
| 웹타입 | 컬럼 수 | 퍼센트 | 설명 |
|
||||
| ------ | ------- | ------ | ------------- |
|
||||
| file | 4 | 33% | 파일 업로드 |
|
||||
| image | 3 | 25% | 이미지 업로드 |
|
||||
|
||||
### 기타
|
||||
|
||||
| 웹타입 | 컬럼 수 | 퍼센트 | 설명 |
|
||||
| ---------- | ------- | ------ | ------------------ |
|
||||
| button | 2 | 16.67% | 버튼 |
|
||||
| label | 2 | 16.67% | 라벨 |
|
||||
| **기본값** | 3 | 25% | 정의되지 않은 타입 |
|
||||
|
||||
## 적용 효과
|
||||
|
||||
### Before (기존)
|
||||
|
||||
```
|
||||
[컬럼 추가]
|
||||
- 이름 (text) → 2컬럼 → 너무 좁음 😞
|
||||
- 설명 (textarea) → 2컬럼 → 너무 좁음 😞
|
||||
- 나이 (number) → 2컬럼 → 적절함 😐
|
||||
- 활성화 (checkbox) → 2컬럼 → 너무 넓음 😞
|
||||
|
||||
[필터 추가]
|
||||
- 검색어 (text) → 3컬럼 → 약간 좁음 😐
|
||||
- 날짜 (date) → 3컬럼 → 적절함 😐
|
||||
- 승인 (boolean) → 3컬럼 → 너무 넓음 😞
|
||||
```
|
||||
|
||||
### After (개선)
|
||||
|
||||
```
|
||||
[컬럼 추가]
|
||||
- 이름 (text) → 4컬럼 (33%) → 적절함 ✅
|
||||
- 설명 (textarea) → 6컬럼 (50%) → 충분함 ✅
|
||||
- 나이 (number) → 2컬럼 (16.67%) → 적절함 ✅
|
||||
- 활성화 (checkbox) → 2컬럼 (16.67%) → 적절함 ✅
|
||||
|
||||
[필터 추가]
|
||||
- 검색어 (text) → 4컬럼 (33%) → 충분함 ✅
|
||||
- 날짜 (date) → 3컬럼 (25%) → 적절함 ✅
|
||||
- 승인 (boolean) → 2컬럼 (16.67%) → 적절함 ✅
|
||||
```
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 1. 컬럼 추가
|
||||
|
||||
1. 테이블 선택
|
||||
2. "컬럼 추가" 버튼 클릭 또는 드롭다운에서 컬럼 선택
|
||||
3. 웹타입에 맞는 기본 너비로 자동 생성됨
|
||||
4. 필요시 속성 패널에서 너비 조정 가능
|
||||
|
||||
### 2. 필터 추가
|
||||
|
||||
1. 테이블 선택
|
||||
2. "필터 추가" 버튼 클릭
|
||||
3. 웹타입에 맞는 기본 너비로 자동 생성됨
|
||||
4. 필요시 컬럼별 너비 조정 가능
|
||||
|
||||
### 3. 가상 파일 컬럼 추가
|
||||
|
||||
1. "파일 컬럼" 버튼 클릭
|
||||
2. 파일 타입에 맞는 기본 너비(4컬럼, 33%)로 생성됨
|
||||
|
||||
### 4. 너비 조정 (수동)
|
||||
|
||||
**컬럼 너비 조정**:
|
||||
|
||||
- 컬럼 설정 탭에서 각 컬럼별 "컬럼 너비" 드롭다운 선택
|
||||
- 1/12 (8.33%)부터 12/12 (100%)까지 선택 가능
|
||||
- 기본값은 웹타입에 따라 자동 설정됨
|
||||
|
||||
**필터 너비 조정**:
|
||||
|
||||
- 필터 설정 탭에서 각 필터별 "필터 너비" 드롭다운 선택
|
||||
- 1/12 (8.33%)부터 12/12 (100%)까지 선택 가능
|
||||
- 기본값은 웹타입에 따라 자동 설정됨
|
||||
|
||||
## 주의 사항
|
||||
|
||||
### 기존 데이터
|
||||
|
||||
- **영향 없음**: 이미 생성된 컬럼/필터는 변경되지 않음
|
||||
- **새로 추가되는 항목만** 새로운 기본값 적용
|
||||
|
||||
### 커스터마이징
|
||||
|
||||
- 기본값이 맞지 않으면 수동으로 조정 가능
|
||||
- 자주 사용하는 너비가 있다면 `getDefaultGridColumns` 함수 수정 가능
|
||||
|
||||
### 레이아웃 고려
|
||||
|
||||
- 한 행에 총 12컬럼까지 배치 가능
|
||||
- 예: 4컬럼 + 4컬럼 + 4컬럼 = 12컬럼 (딱 맞음)
|
||||
- 예: 4컬럼 + 4컬럼 + 6컬럼 = 14컬럼 (넘침 → 다음 줄로 이동)
|
||||
|
||||
## 테스트 체크리스트
|
||||
|
||||
### 컬럼 추가 테스트
|
||||
|
||||
- [ ] text 타입 컬럼 추가 → 4컬럼(33%) 확인
|
||||
- [ ] number 타입 컬럼 추가 → 2컬럼(16.67%) 확인
|
||||
- [ ] textarea 타입 컬럼 추가 → 6컬럼(50%) 확인
|
||||
- [ ] select 타입 컬럼 추가 → 3컬럼(25%) 확인
|
||||
- [ ] checkbox 타입 컬럼 추가 → 2컬럼(16.67%) 확인
|
||||
|
||||
### 필터 추가 테스트
|
||||
|
||||
- [ ] text 타입 필터 추가 → 4컬럼(33%) 확인
|
||||
- [ ] date 타입 필터 추가 → 3컬럼(25%) 확인
|
||||
- [ ] boolean 타입 필터 추가 → 2컬럼(16.67%) 확인
|
||||
|
||||
### 가상 파일 컬럼 테스트
|
||||
|
||||
- [ ] 파일 컬럼 추가 → 4컬럼(33%) 확인
|
||||
|
||||
### 수동 조정 테스트
|
||||
|
||||
- [ ] 생성 후 너비 수동 변경 가능한지 확인
|
||||
- [ ] 변경된 너비가 저장/로드 시 유지되는지 확인
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 수정된 파일
|
||||
|
||||
#### 1. `/frontend/components/screen/panels/DataTableConfigPanel.tsx`
|
||||
|
||||
- `getDefaultGridColumns` 함수 추가 (891-929줄)
|
||||
- `addColumn` 함수 수정 (954줄) - 웹타입별 기본 너비 자동 계산
|
||||
- `addFilter` 함수 수정 (781줄) - 웹타입별 기본 너비 자동 계산
|
||||
- `addVirtualFileColumn` 함수 수정 (1055줄) - 파일 타입 기본 너비 적용
|
||||
- 컬럼 설정 UI 개선 (1652줄) - "그리드 컬럼" → "컬럼 너비" (1/12 ~ 12/12)
|
||||
- 필터 설정 UI 개선 (2131줄) - "그리드 컬럼" → "필터 너비" (1/12 ~ 12/12)
|
||||
|
||||
#### 2. `/frontend/components/screen/ScreenDesigner.tsx`
|
||||
|
||||
- `getDefaultGridColumns` 함수 추가 (1946-1984줄) - 드래그 드롭 컴포넌트용
|
||||
- `getDefaultGridColumnsForTemplate` 함수 추가 (1429-1438줄) - 템플릿 컴포넌트용
|
||||
- 템플릿 컴포넌트 생성 시 기본 너비 적용 (1514줄)
|
||||
- 폼 컨테이너 내 컴포넌트 생성 시 기본 너비 적용 (2151줄)
|
||||
- 드래그 드롭 컴포넌트 생성 시 기본 너비 적용 (2194줄)
|
||||
|
||||
### 관련 문서
|
||||
|
||||
- [컴포넌트*기본*너비*설정*가이드.md](./컴포넌트_기본_너비_설정_가이드.md)
|
||||
- [그리드*컬럼수*옵션\_통합.md](./그리드_컬럼수_옵션_통합.md)
|
||||
|
||||
## 버전 히스토리
|
||||
|
||||
### v1.0.0 (2025-10-14)
|
||||
|
||||
- 웹타입별 기본 너비 자동 설정 기능 추가
|
||||
- `getDefaultGridColumns` 함수 구현
|
||||
- `addColumn`, `addFilter`, `addVirtualFileColumn` 함수에 적용
|
||||
|
|
@ -17,6 +17,9 @@ export default function ScreenManagementPage() {
|
|||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||
|
||||
// 화면 설계 모드일 때는 전체 화면 사용
|
||||
const isDesignMode = currentStep === "design";
|
||||
|
||||
// 단계별 제목과 설명
|
||||
const stepConfig = {
|
||||
list: {
|
||||
|
|
@ -65,11 +68,20 @@ export default function ScreenManagementPage() {
|
|||
// 현재 단계가 마지막 단계인지 확인
|
||||
const isLastStep = currentStep === "template";
|
||||
|
||||
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-6">
|
||||
<div className="w-full max-w-none space-y-6 px-4 py-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">화면 관리</h1>
|
||||
<p className="mt-2 text-gray-600">화면을 설계하고 템플릿을 관리합니다</p>
|
||||
|
|
@ -81,40 +93,27 @@ export default function ScreenManagementPage() {
|
|||
{/* 화면 목록 단계 */}
|
||||
{currentStep === "list" && (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.list.title}</h2>
|
||||
<Button variant="default" className="shadow-sm" onClick={() => goToNextStep("design")}>
|
||||
화면 설계하기 <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ScreenList
|
||||
onScreenSelect={setSelectedScreen}
|
||||
selectedScreen={selectedScreen}
|
||||
onDesignScreen={(screen) => {
|
||||
setSelectedScreen(screen);
|
||||
goToNextStep("design");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 설계 단계 */}
|
||||
{currentStep === "design" && (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.design.title}</h2>
|
||||
<Button variant="outline" className="shadow-sm" onClick={() => goToStep("list")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />목록으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
|
||||
<ScreenList
|
||||
onScreenSelect={setSelectedScreen}
|
||||
selectedScreen={selectedScreen}
|
||||
onDesignScreen={(screen) => {
|
||||
setSelectedScreen(screen);
|
||||
goToNextStep("design");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 템플릿 관리 단계 */}
|
||||
{currentStep === "template" && (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.template.title}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="shadow-sm" onClick={goToPreviousStep}>
|
||||
|
|
|
|||
|
|
@ -122,9 +122,9 @@ export default function ScreenViewPage() {
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
<div className="text-center bg-white rounded-xl border border-gray-200/60 shadow-lg p-8">
|
||||
<div className="rounded-xl border border-gray-200/60 bg-white p-8 text-center shadow-lg">
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-600" />
|
||||
<p className="mt-4 text-gray-700 font-medium">화면을 불러오는 중...</p>
|
||||
<p className="mt-4 font-medium text-gray-700">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -133,12 +133,12 @@ export default function ScreenViewPage() {
|
|||
if (error || !screen) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
<div className="text-center bg-white rounded-xl border border-gray-200/60 shadow-lg p-8 max-w-md">
|
||||
<div className="max-w-md rounded-xl border border-gray-200/60 bg-white p-8 text-center shadow-lg">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-red-100 to-orange-100 shadow-sm">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-bold text-gray-900">화면을 찾을 수 없습니다</h2>
|
||||
<p className="mb-6 text-gray-600 leading-relaxed">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||
<p className="mb-6 leading-relaxed text-gray-600">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||
<Button onClick={() => router.back()} variant="outline" className="rounded-lg">
|
||||
이전으로 돌아가기
|
||||
</Button>
|
||||
|
|
@ -156,7 +156,7 @@ export default function ScreenViewPage() {
|
|||
{layout && layout.components.length > 0 ? (
|
||||
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||
<div
|
||||
className="relative bg-white rounded-xl border border-gray-200/60 shadow-lg shadow-gray-900/5 mx-auto"
|
||||
className="relative mx-auto rounded-xl border border-gray-200/60 bg-white shadow-lg shadow-gray-900/5"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
|
|
@ -178,8 +178,8 @@ export default function ScreenViewPage() {
|
|||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: `${component.size.width}px`,
|
||||
height: `${component.size.height}px`,
|
||||
width: component.style?.width || `${component.size.width}px`,
|
||||
height: component.style?.height || `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.05)",
|
||||
border: (component as any).border || "1px solid rgba(59, 130, 246, 0.2)",
|
||||
|
|
@ -190,7 +190,9 @@ export default function ScreenViewPage() {
|
|||
>
|
||||
{/* 그룹 제목 */}
|
||||
{(component as any).title && (
|
||||
<div className="mb-3 text-sm font-semibold text-blue-700 bg-blue-50 px-3 py-1 rounded-lg inline-block">{(component as any).title}</div>
|
||||
<div className="mb-3 inline-block rounded-lg bg-blue-50 px-3 py-1 text-sm font-semibold text-blue-700">
|
||||
{(component as any).title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
|
||||
|
|
@ -201,8 +203,8 @@ export default function ScreenViewPage() {
|
|||
position: "absolute",
|
||||
left: `${child.position.x}px`,
|
||||
top: `${child.position.y}px`,
|
||||
width: `${child.size.width}px`,
|
||||
height: `${child.size.height}px`,
|
||||
width: child.style?.width || `${child.size.width}px`,
|
||||
height: child.style?.height || `${child.size.height}px`,
|
||||
zIndex: child.position.z || 1,
|
||||
}}
|
||||
>
|
||||
|
|
@ -275,8 +277,8 @@ export default function ScreenViewPage() {
|
|||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: `${component.size.width}px`,
|
||||
height: `${component.size.height}px`,
|
||||
width: component.style?.width || `${component.size.width}px`,
|
||||
height: component.style?.height || `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
|
|
@ -295,7 +297,13 @@ export default function ScreenViewPage() {
|
|||
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
||||
{component.type !== "widget" ? (
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
component={{
|
||||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: shouldShowLabel ? false : (component.style?.labelDisplay ?? true), // 상위에서 라벨을 표시했으면 컴포넌트 내부에서는 숨김
|
||||
},
|
||||
}}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
|
|
@ -332,10 +340,10 @@ export default function ScreenViewPage() {
|
|||
webType={(() => {
|
||||
// 유틸리티 함수로 파일 컴포넌트 감지
|
||||
if (isFileComponent(component)) {
|
||||
console.log(`🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"`, {
|
||||
console.log('🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"', {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
originalWebType: component.webType
|
||||
originalWebType: component.webType,
|
||||
});
|
||||
return "file";
|
||||
}
|
||||
|
|
@ -382,7 +390,7 @@ export default function ScreenViewPage() {
|
|||
) : (
|
||||
// 빈 화면일 때도 깔끔하게 표시
|
||||
<div
|
||||
className="mx-auto flex items-center justify-center bg-white rounded-xl border border-gray-200/60 shadow-lg shadow-gray-900/5"
|
||||
className="mx-auto flex items-center justify-center rounded-xl border border-gray-200/60 bg-white shadow-lg shadow-gray-900/5"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
|
|
|
|||
|
|
@ -1698,8 +1698,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
position: "absolute",
|
||||
left: `${child.position.x - component.position.x}px`,
|
||||
top: `${child.position.y - component.position.y}px`,
|
||||
width: `${child.size.width}px`,
|
||||
height: `${child.size.height}px`,
|
||||
width: child.style?.width || `${child.size.width}px`,
|
||||
height: child.style?.height || `${child.size.height}px`,
|
||||
zIndex: child.position.z || 1,
|
||||
}}
|
||||
>
|
||||
|
|
@ -1750,6 +1750,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
};
|
||||
|
||||
|
||||
// 상위에서 라벨을 표시한 경우, 컴포넌트 내부에서는 라벨을 숨김
|
||||
const componentForRendering = shouldShowLabel
|
||||
? {
|
||||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김
|
||||
},
|
||||
}
|
||||
: component;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full w-full">
|
||||
|
|
@ -1763,8 +1774,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 실제 위젯 */}
|
||||
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||
<div className="h-full w-full">{renderInteractiveWidget(componentForRendering)}</div>
|
||||
</div>
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
|
|
@ -1817,8 +1828,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
style={{
|
||||
left: `${popupComponent.position.x}px`,
|
||||
top: `${popupComponent.position.y}px`,
|
||||
width: `${popupComponent.size.width}px`,
|
||||
height: `${popupComponent.size.height}px`,
|
||||
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
|
||||
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
|
||||
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -202,17 +202,16 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
setAssignmentSuccess(true);
|
||||
setAssignmentMessage(successMessage);
|
||||
|
||||
// 할당 완료 콜백 호출
|
||||
// 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지)
|
||||
if (onAssignmentComplete) {
|
||||
onAssignmentComplete();
|
||||
}
|
||||
|
||||
// 3초 후 자동으로 화면 목록으로 이동
|
||||
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
||||
setTimeout(() => {
|
||||
onClose(); // 모달 닫기
|
||||
if (onBackToList) {
|
||||
onBackToList();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
|
|
@ -232,17 +231,16 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
setAssignmentSuccess(true);
|
||||
setAssignmentMessage(`"${screenInfo.screenName}" 화면이 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다.`);
|
||||
|
||||
// 할당 완료 콜백 호출
|
||||
// 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지)
|
||||
if (onAssignmentComplete) {
|
||||
onAssignmentComplete();
|
||||
}
|
||||
|
||||
// 3초 후 자동으로 화면 목록으로 이동
|
||||
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
||||
setTimeout(() => {
|
||||
onClose(); // 모달 닫기
|
||||
if (onBackToList) {
|
||||
onBackToList();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -90,17 +90,43 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
: {};
|
||||
|
||||
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
||||
// 너비 우선순위: style.width > size.width (픽셀값)
|
||||
const getWidth = () => {
|
||||
// 1순위: style.width가 있으면 우선 사용
|
||||
if (componentStyle?.width) {
|
||||
return componentStyle.width;
|
||||
}
|
||||
|
||||
// 2순위: size.width (픽셀)
|
||||
if (component.componentConfig?.type === "table-list") {
|
||||
return `${Math.max(size?.width || 120, 120)}px`;
|
||||
}
|
||||
|
||||
return `${size?.width || 100}px`;
|
||||
};
|
||||
|
||||
const getHeight = () => {
|
||||
// 1순위: style.height가 있으면 우선 사용
|
||||
if (componentStyle?.height) {
|
||||
return componentStyle.height;
|
||||
}
|
||||
|
||||
// 2순위: size.height (픽셀)
|
||||
if (component.componentConfig?.type === "table-list") {
|
||||
return `${Math.max(size?.height || 200, 200)}px`;
|
||||
}
|
||||
|
||||
return `${size?.height || 40}px`;
|
||||
};
|
||||
|
||||
const baseStyle = {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: component.componentConfig?.type === "table-list"
|
||||
? `${Math.max(size?.width || 120, 120)}px` // table-list 디폴트를 그리드 1컬럼 크기로 축소 (120px)
|
||||
: `${size?.width || 100}px`,
|
||||
height: component.componentConfig?.type === "table-list"
|
||||
? `${Math.max(size?.height || 200, 200)}px` // table-list 디폴트 높이도 축소 (200px)
|
||||
: `${size?.height || 36}px`,
|
||||
width: getWidth(),
|
||||
height: getHeight(),
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상
|
||||
...componentStyle,
|
||||
// style.width와 style.height는 이미 getWidth/getHeight에서 처리했으므로 중복 적용됨
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
|
|
@ -134,9 +160,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 동적 컴포넌트 렌더링 */}
|
||||
<div className={`h-full w-full ${
|
||||
component.componentConfig?.type === "table-list" ? "overflow-hidden" : ""
|
||||
}`}>
|
||||
<div
|
||||
className={`h-full w-full max-w-full ${
|
||||
component.componentConfig?.type === "table-list" ? "overflow-hidden" : "overflow-hidden"
|
||||
}`}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
|
|
@ -155,7 +183,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-8 left-0 rounded-lg bg-gray-800/90 px-3 py-2 text-xs text-white backdrop-blur-sm shadow-lg">
|
||||
<div className="absolute -top-8 left-0 rounded-lg bg-gray-800/90 px-3 py-2 text-xs text-white shadow-lg backdrop-blur-sm">
|
||||
{type === "widget" && (
|
||||
<div className="flex items-center gap-2">
|
||||
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,14 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Palette, Type, Square, Box, Eye, RotateCcw } from "lucide-react";
|
||||
import { Palette, Type, Square, Box } from "lucide-react";
|
||||
import { ComponentStyle } from "@/types/screen";
|
||||
|
||||
interface StyleEditorProps {
|
||||
|
|
@ -18,10 +15,10 @@ interface StyleEditorProps {
|
|||
}
|
||||
|
||||
export default function StyleEditor({ style, onStyleChange, className }: StyleEditorProps) {
|
||||
const [localStyle, setLocalStyle] = useState<ComponentStyle>(style);
|
||||
const [localStyle, setLocalStyle] = useState<ComponentStyle>(style || {});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalStyle(style);
|
||||
setLocalStyle(style || {});
|
||||
}, [style]);
|
||||
|
||||
const handleStyleChange = (property: keyof ComponentStyle, value: any) => {
|
||||
|
|
@ -30,243 +27,218 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
onStyleChange(newStyle);
|
||||
};
|
||||
|
||||
const resetStyle = () => {
|
||||
const resetStyle: ComponentStyle = {};
|
||||
setLocalStyle(resetStyle);
|
||||
onStyleChange(resetStyle);
|
||||
};
|
||||
|
||||
const applyStyle = () => {
|
||||
onStyleChange(localStyle);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Palette className="h-4 w-4" />
|
||||
스타일 편집
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={resetStyle}>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button size="sm" onClick={applyStyle}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
적용
|
||||
</Button>
|
||||
<div className={`space-y-6 p-4 ${className}`}>
|
||||
{/* 여백 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Box className="h-4 w-4 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-900">여백</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="margin">외부 여백</Label>
|
||||
<Input
|
||||
id="margin"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.margin || ""}
|
||||
onChange={(e) => handleStyleChange("margin", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="padding">내부 여백</Label>
|
||||
<Input
|
||||
id="padding"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.padding || ""}
|
||||
onChange={(e) => handleStyleChange("padding", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="spacing" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="spacing">
|
||||
<Box className="mr-1 h-3 w-3" />
|
||||
여백
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="border">
|
||||
<Square className="mr-1 h-3 w-3" />
|
||||
테두리
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="background">
|
||||
<Palette className="mr-1 h-3 w-3" />
|
||||
배경
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="typography">
|
||||
<Type className="mr-1 h-3 w-3" />
|
||||
텍스트
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 여백 탭 */}
|
||||
<TabsContent value="spacing" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="margin">외부 여백</Label>
|
||||
<Input
|
||||
id="margin"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.margin || ""}
|
||||
onChange={(e) => handleStyleChange("margin", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="padding">내부 여백</Label>
|
||||
<Input
|
||||
id="padding"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.padding || ""}
|
||||
onChange={(e) => handleStyleChange("padding", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gap">간격</Label>
|
||||
<Input
|
||||
id="gap"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.gap || ""}
|
||||
onChange={(e) => handleStyleChange("gap", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gap">간격</Label>
|
||||
<Input
|
||||
id="gap"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.gap || ""}
|
||||
onChange={(e) => handleStyleChange("gap", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{/* 테두리 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Square className="h-4 w-4 text-green-600" />
|
||||
<h3 className="font-semibold text-gray-900">테두리</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderWidth">테두리 두께</Label>
|
||||
<Input
|
||||
id="borderWidth"
|
||||
type="text"
|
||||
placeholder="1px, 2px"
|
||||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderStyle">테두리 스타일</Label>
|
||||
<Select
|
||||
value={localStyle.borderStyle || "solid"}
|
||||
onValueChange={(value) => handleStyleChange("borderStyle", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">파선</SelectItem>
|
||||
<SelectItem value="dotted">점선</SelectItem>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테두리 탭 */}
|
||||
<TabsContent value="border" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderWidth">테두리 두께</Label>
|
||||
<Input
|
||||
id="borderWidth"
|
||||
type="text"
|
||||
placeholder="1px, 2px"
|
||||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderStyle">테두리 스타일</Label>
|
||||
<Select
|
||||
value={localStyle.borderStyle || "solid"}
|
||||
onValueChange={(value) => handleStyleChange("borderStyle", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">파선</SelectItem>
|
||||
<SelectItem value="dotted">점선</SelectItem>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderColor">테두리 색상</Label>
|
||||
<Input
|
||||
id="borderColor"
|
||||
type="color"
|
||||
value={localStyle.borderColor || "#000000"}
|
||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderRadius">모서리 둥글기</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
type="text"
|
||||
placeholder="5px, 10px"
|
||||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderColor">테두리 색상</Label>
|
||||
<Input
|
||||
id="borderColor"
|
||||
type="color"
|
||||
value={localStyle.borderColor || "#000000"}
|
||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderRadius">모서리 둥글기</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
type="text"
|
||||
placeholder="5px, 10px"
|
||||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{/* 배경 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="font-semibold text-gray-900">배경</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundColor">배경 색상</Label>
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
type="color"
|
||||
value={localStyle.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배경 탭 */}
|
||||
<TabsContent value="background" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundColor">배경 색상</Label>
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
type="color"
|
||||
value={localStyle.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundImage">배경 이미지</Label>
|
||||
<Input
|
||||
id="backgroundImage"
|
||||
type="text"
|
||||
placeholder="url('image.jpg')"
|
||||
value={localStyle.backgroundImage || ""}
|
||||
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundImage">배경 이미지</Label>
|
||||
<Input
|
||||
id="backgroundImage"
|
||||
type="text"
|
||||
placeholder="url('image.jpg')"
|
||||
value={localStyle.backgroundImage || ""}
|
||||
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{/* 텍스트 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="h-4 w-4 text-orange-600" />
|
||||
<h3 className="font-semibold text-gray-900">텍스트</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">텍스트 색상</Label>
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={localStyle.color || "#000000"}
|
||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontSize">글자 크기</Label>
|
||||
<Input
|
||||
id="fontSize"
|
||||
type="text"
|
||||
placeholder="14px, 1rem"
|
||||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 탭 */}
|
||||
<TabsContent value="typography" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">텍스트 색상</Label>
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={localStyle.color || "#000000"}
|
||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontSize">글자 크기</Label>
|
||||
<Input
|
||||
id="fontSize"
|
||||
type="text"
|
||||
placeholder="14px, 1rem"
|
||||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontWeight">글자 굵기</Label>
|
||||
<Select
|
||||
value={localStyle.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="400">400</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="600">600</SelectItem>
|
||||
<SelectItem value="700">700</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="textAlign">텍스트 정렬</Label>
|
||||
<Select
|
||||
value={localStyle.textAlign || "left"}
|
||||
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
<SelectItem value="justify">양쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontWeight">글자 굵기</Label>
|
||||
<Select
|
||||
value={localStyle.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="400">400</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="600">600</SelectItem>
|
||||
<SelectItem value="700">700</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="textAlign">텍스트 정렬</Label>
|
||||
<Select
|
||||
value={localStyle.textAlign || "left"}
|
||||
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
<SelectItem value="justify">양쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true,
|
||||
showPageInfo: component.pagination?.showPageInfo ?? true,
|
||||
showFirstLast: component.pagination?.showFirstLast ?? true,
|
||||
gridColumns: component.gridColumns || 6,
|
||||
});
|
||||
|
||||
// 컬럼별 로컬 입력 상태
|
||||
|
|
@ -110,45 +109,45 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
// 컴포넌트 변경 시 로컬 값 동기화
|
||||
useEffect(() => {
|
||||
// console.log("🔄 DataTableConfig: 컴포넌트 변경 감지", {
|
||||
// componentId: component.id,
|
||||
// title: component.title,
|
||||
// searchButtonText: component.searchButtonText,
|
||||
// columnsCount: component.columns.length,
|
||||
// filtersCount: component.filters.length,
|
||||
// columnIds: component.columns.map((col) => col.id),
|
||||
// filterColumnNames: component.filters.map((filter) => filter.columnName),
|
||||
// timestamp: new Date().toISOString(),
|
||||
// componentId: component.id,
|
||||
// title: component.title,
|
||||
// searchButtonText: component.searchButtonText,
|
||||
// columnsCount: component.columns.length,
|
||||
// filtersCount: component.filters.length,
|
||||
// columnIds: component.columns.map((col) => col.id),
|
||||
// filterColumnNames: component.filters.map((filter) => filter.columnName),
|
||||
// timestamp: new Date().toISOString(),
|
||||
// });
|
||||
|
||||
// 컬럼과 필터 상세 정보 로그
|
||||
if (component.columns.length > 0) {
|
||||
// console.log(
|
||||
// "📋 현재 컬럼 목록:",
|
||||
// component.columns.map((col) => ({
|
||||
// id: col.id,
|
||||
// columnName: col.columnName,
|
||||
// label: col.label,
|
||||
// visible: col.visible,
|
||||
// gridColumns: col.gridColumns,
|
||||
// })),
|
||||
// "📋 현재 컬럼 목록:",
|
||||
// component.columns.map((col) => ({
|
||||
// id: col.id,
|
||||
// columnName: col.columnName,
|
||||
// label: col.label,
|
||||
// visible: col.visible,
|
||||
// gridColumns: col.gridColumns,
|
||||
// })),
|
||||
// );
|
||||
}
|
||||
|
||||
// 로컬 상태 정보 로그
|
||||
// console.log("🔧 로컬 상태 정보:", {
|
||||
// localColumnInputsCount: Object.keys(localColumnInputs).length,
|
||||
// localColumnCheckboxesCount: Object.keys(localColumnCheckboxes).length,
|
||||
// localColumnGridColumnsCount: Object.keys(localColumnGridColumns).length,
|
||||
// localColumnInputsCount: Object.keys(localColumnInputs).length,
|
||||
// localColumnCheckboxesCount: Object.keys(localColumnCheckboxes).length,
|
||||
// localColumnGridColumnsCount: Object.keys(localColumnGridColumns).length,
|
||||
// });
|
||||
|
||||
if (component.filters.length > 0) {
|
||||
// console.log(
|
||||
// "🔍 현재 필터 목록:",
|
||||
// component.filters.map((filter) => ({
|
||||
// columnName: filter.columnName,
|
||||
// widgetType: filter.widgetType,
|
||||
// label: filter.label,
|
||||
// })),
|
||||
// "🔍 현재 필터 목록:",
|
||||
// component.filters.map((filter) => ({
|
||||
// columnName: filter.columnName,
|
||||
// widgetType: filter.widgetType,
|
||||
// label: filter.label,
|
||||
// })),
|
||||
// );
|
||||
}
|
||||
|
||||
|
|
@ -179,7 +178,6 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true,
|
||||
showPageInfo: component.pagination?.showPageInfo ?? true,
|
||||
showFirstLast: component.pagination?.showFirstLast ?? true,
|
||||
gridColumns: component.gridColumns || 6,
|
||||
// 테이블명 동기화
|
||||
tableName: component.tableName || "",
|
||||
});
|
||||
|
|
@ -259,9 +257,9 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
if (!(filterKey in newFilterInputs)) {
|
||||
newFilterInputs[filterKey] = filter.label || filter.columnName;
|
||||
// console.log("🆕 새 필터 로컬 상태 추가:", {
|
||||
// filterKey,
|
||||
// label: filter.label,
|
||||
// columnName: filter.columnName,
|
||||
// filterKey,
|
||||
// label: filter.label,
|
||||
// columnName: filter.columnName,
|
||||
// });
|
||||
}
|
||||
});
|
||||
|
|
@ -278,9 +276,9 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
|
||||
// console.log("📝 필터 로컬 상태 동기화 완료:", {
|
||||
// prevCount: Object.keys(prev).length,
|
||||
// newCount: Object.keys(newFilterInputs).length,
|
||||
// newKeys: Object.keys(newFilterInputs),
|
||||
// prevCount: Object.keys(prev).length,
|
||||
// newCount: Object.keys(newFilterInputs).length,
|
||||
// newKeys: Object.keys(newFilterInputs),
|
||||
// });
|
||||
|
||||
return newFilterInputs;
|
||||
|
|
@ -320,18 +318,18 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
if (!table) return;
|
||||
|
||||
// console.log("🔄 테이블 변경:", {
|
||||
// tableName,
|
||||
// currentTableName: localValues.tableName,
|
||||
// table,
|
||||
// columnsCount: table.columns.length,
|
||||
// tableName,
|
||||
// currentTableName: localValues.tableName,
|
||||
// table,
|
||||
// columnsCount: table.columns.length,
|
||||
// });
|
||||
|
||||
// 테이블 변경 시 컬럼을 자동으로 추가하지 않음 (사용자가 수동으로 추가해야 함)
|
||||
const defaultColumns: DataTableColumn[] = [];
|
||||
|
||||
// console.log("✅ 생성된 컬럼 설정:", {
|
||||
// defaultColumnsCount: defaultColumns.length,
|
||||
// visibleColumns: defaultColumns.filter((col) => col.visible).length,
|
||||
// defaultColumnsCount: defaultColumns.length,
|
||||
// visibleColumns: defaultColumns.filter((col) => col.visible).length,
|
||||
// });
|
||||
|
||||
// 상태 업데이트를 한 번에 처리
|
||||
|
|
@ -378,10 +376,10 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
try {
|
||||
// TODO: 테이블 타입 관리 API 호출하여 웹 타입과 상세 설정 업데이트
|
||||
// console.log("📡 테이블 타입 관리 업데이트 필요:", {
|
||||
// tableName: component.tableName,
|
||||
// columnName: targetColumn.columnName,
|
||||
// webType: "radio",
|
||||
// detailSettings: JSON.stringify(webTypeConfig),
|
||||
// tableName: component.tableName,
|
||||
// columnName: targetColumn.columnName,
|
||||
// webType: "radio",
|
||||
// detailSettings: JSON.stringify(webTypeConfig),
|
||||
// });
|
||||
} catch (error) {
|
||||
// console.error("테이블 타입 관리 업데이트 실패:", error);
|
||||
|
|
@ -738,9 +736,9 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
|
||||
// console.log("🗑️ 컬럼 삭제:", {
|
||||
// columnId,
|
||||
// columnName: columnToRemove?.columnName,
|
||||
// remainingColumns: updatedColumns.length,
|
||||
// columnId,
|
||||
// columnName: columnToRemove?.columnName,
|
||||
// remainingColumns: updatedColumns.length,
|
||||
// });
|
||||
|
||||
onUpdateComponent({
|
||||
|
|
@ -780,7 +778,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
columnName: targetColumn.columnName,
|
||||
widgetType,
|
||||
label: targetColumn.columnLabel || targetColumn.columnName,
|
||||
gridColumns: 3,
|
||||
gridColumns: getDefaultGridColumns(widgetType),
|
||||
// 웹타입별 추가 정보 설정
|
||||
codeCategory: targetColumn.codeCategory,
|
||||
referenceTable: targetColumn.referenceTable,
|
||||
|
|
@ -789,28 +787,28 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
// console.log("➕ 필터 추가 시작:", {
|
||||
// targetColumnName: targetColumn.columnName,
|
||||
// targetColumnLabel: targetColumn.columnLabel,
|
||||
// inferredWidgetType: widgetType,
|
||||
// currentFiltersCount: component.filters.length,
|
||||
// targetColumnName: targetColumn.columnName,
|
||||
// targetColumnLabel: targetColumn.columnLabel,
|
||||
// inferredWidgetType: widgetType,
|
||||
// currentFiltersCount: component.filters.length,
|
||||
// });
|
||||
|
||||
// console.log("➕ 생성된 새 필터:", {
|
||||
// columnName: newFilter.columnName,
|
||||
// widgetType: newFilter.widgetType,
|
||||
// label: newFilter.label,
|
||||
// gridColumns: newFilter.gridColumns,
|
||||
// columnName: newFilter.columnName,
|
||||
// widgetType: newFilter.widgetType,
|
||||
// label: newFilter.label,
|
||||
// gridColumns: newFilter.gridColumns,
|
||||
// });
|
||||
|
||||
const updatedFilters = [...component.filters, newFilter];
|
||||
// console.log("🔄 필터 업데이트 호출:", {
|
||||
// filtersToAdd: 1,
|
||||
// totalFiltersAfter: updatedFilters.length,
|
||||
// updatedFilters: updatedFilters.map((filter) => ({
|
||||
// columnName: filter.columnName,
|
||||
// widgetType: filter.widgetType,
|
||||
// label: filter.label,
|
||||
// })),
|
||||
// filtersToAdd: 1,
|
||||
// totalFiltersAfter: updatedFilters.length,
|
||||
// updatedFilters: updatedFilters.map((filter) => ({
|
||||
// columnName: filter.columnName,
|
||||
// widgetType: filter.widgetType,
|
||||
// label: filter.label,
|
||||
// })),
|
||||
// });
|
||||
|
||||
// 먼저 로컬 상태를 업데이트하고
|
||||
|
|
@ -821,10 +819,10 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
[filterKey]: newFilter.label,
|
||||
};
|
||||
// console.log("📝 필터 로컬 상태 업데이트:", {
|
||||
// filterKey,
|
||||
// newLabel: newFilter.label,
|
||||
// prevState: prev,
|
||||
// newState,
|
||||
// filterKey,
|
||||
// newLabel: newFilter.label,
|
||||
// prevState: prev,
|
||||
// newState,
|
||||
// });
|
||||
return newState;
|
||||
});
|
||||
|
|
@ -836,8 +834,8 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
setActiveTab("filters");
|
||||
|
||||
// console.log("🔍 필터 추가 후 탭 이동:", {
|
||||
// activeTab: "filters",
|
||||
// isExternalControl: !!onTabChange,
|
||||
// activeTab: "filters",
|
||||
// isExternalControl: !!onTabChange,
|
||||
// });
|
||||
|
||||
// 강제로 리렌더링을 트리거하기 위해 여러 방법 사용
|
||||
|
|
@ -859,9 +857,9 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
}, 100);
|
||||
|
||||
// console.log("✅ 필터 추가 완료 - 로컬 상태와 컴포넌트 모두 업데이트됨", {
|
||||
// filterKey,
|
||||
// newFilterLabel: newFilter.label,
|
||||
// switchedToTab: "filters",
|
||||
// filterKey,
|
||||
// newFilterLabel: newFilter.label,
|
||||
// switchedToTab: "filters",
|
||||
// });
|
||||
}, [selectedTable, component.filters, onUpdateComponent]);
|
||||
|
||||
|
|
@ -890,6 +888,48 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
return !nonFilterableTypes.includes(webType);
|
||||
};
|
||||
|
||||
// 웹타입별 기본 컬럼 수 계산 (컴포넌트 너비 기반)
|
||||
const getDefaultGridColumns = (webType: WebType): number => {
|
||||
// 각 웹타입별 적절한 기본 너비 설정
|
||||
const widthMap: Record<WebType, number> = {
|
||||
// 텍스트 입력 계열 (넓게)
|
||||
text: 4, // 1/3 (33%)
|
||||
email: 4, // 1/3 (33%)
|
||||
tel: 3, // 1/4 (25%)
|
||||
url: 4, // 1/3 (33%)
|
||||
textarea: 6, // 절반 (50%)
|
||||
|
||||
// 숫자/날짜 입력 (중간)
|
||||
number: 2, // 2/12 (16.67%)
|
||||
decimal: 2, // 2/12 (16.67%)
|
||||
date: 3, // 1/4 (25%)
|
||||
datetime: 3, // 1/4 (25%)
|
||||
time: 2, // 2/12 (16.67%)
|
||||
|
||||
// 선택 입력 (중간)
|
||||
select: 3, // 1/4 (25%)
|
||||
radio: 3, // 1/4 (25%)
|
||||
checkbox: 2, // 2/12 (16.67%)
|
||||
boolean: 2, // 2/12 (16.67%)
|
||||
|
||||
// 코드/참조 (넓게)
|
||||
code: 3, // 1/4 (25%)
|
||||
entity: 4, // 1/3 (33%)
|
||||
|
||||
// 파일/이미지 (넓게)
|
||||
file: 4, // 1/3 (33%)
|
||||
image: 3, // 1/4 (25%)
|
||||
|
||||
// 기타
|
||||
button: 2, // 2/12 (16.67%)
|
||||
label: 2, // 2/12 (16.67%)
|
||||
};
|
||||
|
||||
const defaultColumns = widthMap[webType] || 3; // 기본값 3 (1/4, 25%)
|
||||
console.log("🎯 getDefaultGridColumns 호출:", { webType, defaultColumns, widthMap: widthMap[webType] });
|
||||
return defaultColumns;
|
||||
};
|
||||
|
||||
// 컬럼 추가 (테이블에서 선택)
|
||||
const addColumn = useCallback(
|
||||
(columnName?: string) => {
|
||||
|
|
@ -907,38 +947,47 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
: availableColumns[0];
|
||||
|
||||
const widgetType = getWidgetTypeFromColumn(targetColumn);
|
||||
const calculatedGridColumns = getDefaultGridColumns(widgetType);
|
||||
|
||||
console.log("➕ addColumn 호출:", {
|
||||
columnName: targetColumn.columnName,
|
||||
widgetType,
|
||||
calculatedGridColumns,
|
||||
});
|
||||
|
||||
const newColumn: DataTableColumn = {
|
||||
id: generateComponentId(),
|
||||
columnName: targetColumn.columnName,
|
||||
label: targetColumn.columnLabel || targetColumn.columnName,
|
||||
widgetType,
|
||||
gridColumns: 2,
|
||||
gridColumns: calculatedGridColumns,
|
||||
visible: true,
|
||||
filterable: isFilterableWebType(widgetType),
|
||||
sortable: true,
|
||||
searchable: ["text", "email", "tel"].includes(widgetType),
|
||||
};
|
||||
|
||||
console.log("✅ 생성된 newColumn:", newColumn);
|
||||
|
||||
// 필터는 자동으로 추가하지 않음 (사용자가 수동으로 추가)
|
||||
|
||||
// console.log("➕ 컬럼 추가 시작:", {
|
||||
// targetColumnName: targetColumn.columnName,
|
||||
// targetColumnLabel: targetColumn.columnLabel,
|
||||
// inferredWidgetType: widgetType,
|
||||
// currentColumnsCount: component.columns.length,
|
||||
// currentFiltersCount: component.filters.length,
|
||||
// targetColumnName: targetColumn.columnName,
|
||||
// targetColumnLabel: targetColumn.columnLabel,
|
||||
// inferredWidgetType: widgetType,
|
||||
// currentColumnsCount: component.columns.length,
|
||||
// currentFiltersCount: component.filters.length,
|
||||
// });
|
||||
|
||||
// console.log("➕ 생성된 새 컬럼:", {
|
||||
// id: newColumn.id,
|
||||
// columnName: newColumn.columnName,
|
||||
// label: newColumn.label,
|
||||
// widgetType: newColumn.widgetType,
|
||||
// filterable: newColumn.filterable,
|
||||
// visible: newColumn.visible,
|
||||
// sortable: newColumn.sortable,
|
||||
// searchable: newColumn.searchable,
|
||||
// id: newColumn.id,
|
||||
// columnName: newColumn.columnName,
|
||||
// label: newColumn.label,
|
||||
// widgetType: newColumn.widgetType,
|
||||
// filterable: newColumn.filterable,
|
||||
// visible: newColumn.visible,
|
||||
// sortable: newColumn.sortable,
|
||||
// searchable: newColumn.searchable,
|
||||
// });
|
||||
|
||||
// 필터는 수동으로만 추가
|
||||
|
|
@ -950,9 +999,9 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
[newColumn.id]: newColumn.label,
|
||||
};
|
||||
// console.log("🔄 로컬 컬럼 상태 업데이트:", {
|
||||
// newColumnId: newColumn.id,
|
||||
// newLabel: newColumn.label,
|
||||
// totalLocalInputs: Object.keys(newInputs).length,
|
||||
// newColumnId: newColumn.id,
|
||||
// newLabel: newColumn.label,
|
||||
// totalLocalInputs: Object.keys(newInputs).length,
|
||||
// });
|
||||
return newInputs;
|
||||
});
|
||||
|
|
@ -979,14 +1028,14 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
// console.log("🔄 컴포넌트 업데이트 호출:", {
|
||||
// columnsToAdd: 1,
|
||||
// totalColumnsAfter: updates.columns?.length,
|
||||
// hasColumns: !!updates.columns,
|
||||
// updateKeys: Object.keys(updates),
|
||||
// columnsToAdd: 1,
|
||||
// totalColumnsAfter: updates.columns?.length,
|
||||
// hasColumns: !!updates.columns,
|
||||
// updateKeys: Object.keys(updates),
|
||||
// });
|
||||
|
||||
// console.log("🔄 업데이트 상세 내용:", {
|
||||
// columns: updates.columns?.map((col) => ({ id: col.id, columnName: col.columnName, label: col.label })),
|
||||
// columns: updates.columns?.map((col) => ({ id: col.id, columnName: col.columnName, label: col.label })),
|
||||
// });
|
||||
|
||||
onUpdateComponent(updates);
|
||||
|
|
@ -995,8 +1044,8 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
setActiveTab("columns");
|
||||
|
||||
// console.log("📋 컬럼 추가 후 탭 이동:", {
|
||||
// activeTab: "columns",
|
||||
// isExternalControl: !!onTabChange,
|
||||
// activeTab: "columns",
|
||||
// isExternalControl: !!onTabChange,
|
||||
// });
|
||||
|
||||
// console.log("✅ 컬럼 추가 완료 - onUpdateComponent 호출됨");
|
||||
|
|
@ -1014,7 +1063,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
columnName: newColumnName,
|
||||
label: `파일 컬럼 ${fileColumnCount + 1}`,
|
||||
widgetType: "file",
|
||||
gridColumns: 2,
|
||||
gridColumns: getDefaultGridColumns("file"),
|
||||
visible: true,
|
||||
filterable: false, // 파일 컬럼은 필터링 불가
|
||||
sortable: false, // 파일 컬럼은 정렬 불가
|
||||
|
|
@ -1029,9 +1078,9 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
// console.log("📁 가상 파일 컬럼 추가:", {
|
||||
// columnName: newColumn.columnName,
|
||||
// label: newColumn.label,
|
||||
// isVirtualFileColumn: newColumn.isVirtualFileColumn,
|
||||
// columnName: newColumn.columnName,
|
||||
// label: newColumn.label,
|
||||
// isVirtualFileColumn: newColumn.isVirtualFileColumn,
|
||||
// });
|
||||
|
||||
// 로컬 상태에 새 컬럼 입력값 추가
|
||||
|
|
@ -1092,7 +1141,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
<div className="space-y-2">
|
||||
<Label htmlFor="table-select">연결 테이블</Label>
|
||||
<select
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
className="focus:border-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={localValues.tableName}
|
||||
onChange={(e) => handleTableChange(e.target.value)}
|
||||
>
|
||||
|
|
@ -1434,27 +1483,6 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="grid-columns">그리드 컬럼 수</Label>
|
||||
<select
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={localValues.gridColumns.toString()}
|
||||
onChange={(e) => {
|
||||
const gridColumns = parseInt(e.target.value, 10);
|
||||
// console.log("🔄 테이블 그리드 컬럼 수 변경:", gridColumns);
|
||||
setLocalValues((prev) => ({ ...prev, gridColumns }));
|
||||
onUpdateComponent({ gridColumns });
|
||||
}}
|
||||
>
|
||||
<option value="">그리드 컬럼 수 선택</option>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((num) => (
|
||||
<option key={num} value={num.toString()}>
|
||||
{num}컬럼 ({Math.round((num / 12) * 100)}%)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
|
@ -1632,12 +1660,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">그리드 컬럼</Label>
|
||||
<Label className="text-xs">컬럼 너비</Label>
|
||||
<Select
|
||||
value={(localColumnGridColumns[column.id] ?? column.gridColumns).toString()}
|
||||
onValueChange={(value) => {
|
||||
const newGridColumns = parseInt(value);
|
||||
// console.log("🔄 컬럼 그리드 컬럼 변경:", { columnId: column.id, newGridColumns });
|
||||
// console.log("🔄 컬럼 너비 변경:", { columnId: column.id, newGridColumns });
|
||||
setLocalColumnGridColumns((prev) => ({
|
||||
...prev,
|
||||
[column.id]: newGridColumns,
|
||||
|
|
@ -1649,11 +1677,18 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 6, 12].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num}칸
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="1">1/12 (8.33%)</SelectItem>
|
||||
<SelectItem value="2">2/12 (16.67%)</SelectItem>
|
||||
<SelectItem value="3">3/12 (25%)</SelectItem>
|
||||
<SelectItem value="4">4/12 (33%)</SelectItem>
|
||||
<SelectItem value="5">5/12 (41.67%)</SelectItem>
|
||||
<SelectItem value="6">6/12 (50%)</SelectItem>
|
||||
<SelectItem value="7">7/12 (58.33%)</SelectItem>
|
||||
<SelectItem value="8">8/12 (66.67%)</SelectItem>
|
||||
<SelectItem value="9">9/12 (75%)</SelectItem>
|
||||
<SelectItem value="10">10/12 (83.33%)</SelectItem>
|
||||
<SelectItem value="11">11/12 (91.67%)</SelectItem>
|
||||
<SelectItem value="12">12/12 (100%)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -2023,11 +2058,11 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
const localValue = localFilterInputs[filterKey];
|
||||
const finalValue = localValue !== undefined ? localValue : filter.label;
|
||||
// console.log("🎯 필터 입력 값 결정:", {
|
||||
// filterKey,
|
||||
// localValue,
|
||||
// filterLabel: filter.label,
|
||||
// finalValue,
|
||||
// allLocalInputs: Object.keys(localFilterInputs),
|
||||
// filterKey,
|
||||
// localValue,
|
||||
// filterLabel: filter.label,
|
||||
// finalValue,
|
||||
// allLocalInputs: Object.keys(localFilterInputs),
|
||||
// });
|
||||
return finalValue;
|
||||
})()}
|
||||
|
|
@ -2104,7 +2139,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">그리드 컬럼</Label>
|
||||
<Label className="text-xs">필터 너비</Label>
|
||||
<Select
|
||||
value={filter.gridColumns.toString()}
|
||||
onValueChange={(value) => updateFilter(index, { gridColumns: parseInt(value) })}
|
||||
|
|
@ -2113,11 +2148,18 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 6, 12].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num}칸
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="1">1/12 (8.33%)</SelectItem>
|
||||
<SelectItem value="2">2/12 (16.67%)</SelectItem>
|
||||
<SelectItem value="3">3/12 (25%)</SelectItem>
|
||||
<SelectItem value="4">4/12 (33%)</SelectItem>
|
||||
<SelectItem value="5">5/12 (41.67%)</SelectItem>
|
||||
<SelectItem value="6">6/12 (50%)</SelectItem>
|
||||
<SelectItem value="7">7/12 (58.33%)</SelectItem>
|
||||
<SelectItem value="8">8/12 (66.67%)</SelectItem>
|
||||
<SelectItem value="9">9/12 (75%)</SelectItem>
|
||||
<SelectItem value="10">10/12 (83.33%)</SelectItem>
|
||||
<SelectItem value="11">11/12 (91.67%)</SelectItem>
|
||||
<SelectItem value="12">12/12 (100%)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -2180,7 +2222,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
<div className="space-y-2">
|
||||
<Label>페이지당 행 수</Label>
|
||||
<select
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
className="focus:border-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={component.pagination.pageSize.toString()}
|
||||
onChange={(e) =>
|
||||
onUpdateComponent({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
|
|
@ -15,7 +15,9 @@ import {
|
|||
} from "@/types/screen";
|
||||
// 레거시 ButtonConfigPanel 제거됨
|
||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } from "@/types/input-type-mapping";
|
||||
|
||||
// 새로운 컴포넌트 설정 패널들 import
|
||||
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
||||
|
|
@ -47,11 +49,11 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
|
||||
// console.log(`🔍 DetailSettingsPanel props:`, {
|
||||
// selectedComponent: selectedComponent?.id,
|
||||
// componentType: selectedComponent?.type,
|
||||
// currentTableName,
|
||||
// currentTable: currentTable?.tableName,
|
||||
// selectedComponentTableName: selectedComponent?.tableName,
|
||||
// selectedComponent: selectedComponent?.id,
|
||||
// componentType: selectedComponent?.type,
|
||||
// currentTableName,
|
||||
// currentTable: currentTable?.tableName,
|
||||
// selectedComponentTableName: selectedComponent?.tableName,
|
||||
// });
|
||||
// console.log(`🔍 DetailSettingsPanel webTypes 로드됨:`, webTypes?.length, "개");
|
||||
// console.log(`🔍 webTypes:`, webTypes);
|
||||
|
|
@ -59,6 +61,19 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
// console.log(`🔍 DetailSettingsPanel selectedComponent.widgetType:`, selectedComponent?.widgetType);
|
||||
const inputableWebTypes = webTypes.map((wt) => wt.web_type);
|
||||
|
||||
// 새로운 컴포넌트 시스템용 로컬 상태
|
||||
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
||||
|
||||
// 새로운 컴포넌트 시스템의 webType 동기화
|
||||
useEffect(() => {
|
||||
if (selectedComponent?.type === "component") {
|
||||
const webType = selectedComponent.componentConfig?.webType;
|
||||
if (webType) {
|
||||
setLocalComponentDetailType(webType);
|
||||
}
|
||||
}
|
||||
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
|
||||
|
||||
// 레이아웃 컴포넌트 설정 렌더링 함수
|
||||
const renderLayoutConfig = (layoutComponent: LayoutComponent) => {
|
||||
return (
|
||||
|
|
@ -66,11 +81,11 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<Settings className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="font-medium text-gray-900">레이아웃 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">타입:</span>
|
||||
<span className="text-muted-foreground text-sm">타입:</span>
|
||||
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800">
|
||||
{layoutComponent.layoutType}
|
||||
</span>
|
||||
|
|
@ -87,7 +102,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
type="text"
|
||||
value={layoutComponent.label || ""}
|
||||
onChange={(e) => onUpdateProperty(layoutComponent.id, "label", e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-2 focus:ring-blue-500"
|
||||
className="focus:border-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="레이아웃 이름을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -218,9 +233,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}));
|
||||
|
||||
// console.log("🔄 존 크기 자동 조정:", {
|
||||
// direction: newDirection,
|
||||
// zoneCount,
|
||||
// updatedZones: updatedZones.map((z) => ({ id: z.id, size: z.size })),
|
||||
// direction: newDirection,
|
||||
// zoneCount,
|
||||
// updatedZones: updatedZones.map((z) => ({ id: z.id, size: z.size })),
|
||||
// });
|
||||
|
||||
onUpdateProperty(layoutComponent.id, "zones", updatedZones);
|
||||
|
|
@ -334,7 +349,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-xs font-medium text-gray-700">테이블 컬럼 매핑</h5>
|
||||
{currentTable && (
|
||||
<span className="rounded bg-accent px-2 py-1 text-xs text-primary">
|
||||
<span className="bg-accent text-primary rounded px-2 py-1 text-xs">
|
||||
테이블: {currentTable.table_name}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -354,7 +369,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
{currentTable && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">타이틀 컬럼</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">타이틀 컬럼</label>
|
||||
<select
|
||||
value={layoutComponent.layoutConfig?.card?.columnMapping?.titleColumn || ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -376,7 +391,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">서브타이틀 컬럼</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">서브타이틀 컬럼</label>
|
||||
<select
|
||||
value={layoutComponent.layoutConfig?.card?.columnMapping?.subtitleColumn || ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -398,7 +413,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">설명 컬럼</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">설명 컬럼</label>
|
||||
<select
|
||||
value={layoutComponent.layoutConfig?.card?.columnMapping?.descriptionColumn || ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -420,7 +435,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">이미지 컬럼</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">이미지 컬럼</label>
|
||||
<select
|
||||
value={layoutComponent.layoutConfig?.card?.columnMapping?.imageColumn || ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -444,7 +459,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
{/* 동적 표시 컬럼 추가 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">표시 컬럼들</label>
|
||||
<label className="text-muted-foreground text-xs font-medium">표시 컬럼들</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -457,7 +472,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
newColumns,
|
||||
);
|
||||
}}
|
||||
className="rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
+ 컬럼 추가
|
||||
</button>
|
||||
|
|
@ -502,7 +517,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
currentColumns,
|
||||
);
|
||||
}}
|
||||
className="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
|
|
@ -528,7 +543,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">한 행당 카드 수</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">한 행당 카드 수</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
|
|
@ -542,7 +557,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">카드 간격 (px)</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">카드 간격 (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
|
|
@ -567,7 +582,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="showTitle" className="text-xs text-muted-foreground">
|
||||
<label htmlFor="showTitle" className="text-muted-foreground text-xs">
|
||||
타이틀 표시
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -586,7 +601,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="showSubtitle" className="text-xs text-muted-foreground">
|
||||
<label htmlFor="showSubtitle" className="text-muted-foreground text-xs">
|
||||
서브타이틀 표시
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -605,7 +620,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="showDescription" className="text-xs text-muted-foreground">
|
||||
<label htmlFor="showDescription" className="text-muted-foreground text-xs">
|
||||
설명 표시
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -620,14 +635,14 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="showImage" className="text-xs text-muted-foreground">
|
||||
<label htmlFor="showImage" className="text-muted-foreground text-xs">
|
||||
이미지 표시
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">설명 최대 길이</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">설명 최대 길이</label>
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
|
|
@ -660,7 +675,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">너비</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">너비</label>
|
||||
<input
|
||||
type="text"
|
||||
value={zone.size?.width || "100%"}
|
||||
|
|
@ -672,7 +687,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">높이</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">높이</label>
|
||||
<input
|
||||
type="text"
|
||||
value={zone.size?.height || "auto"}
|
||||
|
|
@ -699,25 +714,25 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
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(),
|
||||
// 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(),
|
||||
// });
|
||||
// console.log("🎨 selectedComponent 전체:", selectedComponent);
|
||||
|
||||
const handleConfigChange = (newConfig: WebTypeConfig) => {
|
||||
// console.log("🔧 WebTypeConfig 업데이트:", {
|
||||
// widgetType: widget.widgetType,
|
||||
// oldConfig: currentConfig,
|
||||
// newConfig,
|
||||
// componentId: widget.id,
|
||||
// isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig),
|
||||
// widgetType: widget.widgetType,
|
||||
// oldConfig: currentConfig,
|
||||
// newConfig,
|
||||
// componentId: widget.id,
|
||||
// isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig),
|
||||
// });
|
||||
|
||||
// 강제 새 객체 생성으로 React 변경 감지 보장
|
||||
|
|
@ -738,40 +753,40 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
// console.log(`🎨 ✅ ConfigPanelComponent 렌더링 시작`);
|
||||
return <ConfigPanelComponent config={currentConfig} onConfigChange={handleConfigChange} />;
|
||||
} else {
|
||||
// console.log(`🎨 ❌ ConfigPanelComponent가 null - 기본 설정 표시`);
|
||||
// console.log(`🎨 ❌ ConfigPanelComponent가 null - WebTypeConfigPanel 사용`);
|
||||
return (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
⚙️ 기본 설정
|
||||
<br />
|
||||
웹타입 "{widget.widgetType}"은 추가 설정이 없습니다.
|
||||
</div>
|
||||
<WebTypeConfigPanel
|
||||
webType={widget.widgetType as any}
|
||||
config={currentConfig}
|
||||
onUpdateConfig={handleConfigChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// console.log(`🎨 config_panel이 없음 - 기본 설정 표시`);
|
||||
// console.log(`🎨 config_panel이 없음 - WebTypeConfigPanel 사용`);
|
||||
return (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
⚙️ 기본 설정
|
||||
<br />
|
||||
웹타입 "{widget.widgetType}"은 추가 설정이 없습니다.
|
||||
</div>
|
||||
<WebTypeConfigPanel
|
||||
webType={widget.widgetType as any}
|
||||
config={currentConfig}
|
||||
onUpdateConfig={handleConfigChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-orange-50/30 border-r border-gray-200/60 shadow-sm">
|
||||
<div className="flex h-full flex-col border-r border-gray-200/60 bg-gradient-to-br from-slate-50 to-orange-50/30 shadow-sm">
|
||||
<div className="p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">상세 설정</h2>
|
||||
<h2 className="mb-1 text-lg font-semibold text-gray-900">상세 설정</h2>
|
||||
<p className="text-sm text-gray-500">컴포넌트를 선택하여 상세 설정을 편집하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 빈 상태 */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
<div className="flex flex-1 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>
|
||||
|
|
@ -847,9 +862,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
// 새로운 컴포넌트 타입들에 대한 설정 패널 확인
|
||||
const componentType = selectedComponent?.componentConfig?.type || selectedComponent?.type;
|
||||
// console.log("🔍 DetailSettingsPanel componentType 확인:", {
|
||||
// selectedComponentType: selectedComponent?.type,
|
||||
// componentConfigType: selectedComponent?.componentConfig?.type,
|
||||
// finalComponentType: componentType,
|
||||
// selectedComponentType: selectedComponent?.type,
|
||||
// componentConfigType: selectedComponent?.componentConfig?.type,
|
||||
// finalComponentType: componentType,
|
||||
// });
|
||||
|
||||
const hasNewConfigPanel =
|
||||
|
|
@ -880,11 +895,11 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<Settings className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="font-medium text-gray-900">컴포넌트 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">타입:</span>
|
||||
<span className="text-muted-foreground text-sm">타입:</span>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">{componentType}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -928,15 +943,17 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<Settings className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="font-medium text-gray-900">파일 컴포넌트 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">타입:</span>
|
||||
<span className="text-muted-foreground text-sm">타입:</span>
|
||||
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800">파일 업로드</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{selectedComponent.type === "widget" ? `위젯타입: ${selectedComponent.widgetType}` : `문서 타입: ${fileComponent.fileConfig?.docTypeName || "일반 문서"}`}
|
||||
{selectedComponent.type === "widget"
|
||||
? `위젯타입: ${selectedComponent.widgetType}`
|
||||
: `문서 타입: ${fileComponent.fileConfig?.docTypeName || "일반 문서"}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -993,65 +1010,126 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 현재 웹타입의 기본 입력 타입 추출
|
||||
const currentBaseInputType = webType ? getBaseInputType(webType as any) : null;
|
||||
|
||||
// 선택 가능한 세부 타입 목록
|
||||
const availableDetailTypes = currentBaseInputType ? getDetailTypes(currentBaseInputType) : [];
|
||||
|
||||
// 세부 타입 변경 핸들러
|
||||
const handleDetailTypeChange = (newDetailType: string) => {
|
||||
setLocalComponentDetailType(newDetailType);
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.webType", newDetailType);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-orange-50/30 border-r border-gray-200/60 shadow-sm">
|
||||
<div className="flex h-full flex-col border-r border-gray-200/60 bg-gradient-to-br from-slate-50 to-orange-50/30 shadow-sm">
|
||||
<div className="p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">상세 설정</h2>
|
||||
<h2 className="mb-1 text-lg font-semibold text-gray-900">상세 설정</h2>
|
||||
<p className="text-sm text-gray-500">선택한 컴포넌트의 속성을 편집하세요</p>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 정보 */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">컴포넌트:</span>
|
||||
<span className="text-muted-foreground text-sm">컴포넌트:</span>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">{componentId}</span>
|
||||
</div>
|
||||
{webType && (
|
||||
{webType && currentBaseInputType && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">웹타입:</span>
|
||||
<span className="rounded bg-primary/20 px-2 py-1 text-xs font-medium text-primary">{webType}</span>
|
||||
<span className="text-muted-foreground text-sm">입력 타입:</span>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
{currentBaseInputType}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedComponent.columnName && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">컬럼:</span>
|
||||
<span className="text-muted-foreground text-sm">컬럼:</span>
|
||||
<span className="text-xs text-gray-700">{selectedComponent.columnName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 세부 타입 선택 영역 */}
|
||||
{webType && availableDetailTypes.length > 1 && (
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-6 pt-0">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">세부 타입 선택</label>
|
||||
<Select value={localComponentDetailType || webType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue placeholder="세부 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDetailTypes.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">{option.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
입력 타입 "{currentBaseInputType}"에 사용할 구체적인 형태를 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컴포넌트 설정 패널 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<DynamicComponentConfigPanel
|
||||
componentId={componentId}
|
||||
config={(() => {
|
||||
const config = selectedComponent.componentConfig || {};
|
||||
// console.log("🔍 DetailSettingsPanel에서 전달하는 config:", config);
|
||||
// console.log("🔍 selectedComponent 전체:", selectedComponent);
|
||||
return config;
|
||||
})()}
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||
tableColumns={(() => {
|
||||
// console.log("🔍 DetailSettingsPanel tableColumns 전달:", {
|
||||
<div className="space-y-6">
|
||||
{/* DynamicComponentConfigPanel */}
|
||||
<DynamicComponentConfigPanel
|
||||
componentId={componentId}
|
||||
config={(() => {
|
||||
const config = selectedComponent.componentConfig || {};
|
||||
// console.log("🔍 DetailSettingsPanel에서 전달하는 config:", config);
|
||||
// console.log("🔍 selectedComponent 전체:", selectedComponent);
|
||||
return config;
|
||||
})()}
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||
tableColumns={(() => {
|
||||
// console.log("🔍 DetailSettingsPanel tableColumns 전달:", {
|
||||
// currentTable,
|
||||
// columns: currentTable?.columns,
|
||||
// columnsLength: currentTable?.columns?.length,
|
||||
// sampleColumn: currentTable?.columns?.[0],
|
||||
// deptCodeColumn: currentTable?.columns?.find((col) => col.columnName === "dept_code"),
|
||||
// });
|
||||
return currentTable?.columns || [];
|
||||
})()}
|
||||
onChange={(newConfig) => {
|
||||
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
// });
|
||||
return currentTable?.columns || [];
|
||||
})()}
|
||||
onChange={(newConfig) => {
|
||||
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 웹타입별 특화 설정 */}
|
||||
{webType && (
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="mb-4 text-sm font-semibold text-gray-900">웹타입 설정</h4>
|
||||
<WebTypeConfigPanel
|
||||
webType={webType as any}
|
||||
config={selectedComponent.componentConfig || {}}
|
||||
onUpdateConfig={(newConfig) => {
|
||||
// 기존 설정과 병합하여 업데이트
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1060,21 +1138,71 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
// 기존 위젯 시스템 처리 (type: "widget")
|
||||
const widget = selectedComponent as WidgetComponent;
|
||||
|
||||
// 현재 웹타입의 기본 입력 타입 추출
|
||||
const currentBaseInputType = getBaseInputType(widget.widgetType);
|
||||
|
||||
// 선택 가능한 세부 타입 목록
|
||||
const availableDetailTypes = getDetailTypes(currentBaseInputType);
|
||||
|
||||
// 로컬 상태: 세부 타입 선택
|
||||
const [localDetailType, setLocalDetailType] = useState(widget.widgetType);
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setLocalDetailType(widget.widgetType);
|
||||
}, [widget.widgetType, widget.id]);
|
||||
|
||||
// 세부 타입 변경 핸들러
|
||||
const handleDetailTypeChange = (newDetailType: string) => {
|
||||
setLocalDetailType(newDetailType);
|
||||
onUpdateProperty(widget.id, "widgetType", newDetailType);
|
||||
|
||||
// 웹타입 변경 시 기존 설정 초기화 (선택적)
|
||||
// onUpdateProperty(widget.id, "webTypeConfig", {});
|
||||
};
|
||||
|
||||
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-muted-foreground" />
|
||||
<Settings className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="font-medium text-gray-900">상세 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">웹타입:</span>
|
||||
<span className="rounded bg-primary/20 px-2 py-1 text-xs font-medium text-primary">{widget.widgetType}</span>
|
||||
<span className="text-muted-foreground text-sm">입력 타입:</span>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
{currentBaseInputType}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">컬럼: {widget.columnName}</div>
|
||||
</div>
|
||||
|
||||
{/* 세부 타입 선택 영역 */}
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">세부 타입 선택</label>
|
||||
<Select value={localDetailType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue placeholder="세부 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDetailTypes.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">{option.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
입력 타입 "{currentBaseInputType}"에 사용할 구체적인 형태를 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 설정 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">{renderWebTypeConfig(widget)}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ import { ColumnSpanPreset, COLUMN_SPAN_PRESETS, COLUMN_SPAN_VALUES } from "@/lib
|
|||
import { cn } from "@/lib/utils";
|
||||
import DataTableConfigPanel from "./DataTableConfigPanel";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import {
|
||||
BaseInputType,
|
||||
BASE_INPUT_TYPE_OPTIONS,
|
||||
getBaseInputType,
|
||||
getDefaultDetailType,
|
||||
} from "@/types/input-type-mapping";
|
||||
|
||||
// DataTableConfigPanel을 위한 안정화된 래퍼 컴포넌트
|
||||
const DataTableConfigPanelWrapper: React.FC<{
|
||||
|
|
@ -197,14 +203,6 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
positionZ: selectedComponent?.position.z?.toString() || "1",
|
||||
width: selectedComponent?.size?.width?.toString() || "0",
|
||||
height: selectedComponent?.size?.height?.toString() || "0",
|
||||
gridColumns:
|
||||
selectedComponent?.gridColumns?.toString() ||
|
||||
(selectedComponent?.type === "layout" && (selectedComponent as any)?.layoutType === "card-layout"
|
||||
? "8"
|
||||
: selectedComponent?.type === "component" &&
|
||||
(selectedComponent as any)?.componentConfig?.type === "card-display"
|
||||
? "8"
|
||||
: "1"),
|
||||
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
|
||||
labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
|
||||
labelColor: selectedComponent?.style?.labelColor || "#212121",
|
||||
|
|
@ -217,6 +215,90 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
(selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).widgetType : "text") || "text",
|
||||
});
|
||||
|
||||
// 너비 드롭다운 로컬 상태 - 실시간 업데이트를 위한 별도 관리
|
||||
const calculateWidthSpan = (width: string | number | undefined, gridColumns?: number): string => {
|
||||
// gridColumns 값이 있으면 우선 사용
|
||||
if (gridColumns) {
|
||||
const columnsToSpan: Record<number, string> = {
|
||||
1: "twelfth", // 1/12
|
||||
2: "small", // 2/12
|
||||
3: "quarter", // 3/12
|
||||
4: "third", // 4/12
|
||||
5: "five-twelfths", // 5/12
|
||||
6: "half", // 6/12
|
||||
7: "seven-twelfths", // 7/12
|
||||
8: "twoThirds", // 8/12
|
||||
9: "threeQuarters", // 9/12
|
||||
10: "five-sixths", // 10/12
|
||||
11: "eleven-twelfths", // 11/12
|
||||
12: "full", // 12/12
|
||||
};
|
||||
|
||||
const span = columnsToSpan[gridColumns];
|
||||
if (span) {
|
||||
console.log("🎯 calculateWidthSpan - gridColumns 사용:", { gridColumns, span });
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
// gridColumns가 없으면 style.width에서 계산
|
||||
if (!width) return "half";
|
||||
|
||||
if (typeof width === "string" && width.includes("%")) {
|
||||
const percent = parseFloat(width);
|
||||
|
||||
// 정확한 매핑을 위해 가장 가까운 값 찾기
|
||||
// 중복 제거: small(작게) 사용, third(1/3) 사용, twoThirds(2/3) 사용, quarter(1/4) 사용, threeQuarters(3/4) 사용
|
||||
const percentToSpan: Record<number, string> = {
|
||||
100: "full", // 12/12
|
||||
91.666667: "eleven-twelfths", // 11/12
|
||||
83.333333: "five-sixths", // 10/12
|
||||
75: "threeQuarters", // 9/12
|
||||
66.666667: "twoThirds", // 8/12
|
||||
58.333333: "seven-twelfths", // 7/12
|
||||
50: "half", // 6/12
|
||||
41.666667: "five-twelfths", // 5/12
|
||||
33.333333: "third", // 4/12
|
||||
25: "quarter", // 3/12
|
||||
16.666667: "small", // 2/12
|
||||
8.333333: "twelfth", // 1/12
|
||||
};
|
||||
|
||||
// 가장 가까운 퍼센트 값 찾기 (오차 범위 ±2% 허용)
|
||||
let closestSpan = "half";
|
||||
let minDiff = Infinity;
|
||||
|
||||
for (const [key, span] of Object.entries(percentToSpan)) {
|
||||
const diff = Math.abs(percent - parseFloat(key));
|
||||
if (diff < minDiff && diff < 5) {
|
||||
// 5% 오차 범위 내
|
||||
minDiff = diff;
|
||||
closestSpan = span;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🎯 calculateWidthSpan - width% 사용:", { width, percent, closestSpan });
|
||||
return closestSpan;
|
||||
}
|
||||
|
||||
return "half";
|
||||
};
|
||||
|
||||
const [localWidthSpan, setLocalWidthSpan] = useState<string>(() =>
|
||||
calculateWidthSpan(selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns),
|
||||
);
|
||||
|
||||
// 컴포넌트 또는 style.width, gridColumns가 변경될 때 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
const newSpan = calculateWidthSpan(selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns);
|
||||
setLocalWidthSpan(newSpan);
|
||||
console.log("🔄 localWidthSpan 업데이트:", {
|
||||
gridColumns: (selectedComponent as any)?.gridColumns,
|
||||
width: selectedComponent?.style?.width,
|
||||
newSpan,
|
||||
});
|
||||
}, [selectedComponent?.id, selectedComponent?.style?.width, (selectedComponent as any)?.gridColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedComponentRef.current = selectedComponent;
|
||||
onUpdatePropertyRef.current = onUpdateProperty;
|
||||
|
|
@ -518,24 +600,27 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="widgetType" className="text-sm font-medium">
|
||||
위젯 타입
|
||||
<Label htmlFor="inputType" className="text-sm font-medium">
|
||||
입력 타입
|
||||
</Label>
|
||||
<select
|
||||
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={localInputs.widgetType}
|
||||
value={getBaseInputType(localInputs.widgetType)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as WebType;
|
||||
setLocalInputs((prev) => ({ ...prev, widgetType: value }));
|
||||
onUpdateProperty("widgetType", value);
|
||||
const selectedInputType = e.target.value as BaseInputType;
|
||||
// 입력 타입에 맞는 기본 세부 타입 설정
|
||||
const defaultWebType = getDefaultDetailType(selectedInputType);
|
||||
setLocalInputs((prev) => ({ ...prev, widgetType: defaultWebType }));
|
||||
onUpdateProperty("widgetType", defaultWebType);
|
||||
}}
|
||||
>
|
||||
{webTypeOptions.map((option) => (
|
||||
{BASE_INPUT_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{option.label} - {option.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">세부 타입은 "상세 설정" 패널에서 선택하세요</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -667,13 +752,75 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
|
||||
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
|
||||
<>
|
||||
{/* 🆕 컬럼 스팬 선택 (width 대체) */}
|
||||
{/* 🆕 컬럼 스팬 선택 (width를 퍼센트로 변환) - 기존 UI 유지 */}
|
||||
<div className="col-span-2">
|
||||
<Label className="text-sm font-medium">컴포넌트 너비</Label>
|
||||
<Select
|
||||
value={selectedComponent.gridColumnSpan || "half"}
|
||||
value={localWidthSpan}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("gridColumnSpan", value as ColumnSpanPreset);
|
||||
// 컬럼 스팬을 퍼센트로 변환
|
||||
const percentages: Record<string, string> = {
|
||||
// 표준 옵션 (드롭다운에 표시됨)
|
||||
twelfth: "8.333333%", // 1/12
|
||||
small: "16.666667%", // 2/12 (작게)
|
||||
quarter: "25%", // 3/12 (1/4)
|
||||
third: "33.333333%", // 4/12 (1/3)
|
||||
"five-twelfths": "41.666667%", // 5/12
|
||||
half: "50%", // 6/12 (절반)
|
||||
"seven-twelfths": "58.333333%", // 7/12
|
||||
twoThirds: "66.666667%", // 8/12 (2/3)
|
||||
threeQuarters: "75%", // 9/12 (3/4)
|
||||
"five-sixths": "83.333333%", // 10/12
|
||||
"eleven-twelfths": "91.666667%", // 11/12
|
||||
full: "100%", // 12/12 (전체)
|
||||
|
||||
// 레거시 호환성 (드롭다운에는 없지만 기존 데이터 지원)
|
||||
sixth: "16.666667%", // 2/12 (= small)
|
||||
label: "25%", // 3/12 (= quarter)
|
||||
medium: "33.333333%", // 4/12 (= third)
|
||||
large: "66.666667%", // 8/12 (= twoThirds)
|
||||
input: "75%", // 9/12 (= threeQuarters)
|
||||
"two-thirds": "66.666667%", // 케밥케이스 호환
|
||||
"three-quarters": "75%", // 케밥케이스 호환
|
||||
};
|
||||
|
||||
const newWidth = percentages[value] || "50%";
|
||||
|
||||
// 로컬 상태 즉시 업데이트
|
||||
setLocalWidthSpan(value);
|
||||
|
||||
// 컴포넌트 속성 업데이트
|
||||
onUpdateProperty("style.width", newWidth);
|
||||
|
||||
// gridColumns도 자동 계산하여 업데이트 (1/12 = 1컬럼, 2/12 = 2컬럼, ...)
|
||||
const columnsMap: Record<string, number> = {
|
||||
twelfth: 1,
|
||||
small: 2,
|
||||
quarter: 3,
|
||||
third: 4,
|
||||
"five-twelfths": 5,
|
||||
half: 6,
|
||||
"seven-twelfths": 7,
|
||||
twoThirds: 8,
|
||||
threeQuarters: 9,
|
||||
"five-sixths": 10,
|
||||
"eleven-twelfths": 11,
|
||||
full: 12,
|
||||
// 레거시 호환
|
||||
sixth: 2,
|
||||
label: 3,
|
||||
medium: 4,
|
||||
large: 8,
|
||||
input: 9,
|
||||
"two-thirds": 8,
|
||||
"three-quarters": 9,
|
||||
fiveTwelfths: 5,
|
||||
sevenTwelfths: 7,
|
||||
fiveSixths: 10,
|
||||
elevenTwelfths: 11,
|
||||
};
|
||||
const gridColumns = columnsMap[value] || 6;
|
||||
onUpdateProperty("gridColumns", gridColumns);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
|
|
@ -681,7 +828,12 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(COLUMN_SPAN_PRESETS)
|
||||
.filter(([key]) => key !== "auto")
|
||||
.filter(([key]) => {
|
||||
// auto 제거 및 중복 퍼센트 옵션 제거
|
||||
// 제거할 옵션: auto, label(=quarter), input(=threeQuarters), medium(=third), large(=twoThirds)
|
||||
const excludeKeys = ["auto", "label", "input", "medium", "large"];
|
||||
return !excludeKeys.includes(key);
|
||||
})
|
||||
.map(([key, info]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
|
|
@ -695,14 +847,39 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 시각적 프리뷰 */}
|
||||
{/* 시각적 프리뷰 - 기존 UI 유지, localWidthSpan 기반 */}
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label className="text-xs text-gray-500">미리보기</Label>
|
||||
<div className="grid h-6 grid-cols-12 gap-0.5 overflow-hidden rounded border">
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const spanValue = COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"];
|
||||
const startCol = selectedComponent.gridColumnStart || 1;
|
||||
const isActive = i + 1 >= startCol && i + 1 < startCol + spanValue;
|
||||
// localWidthSpan으로부터 활성 컬럼 계산
|
||||
const spanValues: Record<string, number> = {
|
||||
// 표준 옵션
|
||||
twelfth: 1,
|
||||
small: 2,
|
||||
quarter: 3,
|
||||
third: 4,
|
||||
"five-twelfths": 5,
|
||||
half: 6,
|
||||
"seven-twelfths": 7,
|
||||
twoThirds: 8,
|
||||
threeQuarters: 9,
|
||||
"five-sixths": 10,
|
||||
"eleven-twelfths": 11,
|
||||
full: 12,
|
||||
|
||||
// 레거시 호환성
|
||||
sixth: 2,
|
||||
label: 3,
|
||||
medium: 4,
|
||||
large: 8,
|
||||
input: 9,
|
||||
"two-thirds": 8,
|
||||
"three-quarters": 9,
|
||||
};
|
||||
|
||||
const spanValue = spanValues[localWidthSpan] || 6;
|
||||
const isActive = i < spanValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -713,60 +890,62 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
})}
|
||||
</div>
|
||||
<p className="text-center text-xs text-gray-500">
|
||||
{COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"]} / 12 컬럼
|
||||
{(() => {
|
||||
const spanValues: Record<string, number> = {
|
||||
// 표준 옵션
|
||||
twelfth: 1,
|
||||
small: 2,
|
||||
quarter: 3,
|
||||
third: 4,
|
||||
"five-twelfths": 5,
|
||||
half: 6,
|
||||
"seven-twelfths": 7,
|
||||
twoThirds: 8,
|
||||
threeQuarters: 9,
|
||||
"five-sixths": 10,
|
||||
"eleven-twelfths": 11,
|
||||
full: 12,
|
||||
|
||||
// 레거시 호환성
|
||||
sixth: 2,
|
||||
label: 3,
|
||||
medium: 4,
|
||||
large: 8,
|
||||
input: 9,
|
||||
"two-thirds": 8,
|
||||
"three-quarters": 9,
|
||||
};
|
||||
const cols = spanValues[localWidthSpan] || 6;
|
||||
return `${cols} / 12 컬럼`;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 고급 설정 */}
|
||||
<Collapsible className="mt-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-between">
|
||||
<span className="text-xs">고급 설정</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">시작 컬럼 위치</Label>
|
||||
<Select
|
||||
value={selectedComponent.gridColumnStart?.toString() || "auto"}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("gridColumnStart", value === "auto" ? undefined : parseInt(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동</SelectItem>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<SelectItem key={i + 1} value={(i + 1).toString()}>
|
||||
{i + 1}번 컬럼부터
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">"자동"을 선택하면 이전 컴포넌트 다음에 배치됩니다</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="col-span-2">
|
||||
<Label htmlFor="height" className="text-sm font-medium">
|
||||
높이 (px)
|
||||
높이 (40px 단위)
|
||||
</Label>
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
value={localInputs.height}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, height: newValue }));
|
||||
onUpdateProperty("size.height", Number(newValue));
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={Math.round((localInputs.height || 40) / 40)}
|
||||
onChange={(e) => {
|
||||
const rows = Math.max(1, Math.min(20, Number(e.target.value)));
|
||||
const newHeight = rows * 40;
|
||||
setLocalInputs((prev) => ({ ...prev, height: newHeight.toString() }));
|
||||
onUpdateProperty("size.height", newHeight);
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">행 = {localInputs.height || 40}px</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
1행 = 40px (현재 {Math.round((localInputs.height || 40) / 40)}행)
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -795,32 +974,6 @@ const PropertiesPanelComponent: 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,733 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
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 { Separator } from "@/components/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown, Settings, Info, Database, Trash2, Copy } from "lucide-react";
|
||||
import {
|
||||
ComponentData,
|
||||
WebType,
|
||||
WidgetComponent,
|
||||
GroupComponent,
|
||||
DataTableComponent,
|
||||
TableInfo,
|
||||
LayoutComponent,
|
||||
FileComponent,
|
||||
AreaComponent,
|
||||
} from "@/types/screen";
|
||||
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS } from "@/lib/constants/columnSpans";
|
||||
|
||||
// 컬럼 스팬 숫자 배열 (1~12)
|
||||
const COLUMN_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
import { cn } from "@/lib/utils";
|
||||
import DataTableConfigPanel from "./DataTableConfigPanel";
|
||||
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import {
|
||||
BaseInputType,
|
||||
BASE_INPUT_TYPE_OPTIONS,
|
||||
getBaseInputType,
|
||||
getDefaultDetailType,
|
||||
getDetailTypes,
|
||||
DetailTypeOption,
|
||||
} from "@/types/input-type-mapping";
|
||||
|
||||
// 새로운 컴포넌트 설정 패널들
|
||||
import { ButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
||||
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
|
||||
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
|
||||
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
||||
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
|
||||
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
|
||||
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
||||
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
||||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||
|
||||
interface UnifiedPropertiesPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
tables: TableInfo[];
|
||||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||
onDeleteComponent?: (componentId: string) => void;
|
||||
onCopyComponent?: (componentId: string) => void;
|
||||
currentTable?: TableInfo;
|
||||
currentTableName?: string;
|
||||
dragState?: any;
|
||||
}
|
||||
|
||||
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||
selectedComponent,
|
||||
tables,
|
||||
onUpdateProperty,
|
||||
onDeleteComponent,
|
||||
onCopyComponent,
|
||||
currentTable,
|
||||
currentTableName,
|
||||
dragState,
|
||||
}) => {
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
||||
|
||||
// 새로운 컴포넌트 시스템의 webType 동기화
|
||||
useEffect(() => {
|
||||
if (selectedComponent?.type === "component") {
|
||||
const webType = selectedComponent.componentConfig?.webType;
|
||||
if (webType) {
|
||||
setLocalComponentDetailType(webType);
|
||||
}
|
||||
}
|
||||
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
|
||||
|
||||
// 컴포넌트가 선택되지 않았을 때
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">컴포넌트를 선택하여</p>
|
||||
<p className="text-sm text-gray-500">속성을 편집하세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleUpdate = (path: string, value: any) => {
|
||||
onUpdateProperty(selectedComponent.id, path, value);
|
||||
};
|
||||
|
||||
// 드래그 중일 때 실시간 위치 표시
|
||||
const currentPosition =
|
||||
dragState?.isDragging && dragState?.draggedComponent?.id === selectedComponent.id
|
||||
? dragState.currentPosition
|
||||
: selectedComponent.position;
|
||||
|
||||
// 컴포넌트별 설정 패널 렌더링 함수 (DetailSettingsPanel의 로직)
|
||||
const renderComponentConfigPanel = () => {
|
||||
if (!selectedComponent) return null;
|
||||
|
||||
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
|
||||
|
||||
const handleUpdateProperty = (path: string, value: any) => {
|
||||
onUpdateProperty(selectedComponent.id, path, value);
|
||||
};
|
||||
|
||||
switch (componentType) {
|
||||
case "button":
|
||||
case "button-primary":
|
||||
case "button-secondary":
|
||||
return <ButtonConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||
|
||||
case "card":
|
||||
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||
|
||||
case "dashboard":
|
||||
return <DashboardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||
|
||||
case "stats":
|
||||
case "stats-card":
|
||||
return <StatsCardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||
|
||||
case "progress":
|
||||
case "progress-bar":
|
||||
return <ProgressBarConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||
|
||||
case "chart":
|
||||
case "chart-basic":
|
||||
return <ChartConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||
|
||||
case "alert":
|
||||
case "alert-info":
|
||||
return <AlertConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||
|
||||
case "badge":
|
||||
case "badge-status":
|
||||
return <BadgeConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 기본 정보 탭
|
||||
const renderBasicTab = () => {
|
||||
const widget = selectedComponent as WidgetComponent;
|
||||
const group = selectedComponent as GroupComponent;
|
||||
const area = selectedComponent as AreaComponent;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 컴포넌트 정보 */}
|
||||
<div className="rounded-lg bg-slate-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Info className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-700">컴포넌트 정보</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selectedComponent.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-slate-600">
|
||||
<div>ID: {selectedComponent.id}</div>
|
||||
{widget.widgetType && <div>위젯: {widget.widgetType}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
<div>
|
||||
<Label>라벨</Label>
|
||||
<Input
|
||||
value={widget.label || ""}
|
||||
onChange={(e) => handleUpdate("label", e.target.value)}
|
||||
placeholder="컴포넌트 라벨"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Placeholder (widget만) */}
|
||||
{selectedComponent.type === "widget" && (
|
||||
<div>
|
||||
<Label>Placeholder</Label>
|
||||
<Input
|
||||
value={widget.placeholder || ""}
|
||||
onChange={(e) => handleUpdate("placeholder", e.target.value)}
|
||||
placeholder="입력 안내 텍스트"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (group/area) */}
|
||||
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
|
||||
<div>
|
||||
<Label>제목</Label>
|
||||
<Input
|
||||
value={group.title || area.title || ""}
|
||||
onChange={(e) => handleUpdate("title", e.target.value)}
|
||||
placeholder="제목"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (area만) */}
|
||||
{selectedComponent.type === "area" && (
|
||||
<div>
|
||||
<Label>설명</Label>
|
||||
<Input
|
||||
value={area.description || ""}
|
||||
onChange={(e) => handleUpdate("description", e.target.value)}
|
||||
placeholder="설명"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 크기 */}
|
||||
<div>
|
||||
<Label>높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.size?.height || 0}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
// 40 단위로 반올림
|
||||
const roundedValue = Math.max(40, Math.round(value / 40) * 40);
|
||||
handleUpdate("size.height", roundedValue);
|
||||
}}
|
||||
step={40}
|
||||
placeholder="40 단위로 입력"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">40 단위로 자동 조정됩니다</p>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 스팬 */}
|
||||
{widget.columnSpan !== undefined && (
|
||||
<div>
|
||||
<Label>컬럼 스팬</Label>
|
||||
<Select
|
||||
value={widget.columnSpan?.toString() || "12"}
|
||||
onValueChange={(value) => handleUpdate("columnSpan", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLUMN_NUMBERS.map((span) => (
|
||||
<SelectItem key={span} value={span.toString()}>
|
||||
{span} 컬럼 ({Math.round((span / 12) * 100)}%)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid Columns */}
|
||||
{(selectedComponent as any).gridColumns !== undefined && (
|
||||
<div>
|
||||
<Label>Grid Columns</Label>
|
||||
<Select
|
||||
value={((selectedComponent as any).gridColumns || 12).toString()}
|
||||
onValueChange={(value) => handleUpdate("gridColumns", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLUMN_NUMBERS.map((span) => (
|
||||
<SelectItem key={span} value={span.toString()}>
|
||||
{span} 컬럼 ({Math.round((span / 12) * 100)}%)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 위치 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label>X {dragState?.isDragging && <Badge variant="secondary">드래그중</Badge>}</Label>
|
||||
<Input type="number" value={Math.round(currentPosition.x || 0)} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Y</Label>
|
||||
<Input type="number" value={Math.round(currentPosition.y || 0)} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Z</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentPosition.z || 1}
|
||||
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 라벨 스타일 */}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg bg-slate-50 p-2 text-sm font-medium hover:bg-slate-100">
|
||||
라벨 스타일
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
<div>
|
||||
<Label>라벨 텍스트</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelText || selectedComponent.label || ""}
|
||||
onChange={(e) => handleUpdate("style.labelText", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>폰트 크기</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelFontSize || "12px"}
|
||||
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.style?.labelColor || "#212121"}
|
||||
onChange={(e) => handleUpdate("style.labelColor", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>하단 여백</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelMarginBottom || "4px"}
|
||||
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedComponent.style?.labelDisplay !== false}
|
||||
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)}
|
||||
/>
|
||||
<Label>라벨 표시</Label>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={widget.visible !== false}
|
||||
onCheckedChange={(checked) => handleUpdate("visible", checked)}
|
||||
/>
|
||||
<Label>표시</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={widget.disabled === true}
|
||||
onCheckedChange={(checked) => handleUpdate("disabled", checked)}
|
||||
/>
|
||||
<Label>비활성화</Label>
|
||||
</div>
|
||||
{widget.required !== undefined && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={widget.required === true}
|
||||
onCheckedChange={(checked) => handleUpdate("required", checked)}
|
||||
/>
|
||||
<Label>필수 입력</Label>
|
||||
</div>
|
||||
)}
|
||||
{widget.readonly !== undefined && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={widget.readonly === true}
|
||||
onCheckedChange={(checked) => handleUpdate("readonly", checked)}
|
||||
/>
|
||||
<Label>읽기 전용</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<Separator />
|
||||
<div className="flex gap-2">
|
||||
{onCopyComponent && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onCopyComponent(selectedComponent.id)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
)}
|
||||
{onDeleteComponent && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDeleteComponent(selectedComponent.id)}
|
||||
className="flex-1 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합)
|
||||
const renderDetailTab = () => {
|
||||
// 1. DataTable 컴포넌트
|
||||
if (selectedComponent.type === "datatable") {
|
||||
return (
|
||||
<DataTableConfigPanel
|
||||
component={selectedComponent as DataTableComponent}
|
||||
tables={tables}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onUpdateComponent={(updates) => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
handleUpdate(key, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 파일 컴포넌트
|
||||
if (isFileComponent(selectedComponent)) {
|
||||
return (
|
||||
<FileComponentConfigPanel
|
||||
component={selectedComponent as FileComponent}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
currentTable={currentTable}
|
||||
currentTableName={currentTableName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 새로운 컴포넌트 시스템 (button, card 등)
|
||||
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
|
||||
const hasNewConfigPanel =
|
||||
componentType &&
|
||||
[
|
||||
"button",
|
||||
"button-primary",
|
||||
"button-secondary",
|
||||
"card",
|
||||
"dashboard",
|
||||
"stats",
|
||||
"stats-card",
|
||||
"progress",
|
||||
"progress-bar",
|
||||
"chart",
|
||||
"chart-basic",
|
||||
"alert",
|
||||
"alert-info",
|
||||
"badge",
|
||||
"badge-status",
|
||||
].includes(componentType);
|
||||
|
||||
if (hasNewConfigPanel) {
|
||||
const configPanel = renderComponentConfigPanel();
|
||||
if (configPanel) {
|
||||
return <div className="space-y-4">{configPanel}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 새로운 컴포넌트 시스템 (type: "component")
|
||||
if (selectedComponent.type === "component") {
|
||||
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
|
||||
const webType = selectedComponent.componentConfig?.webType;
|
||||
|
||||
if (!componentId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-gray-500">컴포넌트 ID가 설정되지 않았습니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 현재 웹타입의 기본 입력 타입 추출
|
||||
const currentBaseInputType = webType ? getBaseInputType(webType as any) : null;
|
||||
|
||||
// 선택 가능한 세부 타입 목록
|
||||
const availableDetailTypes = currentBaseInputType ? getDetailTypes(currentBaseInputType) : [];
|
||||
|
||||
// 세부 타입 변경 핸들러
|
||||
const handleDetailTypeChange = (newDetailType: string) => {
|
||||
setLocalComponentDetailType(newDetailType);
|
||||
handleUpdate("componentConfig.webType", newDetailType);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 컴포넌트 정보 */}
|
||||
<div className="rounded-lg bg-green-50 p-3">
|
||||
<span className="text-sm font-medium text-green-900">컴포넌트: {componentId}</span>
|
||||
{webType && currentBaseInputType && (
|
||||
<div className="mt-1 text-xs text-green-700">입력 타입: {currentBaseInputType}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 세부 타입 선택 */}
|
||||
{webType && availableDetailTypes.length > 1 && (
|
||||
<div>
|
||||
<Label>세부 타입 선택</Label>
|
||||
<Select value={localComponentDetailType || webType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="세부 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDetailTypes.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-xs text-gray-500">{option.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">입력 타입 "{currentBaseInputType}"의 세부 형태를 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DynamicComponentConfigPanel */}
|
||||
<DynamicComponentConfigPanel
|
||||
componentId={componentId}
|
||||
config={selectedComponent.componentConfig || {}}
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||
tableColumns={currentTable?.columns || []}
|
||||
onChange={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 웹타입별 특화 설정 */}
|
||||
{webType && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="mb-2 text-sm font-semibold">웹타입 설정</h4>
|
||||
<WebTypeConfigPanel
|
||||
webType={webType as any}
|
||||
config={selectedComponent.componentConfig || {}}
|
||||
onUpdateConfig={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Widget 컴포넌트
|
||||
if (selectedComponent.type === "widget") {
|
||||
const widget = selectedComponent as WidgetComponent;
|
||||
|
||||
// Widget에 webType이 있는 경우
|
||||
if (widget.webType) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* WebType 선택 */}
|
||||
<div>
|
||||
<Label>입력 타입</Label>
|
||||
<Select value={widget.webType} onValueChange={(value) => handleUpdate("webType", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{webTypes.map((wt) => (
|
||||
<SelectItem key={wt.web_type} value={wt.web_type}>
|
||||
{wt.web_type_name_kor || wt.web_type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* WebType 설정 패널 */}
|
||||
<WebTypeConfigPanel
|
||||
webType={widget.webType as any}
|
||||
config={widget.webTypeConfig || {}}
|
||||
onUpdateConfig={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`webTypeConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 새로운 컴포넌트 시스템 (widgetType이 button, card 등)
|
||||
if (
|
||||
widget.widgetType &&
|
||||
["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes(
|
||||
widget.widgetType,
|
||||
)
|
||||
) {
|
||||
return (
|
||||
<DynamicComponentConfigPanel
|
||||
componentId={widget.widgetType}
|
||||
config={widget.componentConfig || {}}
|
||||
screenTableName={widget.tableName || currentTable?.tableName || currentTableName}
|
||||
tableColumns={currentTable?.columns || []}
|
||||
onChange={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 메시지
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-gray-500">이 컴포넌트는 추가 설정이 없습니다</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 데이터 바인딩 탭
|
||||
const renderDataTab = () => {
|
||||
if (selectedComponent.type !== "widget") {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-gray-500">이 컴포넌트는 데이터 바인딩을 지원하지 않습니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const widget = selectedComponent as WidgetComponent;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">데이터 바인딩</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 컬럼 */}
|
||||
<div>
|
||||
<Label>테이블 컬럼</Label>
|
||||
<Input
|
||||
value={widget.columnName || ""}
|
||||
onChange={(e) => handleUpdate("columnName", e.target.value)}
|
||||
placeholder="컬럼명 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 기본값 */}
|
||||
<div>
|
||||
<Label>기본값</Label>
|
||||
<Input
|
||||
value={widget.defaultValue || ""}
|
||||
onChange={(e) => handleUpdate("defaultValue", e.target.value)}
|
||||
placeholder="기본값 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-900">속성 편집</h3>
|
||||
</div>
|
||||
<Badge variant="outline">{selectedComponent.type}</Badge>
|
||||
</div>
|
||||
{selectedComponent.type === "widget" && (
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
|
||||
<TabsList className="w-full justify-start rounded-none border-b px-4">
|
||||
<TabsTrigger value="basic">기본</TabsTrigger>
|
||||
<TabsTrigger value="detail">상세</TabsTrigger>
|
||||
<TabsTrigger value="data">데이터</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<TabsContent value="basic" className="m-0 p-4">
|
||||
{renderBasicTab()}
|
||||
</TabsContent>
|
||||
<TabsContent value="detail" className="m-0 p-4">
|
||||
{renderDetailTab()}
|
||||
</TabsContent>
|
||||
<TabsContent value="data" className="m-0 p-4">
|
||||
{renderDataTab()}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedPropertiesPanel;
|
||||
|
|
@ -0,0 +1,419 @@
|
|||
"use client";
|
||||
|
||||
import React 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 { Separator } from "@/components/ui/separator";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { WebType } from "@/types/screen";
|
||||
|
||||
interface WebTypeConfigPanelProps {
|
||||
webType: WebType | string;
|
||||
config: any;
|
||||
onUpdateConfig: (config: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입별 특화 설정 패널
|
||||
*/
|
||||
export const WebTypeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ webType, config, onUpdateConfig }) => {
|
||||
// webType을 소문자로 변환하고 기본 타입 추출
|
||||
const normalizedWebType = String(webType || "").toLowerCase();
|
||||
|
||||
// 기본 타입 추출 (예: "radio-horizontal" -> "radio", "checkbox-basic" -> "checkbox")
|
||||
const getBaseType = (type: string): string => {
|
||||
if (type.startsWith("radio")) return "radio";
|
||||
if (type.startsWith("checkbox")) return "checkbox";
|
||||
if (type.startsWith("select")) return "select";
|
||||
if (type.startsWith("number")) return "number";
|
||||
if (type.startsWith("text")) return "text";
|
||||
if (type.startsWith("date")) return "date";
|
||||
if (type.startsWith("file")) return "file";
|
||||
if (type.startsWith("image")) return "image";
|
||||
return type;
|
||||
};
|
||||
|
||||
const baseType = getBaseType(normalizedWebType);
|
||||
|
||||
console.log("🎨 WebTypeConfigPanel:", { webType, normalizedWebType, baseType });
|
||||
|
||||
// 선택형 입력 (select, radio, checkbox) - 옵션 관리
|
||||
if (baseType === "select" || baseType === "radio" || baseType === "checkbox") {
|
||||
const options = config?.options || [];
|
||||
const multiple = config?.multiple || false;
|
||||
const searchable = config?.searchable || false;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">옵션 관리</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newOptions = [
|
||||
...options,
|
||||
{ label: `옵션 ${options.length + 1}`, value: `option${options.length + 1}` },
|
||||
];
|
||||
onUpdateConfig({ ...config, options: newOptions });
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
옵션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 옵션 리스트 */}
|
||||
<div className="space-y-2">
|
||||
{options.map((option: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="라벨"
|
||||
value={option.label || ""}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...options];
|
||||
newOptions[index] = { ...option, label: e.target.value };
|
||||
onUpdateConfig({ ...config, options: newOptions });
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="값"
|
||||
value={option.value || ""}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...options];
|
||||
newOptions[index] = { ...option, value: e.target.value };
|
||||
onUpdateConfig({ ...config, options: newOptions });
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newOptions = options.filter((_: any, i: number) => i !== index);
|
||||
onUpdateConfig({ ...config, options: newOptions });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Select 전용 설정 */}
|
||||
{baseType === "select" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="multiple" className="text-sm">
|
||||
다중 선택
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="multiple"
|
||||
checked={multiple}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdateConfig({ ...config, multiple: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="searchable" className="text-sm">
|
||||
검색 가능
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="searchable"
|
||||
checked={searchable}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdateConfig({ ...config, searchable: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 숫자 입력 (number, decimal) - 범위 설정
|
||||
if (baseType === "number" || baseType === "decimal") {
|
||||
const min = config?.min;
|
||||
const max = config?.max;
|
||||
const step = config?.step || (baseType === "decimal" ? 0.1 : 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium">숫자 범위 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="min" className="text-xs text-gray-500">
|
||||
최소값
|
||||
</Label>
|
||||
<Input
|
||||
id="min"
|
||||
type="number"
|
||||
placeholder="최소값"
|
||||
value={min ?? ""}
|
||||
onChange={(e) => {
|
||||
onUpdateConfig({
|
||||
...config,
|
||||
min: e.target.value ? Number(e.target.value) : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="max" className="text-xs text-gray-500">
|
||||
최대값
|
||||
</Label>
|
||||
<Input
|
||||
id="max"
|
||||
type="number"
|
||||
placeholder="최대값"
|
||||
value={max ?? ""}
|
||||
onChange={(e) => {
|
||||
onUpdateConfig({
|
||||
...config,
|
||||
max: e.target.value ? Number(e.target.value) : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="step" className="text-xs text-gray-500">
|
||||
증감 단위
|
||||
</Label>
|
||||
<Input
|
||||
id="step"
|
||||
type="number"
|
||||
step={webType === "decimal" ? 0.01 : 1}
|
||||
placeholder="증감 단위"
|
||||
value={step}
|
||||
onChange={(e) => {
|
||||
onUpdateConfig({
|
||||
...config,
|
||||
step: e.target.value ? Number(e.target.value) : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 날짜/시간 입력 - 형식 설정
|
||||
if (baseType === "date" || baseType === "datetime" || baseType === "time") {
|
||||
const format =
|
||||
config?.format || (baseType === "date" ? "YYYY-MM-DD" : baseType === "datetime" ? "YYYY-MM-DD HH:mm" : "HH:mm");
|
||||
const showTime = config?.showTime || false;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium">날짜/시간 형식 설정</Label>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="format" className="text-xs text-gray-500">
|
||||
표시 형식
|
||||
</Label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(value) => {
|
||||
onUpdateConfig({ ...config, format: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{baseType === "date" && (
|
||||
<>
|
||||
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD (2024-01-15)</SelectItem>
|
||||
<SelectItem value="YYYY/MM/DD">YYYY/MM/DD (2024/01/15)</SelectItem>
|
||||
<SelectItem value="YYYY.MM.DD">YYYY.MM.DD (2024.01.15)</SelectItem>
|
||||
<SelectItem value="DD-MM-YYYY">DD-MM-YYYY (15-01-2024)</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{baseType === "datetime" && (
|
||||
<>
|
||||
<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>
|
||||
<SelectItem value="YYYY/MM/DD HH:mm">YYYY/MM/DD HH:mm</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{baseType === "time" && (
|
||||
<>
|
||||
<SelectItem value="HH:mm">HH:mm (24시간)</SelectItem>
|
||||
<SelectItem value="HH:mm:ss">HH:mm:ss (24시간)</SelectItem>
|
||||
<SelectItem value="hh:mm A">hh:mm A (12시간)</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{baseType === "date" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showTime" className="text-sm">
|
||||
시간 입력 포함
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="showTime"
|
||||
checked={showTime}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdateConfig({ ...config, showTime: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 텍스트 입력 - 검증 규칙
|
||||
if (
|
||||
baseType === "text" ||
|
||||
baseType === "email" ||
|
||||
baseType === "tel" ||
|
||||
baseType === "url" ||
|
||||
baseType === "textarea"
|
||||
) {
|
||||
const minLength = config?.minLength;
|
||||
const maxLength = config?.maxLength;
|
||||
const pattern = config?.pattern;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium">텍스트 검증 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="minLength" className="text-xs text-gray-500">
|
||||
최소 길이
|
||||
</Label>
|
||||
<Input
|
||||
id="minLength"
|
||||
type="number"
|
||||
placeholder="최소 길이"
|
||||
value={minLength ?? ""}
|
||||
onChange={(e) => {
|
||||
onUpdateConfig({
|
||||
...config,
|
||||
minLength: e.target.value ? Number(e.target.value) : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="maxLength" className="text-xs text-gray-500">
|
||||
최대 길이
|
||||
</Label>
|
||||
<Input
|
||||
id="maxLength"
|
||||
type="number"
|
||||
placeholder="최대 길이"
|
||||
value={maxLength ?? ""}
|
||||
onChange={(e) => {
|
||||
onUpdateConfig({
|
||||
...config,
|
||||
maxLength: e.target.value ? Number(e.target.value) : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(webType === "text" || webType === "textarea") && (
|
||||
<div>
|
||||
<Label htmlFor="pattern" className="text-xs text-gray-500">
|
||||
정규식 패턴 (선택)
|
||||
</Label>
|
||||
<Input
|
||||
id="pattern"
|
||||
placeholder="예: ^[A-Za-z]+$"
|
||||
value={pattern ?? ""}
|
||||
onChange={(e) => {
|
||||
onUpdateConfig({
|
||||
...config,
|
||||
pattern: e.target.value || undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">입력값 검증에 사용할 정규식 패턴</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 파일 업로드 - 파일 타입 제한
|
||||
if (baseType === "file" || baseType === "image") {
|
||||
const accept = config?.accept || (baseType === "image" ? "image/*" : "*/*");
|
||||
const maxSize = config?.maxSize || 10;
|
||||
const multiple = config?.multiple || false;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium">파일 업로드 설정</Label>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="accept" className="text-xs text-gray-500">
|
||||
허용 파일 타입
|
||||
</Label>
|
||||
<Input
|
||||
id="accept"
|
||||
placeholder="예: .jpg,.png,.pdf"
|
||||
value={accept}
|
||||
onChange={(e) => {
|
||||
onUpdateConfig({ ...config, accept: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">쉼표로 구분된 파일 확장자 또는 MIME 타입</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="maxSize" className="text-xs text-gray-500">
|
||||
최대 파일 크기 (MB)
|
||||
</Label>
|
||||
<Input
|
||||
id="maxSize"
|
||||
type="number"
|
||||
placeholder="최대 크기"
|
||||
value={maxSize}
|
||||
onChange={(e) => {
|
||||
onUpdateConfig({
|
||||
...config,
|
||||
maxSize: e.target.value ? Number(e.target.value) : 10,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="fileMultiple" className="text-sm">
|
||||
다중 파일 선택
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="fileMultiple"
|
||||
checked={multiple}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdateConfig({ ...config, multiple: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 메시지 (설정 불필요)
|
||||
return (
|
||||
<div className="space-y-2 text-sm text-gray-500">
|
||||
<p>이 웹타입은 추가 설정이 필요하지 않습니다.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Database, Layout, Cog, Settings, Palette, Monitor } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ToolbarButton {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
shortcut: string;
|
||||
group: "source" | "editor";
|
||||
panelWidth: number;
|
||||
}
|
||||
|
||||
interface LeftUnifiedToolbarProps {
|
||||
buttons: ToolbarButton[];
|
||||
panelStates: Record<string, { isOpen: boolean }>;
|
||||
onTogglePanel: (panelId: string) => void;
|
||||
}
|
||||
|
||||
export const LeftUnifiedToolbar: React.FC<LeftUnifiedToolbarProps> = ({ buttons, panelStates, onTogglePanel }) => {
|
||||
// 그룹별로 버튼 분류
|
||||
const sourceButtons = buttons.filter((btn) => btn.group === "source");
|
||||
const editorButtons = buttons.filter((btn) => btn.group === "editor");
|
||||
|
||||
const renderButton = (button: ToolbarButton) => {
|
||||
const isActive = panelStates[button.id]?.isOpen || false;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={button.id}
|
||||
variant="ghost"
|
||||
onClick={() => onTogglePanel(button.id)}
|
||||
title={`${button.label} (${button.shortcut})`}
|
||||
className={cn(
|
||||
"flex h-14 w-14 flex-col items-center justify-center gap-1 rounded-lg transition-all duration-200",
|
||||
isActive
|
||||
? "bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg hover:from-blue-600 hover:to-blue-700"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
{button.icon}
|
||||
{isActive && <div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-white" />}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium">{button.label}</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[60px] flex-col border-r border-slate-200 bg-white">
|
||||
{/* 입력/소스 그룹 */}
|
||||
<div className="flex flex-col gap-1 border-b border-slate-200 p-1">{sourceButtons.map(renderButton)}</div>
|
||||
|
||||
{/* 편집/설정 그룹 */}
|
||||
<div className="flex flex-col gap-1 p-1">{editorButtons.map(renderButton)}</div>
|
||||
|
||||
{/* 하단 여백 */}
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 기본 버튼 설정
|
||||
export const defaultToolbarButtons: ToolbarButton[] = [
|
||||
// 입력/소스 그룹
|
||||
{
|
||||
id: "tables",
|
||||
label: "테이블",
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
shortcut: "T",
|
||||
group: "source",
|
||||
panelWidth: 380,
|
||||
},
|
||||
{
|
||||
id: "components",
|
||||
label: "컴포넌트",
|
||||
icon: <Cog className="h-5 w-5" />,
|
||||
shortcut: "C",
|
||||
group: "source",
|
||||
panelWidth: 350,
|
||||
},
|
||||
|
||||
// 편집/설정 그룹
|
||||
{
|
||||
id: "properties",
|
||||
label: "속성",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
shortcut: "P",
|
||||
group: "editor",
|
||||
panelWidth: 400,
|
||||
},
|
||||
{
|
||||
id: "styles",
|
||||
label: "스타일",
|
||||
icon: <Palette className="h-5 w-5" />,
|
||||
shortcut: "S",
|
||||
group: "editor",
|
||||
panelWidth: 360,
|
||||
},
|
||||
{
|
||||
id: "resolution",
|
||||
label: "해상도",
|
||||
icon: <Monitor className="h-5 w-5" />,
|
||||
shortcut: "E",
|
||||
group: "editor",
|
||||
panelWidth: 300,
|
||||
},
|
||||
];
|
||||
|
||||
export default LeftUnifiedToolbar;
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Database, ArrowLeft, Undo, Redo, Play, Save, Monitor } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ScreenResolution } from "@/types/screen";
|
||||
|
||||
interface SlimToolbarProps {
|
||||
screenName?: string;
|
||||
tableName?: string;
|
||||
screenResolution?: ScreenResolution;
|
||||
onBack: () => void;
|
||||
onSave: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPreview: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||
screenName,
|
||||
tableName,
|
||||
screenResolution,
|
||||
onBack,
|
||||
onSave,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPreview,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isSaving = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
|
||||
{/* 좌측: 네비게이션 및 화면 정보 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center space-x-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span>목록으로</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">{screenName || "화면 설계"}</h1>
|
||||
{tableName && (
|
||||
<div className="mt-0.5 flex items-center space-x-1">
|
||||
<Database className="h-3 w-3 text-gray-500" />
|
||||
<span className="font-mono text-xs text-gray-500">{tableName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 해상도 정보 표시 */}
|
||||
{screenResolution && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
<div className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1.5">
|
||||
<Monitor className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">{screenResolution.name}</span>
|
||||
<span className="text-xs text-blue-600">
|
||||
({screenResolution.width} × {screenResolution.height})
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 액션 버튼들 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">실행취소</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">다시실행</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onPreview} className="flex items-center space-x-2">
|
||||
<Play className="h-4 w-4" />
|
||||
<span>미리보기</span>
|
||||
</Button>
|
||||
|
||||
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
||||
<Save className="h-4 w-4" />
|
||||
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlimToolbar;
|
||||
|
|
@ -58,27 +58,56 @@ export const usePanelState = (panels: PanelConfig[]) => {
|
|||
});
|
||||
}, [panels]);
|
||||
|
||||
// 패널 토글
|
||||
// 패널 토글 (다른 패널 자동 닫기)
|
||||
const togglePanel = useCallback((panelId: string) => {
|
||||
setPanelStates((prev) => ({
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...prev[panelId],
|
||||
isOpen: !prev[panelId]?.isOpen,
|
||||
},
|
||||
}));
|
||||
setPanelStates((prev) => {
|
||||
const isCurrentlyOpen = prev[panelId]?.isOpen;
|
||||
const newStates = { ...prev };
|
||||
|
||||
// 다른 모든 패널 닫기
|
||||
Object.keys(newStates).forEach((id) => {
|
||||
if (id !== panelId) {
|
||||
newStates[id] = {
|
||||
...newStates[id],
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 현재 패널 토글
|
||||
newStates[panelId] = {
|
||||
...newStates[panelId],
|
||||
isOpen: !isCurrentlyOpen,
|
||||
};
|
||||
|
||||
return newStates;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 패널 열기
|
||||
// 패널 열기 (다른 패널 자동 닫기)
|
||||
const openPanel = useCallback((panelId: string) => {
|
||||
// console.log("📂 패널 열기:", panelId);
|
||||
setPanelStates((prev) => ({
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...prev[panelId],
|
||||
setPanelStates((prev) => {
|
||||
const newStates = { ...prev };
|
||||
|
||||
// 다른 모든 패널 닫기
|
||||
Object.keys(newStates).forEach((id) => {
|
||||
if (id !== panelId) {
|
||||
newStates[id] = {
|
||||
...newStates[id],
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 현재 패널 열기
|
||||
newStates[panelId] = {
|
||||
...newStates[panelId],
|
||||
isOpen: true,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return newStates;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 패널 닫기
|
||||
|
|
|
|||
|
|
@ -9,14 +9,19 @@
|
|||
*/
|
||||
export type ColumnSpanPreset =
|
||||
| "full" // 12 컬럼 (100%)
|
||||
| "half" // 6 컬럼 (50%)
|
||||
| "third" // 4 컬럼 (33%)
|
||||
| "twoThirds" // 8 컬럼 (67%)
|
||||
| "quarter" // 3 컬럼 (25%)
|
||||
| "eleven-twelfths" // 11 컬럼 (92%)
|
||||
| "five-sixths" // 10 컬럼 (83%)
|
||||
| "threeQuarters" // 9 컬럼 (75%)
|
||||
| "twoThirds" // 8 컬럼 (67%)
|
||||
| "seven-twelfths" // 7 컬럼 (58%)
|
||||
| "half" // 6 컬럼 (50%)
|
||||
| "five-twelfths" // 5 컬럼 (42%)
|
||||
| "third" // 4 컬럼 (33%)
|
||||
| "quarter" // 3 컬럼 (25%)
|
||||
| "small" // 2 컬럼 (17%)
|
||||
| "twelfth" // 1 컬럼 (8%)
|
||||
| "label" // 3 컬럼 (25%) - 폼 라벨 전용
|
||||
| "input" // 9 컬럼 (75%) - 폼 입력 전용
|
||||
| "small" // 2 컬럼 (17%)
|
||||
| "medium" // 4 컬럼 (33%)
|
||||
| "large" // 8 컬럼 (67%)
|
||||
| "auto"; // 자동 계산
|
||||
|
|
@ -26,14 +31,19 @@ export type ColumnSpanPreset =
|
|||
*/
|
||||
export const COLUMN_SPAN_VALUES: Record<ColumnSpanPreset, number> = {
|
||||
full: 12,
|
||||
half: 6,
|
||||
third: 4,
|
||||
twoThirds: 8,
|
||||
quarter: 3,
|
||||
"eleven-twelfths": 11,
|
||||
"five-sixths": 10,
|
||||
threeQuarters: 9,
|
||||
twoThirds: 8,
|
||||
"seven-twelfths": 7,
|
||||
half: 6,
|
||||
"five-twelfths": 5,
|
||||
third: 4,
|
||||
quarter: 3,
|
||||
small: 2,
|
||||
twelfth: 1,
|
||||
label: 3,
|
||||
input: 9,
|
||||
small: 2,
|
||||
medium: 4,
|
||||
large: 8,
|
||||
auto: 0, // 자동 계산 시 0
|
||||
|
|
@ -54,33 +64,19 @@ export interface ColumnSpanPresetInfo {
|
|||
* 컬럼 스팬 프리셋 상세 정보
|
||||
*/
|
||||
export const COLUMN_SPAN_PRESETS: Record<ColumnSpanPreset, ColumnSpanPresetInfo> = {
|
||||
full: {
|
||||
value: 12,
|
||||
label: "전체",
|
||||
percentage: "100%",
|
||||
class: "col-span-12",
|
||||
description: "전체 너비 (테이블, 제목 등)",
|
||||
twelfth: {
|
||||
value: 1,
|
||||
label: "1/12",
|
||||
percentage: "8.33%",
|
||||
class: "col-span-1",
|
||||
description: "최소 너비",
|
||||
},
|
||||
half: {
|
||||
value: 6,
|
||||
label: "절반",
|
||||
percentage: "50%",
|
||||
class: "col-span-6",
|
||||
description: "2분할 레이아웃",
|
||||
},
|
||||
third: {
|
||||
value: 4,
|
||||
label: "1/3",
|
||||
percentage: "33%",
|
||||
class: "col-span-4",
|
||||
description: "3분할 레이아웃",
|
||||
},
|
||||
twoThirds: {
|
||||
value: 8,
|
||||
label: "2/3",
|
||||
percentage: "67%",
|
||||
class: "col-span-8",
|
||||
description: "큰 컴포넌트",
|
||||
small: {
|
||||
value: 2,
|
||||
label: "1/6",
|
||||
percentage: "16.67%",
|
||||
class: "col-span-2",
|
||||
description: "아이콘, 체크박스",
|
||||
},
|
||||
quarter: {
|
||||
value: 3,
|
||||
|
|
@ -89,6 +85,41 @@ export const COLUMN_SPAN_PRESETS: Record<ColumnSpanPreset, ColumnSpanPresetInfo>
|
|||
class: "col-span-3",
|
||||
description: "4분할 레이아웃",
|
||||
},
|
||||
third: {
|
||||
value: 4,
|
||||
label: "1/3",
|
||||
percentage: "33.33%",
|
||||
class: "col-span-4",
|
||||
description: "3분할 레이아웃",
|
||||
},
|
||||
"five-twelfths": {
|
||||
value: 5,
|
||||
label: "5/12",
|
||||
percentage: "41.67%",
|
||||
class: "col-span-5",
|
||||
description: "중간 크기",
|
||||
},
|
||||
half: {
|
||||
value: 6,
|
||||
label: "절반",
|
||||
percentage: "50%",
|
||||
class: "col-span-6",
|
||||
description: "2분할 레이아웃",
|
||||
},
|
||||
"seven-twelfths": {
|
||||
value: 7,
|
||||
label: "7/12",
|
||||
percentage: "58.33%",
|
||||
class: "col-span-7",
|
||||
description: "큰 크기",
|
||||
},
|
||||
twoThirds: {
|
||||
value: 8,
|
||||
label: "2/3",
|
||||
percentage: "66.67%",
|
||||
class: "col-span-8",
|
||||
description: "큰 컴포넌트",
|
||||
},
|
||||
threeQuarters: {
|
||||
value: 9,
|
||||
label: "3/4",
|
||||
|
|
@ -96,6 +127,27 @@ export const COLUMN_SPAN_PRESETS: Record<ColumnSpanPreset, ColumnSpanPresetInfo>
|
|||
class: "col-span-9",
|
||||
description: "입력 필드",
|
||||
},
|
||||
"five-sixths": {
|
||||
value: 10,
|
||||
label: "5/6",
|
||||
percentage: "83.33%",
|
||||
class: "col-span-10",
|
||||
description: "매우 큰 컴포넌트",
|
||||
},
|
||||
"eleven-twelfths": {
|
||||
value: 11,
|
||||
label: "11/12",
|
||||
percentage: "91.67%",
|
||||
class: "col-span-11",
|
||||
description: "거의 전체",
|
||||
},
|
||||
full: {
|
||||
value: 12,
|
||||
label: "전체",
|
||||
percentage: "100%",
|
||||
class: "col-span-12",
|
||||
description: "전체 너비 (테이블, 제목 등)",
|
||||
},
|
||||
label: {
|
||||
value: 3,
|
||||
label: "라벨용",
|
||||
|
|
@ -110,24 +162,17 @@ export const COLUMN_SPAN_PRESETS: Record<ColumnSpanPreset, ColumnSpanPresetInfo>
|
|||
class: "col-span-9",
|
||||
description: "폼 입력 전용",
|
||||
},
|
||||
small: {
|
||||
value: 2,
|
||||
label: "작게",
|
||||
percentage: "17%",
|
||||
class: "col-span-2",
|
||||
description: "아이콘, 체크박스",
|
||||
},
|
||||
medium: {
|
||||
value: 4,
|
||||
label: "보통",
|
||||
percentage: "33%",
|
||||
percentage: "33.33%",
|
||||
class: "col-span-4",
|
||||
description: "보통 크기 컴포넌트",
|
||||
},
|
||||
large: {
|
||||
value: 8,
|
||||
label: "크게",
|
||||
percentage: "67%",
|
||||
percentage: "66.67%",
|
||||
class: "col-span-8",
|
||||
description: "큰 컴포넌트",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const AccordionBasicDefinition = createComponentDefinition({
|
|||
collapsible: true,
|
||||
defaultValue: "item-1",
|
||||
},
|
||||
defaultSize: { width: 300, height: 200 },
|
||||
defaultSize: { width: 400, height: 200 },
|
||||
configPanel: AccordionBasicConfigPanel,
|
||||
icon: "ChevronDown",
|
||||
tags: ["아코디언", "접기", "펼치기", "콘텐츠", "섹션"],
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const ButtonPrimaryDefinition = createComponentDefinition({
|
|||
errorMessage: "저장 중 오류가 발생했습니다.",
|
||||
},
|
||||
},
|
||||
defaultSize: { width: 120, height: 36 },
|
||||
defaultSize: { width: 120, height: 40 },
|
||||
configPanel: ButtonPrimaryConfigPanel,
|
||||
icon: "MousePointer",
|
||||
tags: ["버튼", "액션", "클릭"],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { CheckboxBasicConfig } from "./types";
|
||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface CheckboxBasicComponentProps extends ComponentRendererProps {
|
||||
config?: CheckboxBasicConfig;
|
||||
|
|
@ -33,6 +35,13 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
...component.config,
|
||||
} as CheckboxBasicConfig;
|
||||
|
||||
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
|
||||
const webType = component.componentConfig?.webType || "checkbox";
|
||||
|
||||
// 상태 관리
|
||||
const [isChecked, setIsChecked] = useState<boolean>(component.value === true || component.value === "true");
|
||||
const [checkedValues, setCheckedValues] = useState<string[]>([]);
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
|
|
@ -53,116 +62,116 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig: _componentConfig,
|
||||
component: _component,
|
||||
isSelected: _isSelected,
|
||||
onClick: _onClick,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
const handleCheckboxChange = (checked: boolean) => {
|
||||
setIsChecked(checked);
|
||||
if (component.onChange) {
|
||||
component.onChange(checked);
|
||||
}
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, checked);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroupChange = (value: string, checked: boolean) => {
|
||||
const newValues = checked ? [...checkedValues, value] : checkedValues.filter((v) => v !== value);
|
||||
setCheckedValues(newValues);
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValues.join(","));
|
||||
}
|
||||
};
|
||||
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(props);
|
||||
|
||||
// 세부 타입별 렌더링
|
||||
const renderCheckboxByWebType = () => {
|
||||
// boolean: On/Off 스위치
|
||||
if (webType === "boolean") {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => handleCheckboxChange(e.target.checked)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"h-6 w-11 rounded-full transition-colors",
|
||||
isChecked ? "bg-blue-600" : "bg-gray-300",
|
||||
"peer-focus:ring-2 peer-focus:ring-blue-200",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform",
|
||||
isChecked && "translate-x-5",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-900">{componentConfig.checkboxLabel || component.text || "스위치"}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// checkbox-group: 여러 체크박스
|
||||
if (webType === "checkbox-group") {
|
||||
const options = componentConfig.options || [
|
||||
{ value: "option1", label: "옵션 1" },
|
||||
{ value: "option2", label: "옵션 2" },
|
||||
{ value: "option3", label: "옵션 3" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={option.value}
|
||||
checked={checkedValues.includes(option.value)}
|
||||
onChange={(e) => handleGroupChange(option.value, e.target.checked)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// checkbox (기본 체크박스)
|
||||
return (
|
||||
<label className="flex h-full w-full cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
required={componentConfig.required || false}
|
||||
onChange={(e) => handleCheckboxChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{componentConfig.checkboxLabel || component.text || "체크박스"}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (component.style?.labelDisplay ?? true) && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#64748b",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e5e7eb",
|
||||
backgroundColor: "#f9fafb",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#e5e7eb";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={component.value === true || component.value === "true"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: "#212121",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{componentConfig.checkboxLabel || component.text || "체크박스"}
|
||||
</span>
|
||||
</label>
|
||||
{/* 세부 타입별 UI 렌더링 */}
|
||||
{renderCheckboxByWebType()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const CheckboxBasicDefinition = createComponentDefinition({
|
|||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 120, height: 24 },
|
||||
defaultSize: { width: 150, height: 120 }, // 40 * 3 (3개 옵션)
|
||||
configPanel: CheckboxBasicConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Tailwind CSS 기반 Input 컴포넌트 공통 스타일
|
||||
*/
|
||||
|
||||
export const INPUT_CLASSES = {
|
||||
// 기본 input 스타일
|
||||
base: `
|
||||
w-full h-full px-3 py-2 text-sm
|
||||
border border-gray-300 rounded-md
|
||||
bg-white text-gray-900
|
||||
outline-none transition-all duration-200
|
||||
focus:border-orange-500 focus:ring-2 focus:ring-orange-100
|
||||
disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed
|
||||
placeholder:text-gray-400
|
||||
max-w-full box-border
|
||||
`,
|
||||
|
||||
// 선택된 상태
|
||||
selected: `
|
||||
border-blue-500 ring-2 ring-blue-100
|
||||
`,
|
||||
|
||||
// 라벨 스타일
|
||||
label: `
|
||||
absolute -top-6 left-0 text-sm font-medium text-slate-600
|
||||
`,
|
||||
|
||||
// 필수 표시
|
||||
required: `
|
||||
text-red-500
|
||||
`,
|
||||
|
||||
// 컨테이너
|
||||
container: `
|
||||
relative w-full h-full max-w-full box-border
|
||||
`,
|
||||
|
||||
// textarea
|
||||
textarea: `
|
||||
w-full h-full px-3 py-2 text-sm
|
||||
border border-gray-300 rounded-md
|
||||
bg-white text-gray-900
|
||||
outline-none transition-all duration-200
|
||||
focus:border-orange-500 focus:ring-2 focus:ring-orange-100
|
||||
disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed
|
||||
resize-none
|
||||
max-w-full box-border
|
||||
`,
|
||||
|
||||
// select
|
||||
select: `
|
||||
w-full h-full px-3 py-2 text-sm
|
||||
border border-gray-300 rounded-md
|
||||
bg-white text-gray-900
|
||||
outline-none transition-all duration-200
|
||||
focus:border-orange-500 focus:ring-2 focus:ring-orange-100
|
||||
disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed
|
||||
cursor-pointer
|
||||
max-w-full box-border
|
||||
`,
|
||||
|
||||
// flex 컨테이너 (email, tel, url 등)
|
||||
flexContainer: `
|
||||
flex items-center gap-2 w-full h-full max-w-full box-border
|
||||
`,
|
||||
|
||||
// 구분자 (@ , ~ 등)
|
||||
separator: `
|
||||
text-base font-medium text-gray-500
|
||||
`,
|
||||
|
||||
// Currency 통화 기호
|
||||
currencySymbol: `
|
||||
text-base font-semibold text-green-600 pl-2
|
||||
`,
|
||||
|
||||
// Currency input
|
||||
currencyInput: `
|
||||
flex-1 h-full px-3 py-2 text-base font-semibold text-right
|
||||
border border-gray-300 rounded-md
|
||||
bg-white text-green-600
|
||||
outline-none transition-all duration-200
|
||||
focus:border-orange-500 focus:ring-2 focus:ring-orange-100
|
||||
disabled:bg-gray-100 disabled:text-gray-400
|
||||
`,
|
||||
|
||||
// Percentage 퍼센트 기호
|
||||
percentageSymbol: `
|
||||
text-base font-semibold text-blue-600 pr-2
|
||||
`,
|
||||
|
||||
// Percentage input
|
||||
percentageInput: `
|
||||
flex-1 h-full px-3 py-2 text-base font-semibold text-right
|
||||
border border-gray-300 rounded-md
|
||||
bg-white text-blue-600
|
||||
outline-none transition-all duration-200
|
||||
focus:border-orange-500 focus:ring-2 focus:ring-orange-100
|
||||
disabled:bg-gray-100 disabled:text-gray-400
|
||||
`,
|
||||
};
|
||||
|
||||
/**
|
||||
* 클래스명 결합 유틸리티
|
||||
*/
|
||||
export function cn(...classes: (string | boolean | undefined)[]): string {
|
||||
return classes.filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Input 클래스 생성기
|
||||
*/
|
||||
export function getInputClasses(isSelected?: boolean, isDisabled?: boolean): string {
|
||||
return cn(INPUT_CLASSES.base, isSelected && INPUT_CLASSES.selected, isDisabled && "opacity-60");
|
||||
}
|
||||
|
|
@ -288,27 +288,132 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(domProps);
|
||||
|
||||
// webType에 따른 실제 input type 결정
|
||||
const webType = component.componentConfig?.webType || "date";
|
||||
const inputType = (() => {
|
||||
switch (webType) {
|
||||
case "datetime":
|
||||
return "datetime-local";
|
||||
case "time":
|
||||
return "time";
|
||||
case "month":
|
||||
return "month";
|
||||
case "year":
|
||||
return "number";
|
||||
case "date":
|
||||
default:
|
||||
return "date";
|
||||
}
|
||||
})();
|
||||
|
||||
// daterange 시작일/종료일 분리 (최상위에서 계산)
|
||||
const [dateRangeStart, dateRangeEnd] = React.useMemo(() => {
|
||||
if (webType === "daterange" && typeof rawValue === "string" && rawValue.includes("~")) {
|
||||
return rawValue.split("~").map((d) => d.trim());
|
||||
}
|
||||
return ["", ""];
|
||||
}, [webType, rawValue]);
|
||||
|
||||
// daterange 타입 전용 UI
|
||||
if (webType === "daterange") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-2">
|
||||
{/* 시작일 */}
|
||||
<input
|
||||
type="date"
|
||||
value={dateRangeStart}
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const newStartDate = e.target.value;
|
||||
const newValue = `${newStartDate} ~ ${dateRangeEnd}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
|
||||
{/* 구분자 */}
|
||||
<span className="text-base font-medium text-gray-500">~</span>
|
||||
|
||||
{/* 종료일 */}
|
||||
<input
|
||||
type="date"
|
||||
value={dateRangeEnd}
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const newEndDate = e.target.value;
|
||||
const newValue = `${dateRangeStart} ~ ${newEndDate}`;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// year 타입 전용 UI (number input with YYYY format)
|
||||
if (webType === "year") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="number"
|
||||
value={rawValue}
|
||||
placeholder="YYYY"
|
||||
min="1900"
|
||||
max="2100"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const year = e.target.value;
|
||||
if (year.length <= 4) {
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, year);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`box-border h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} placeholder:text-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#64748b",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="date"
|
||||
type={inputType}
|
||||
value={formattedValue}
|
||||
placeholder={
|
||||
finalAutoGeneration?.enabled
|
||||
|
|
@ -318,27 +423,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "#f97316";
|
||||
e.target.style.boxShadow = "0 0 0 3px rgba(249, 115, 22, 0.1)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "#d1d5db";
|
||||
e.target.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
className={`box-border h-full w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} placeholder:text-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const DateInputDefinition = createComponentDefinition({
|
|||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 180, height: 36 },
|
||||
defaultSize: { width: 220, height: 40 },
|
||||
configPanel: DateInputConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const DividerLineDefinition = createComponentDefinition({
|
|||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
},
|
||||
defaultSize: { width: 200, height: 36 },
|
||||
defaultSize: { width: 400, height: 2 },
|
||||
configPanel: DividerLineConfigPanel,
|
||||
icon: "Layout",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -11,37 +11,37 @@ import { API_BASE_URL } from "@/lib/api/client";
|
|||
|
||||
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
|
||||
const loadOfficeLibrariesFromCDN = async () => {
|
||||
if (typeof window === 'undefined') return { XLSX: null, mammoth: null };
|
||||
|
||||
if (typeof window === "undefined") return { XLSX: null, mammoth: null };
|
||||
|
||||
try {
|
||||
// XLSX 라이브러리가 이미 로드되어 있는지 확인
|
||||
if (!(window as any).XLSX) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js";
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// mammoth 라이브러리가 이미 로드되어 있는지 확인
|
||||
if (!(window as any).mammoth) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js';
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js";
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
XLSX: (window as any).XLSX,
|
||||
mammoth: (window as any).mammoth
|
||||
mammoth: (window as any).mammoth,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Office 라이브러리 CDN 로드 실패:', error);
|
||||
console.error("Office 라이브러리 CDN 로드 실패:", error);
|
||||
return { XLSX: null, mammoth: null };
|
||||
}
|
||||
};
|
||||
|
|
@ -57,13 +57,7 @@ interface FileViewerModalProps {
|
|||
/**
|
||||
* 파일 뷰어 모달 컴포넌트
|
||||
*/
|
||||
export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||
file,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}) => {
|
||||
export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen, onClose, onDownload, onDelete }) => {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -73,37 +67,37 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
|
||||
// CDN에서 라이브러리 로드
|
||||
const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN();
|
||||
|
||||
|
||||
if (fileExt === "docx" && mammoth) {
|
||||
// Word 문서 렌더링
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||
|
||||
|
||||
const htmlContent = `
|
||||
<div>
|
||||
<h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4>
|
||||
<div class="word-content" style="max-height: 500px; overflow-y: auto; padding: 20px; background: white; border: 1px solid #ddd; border-radius: 5px; line-height: 1.6; font-family: 'Times New Roman', serif;">
|
||||
${result.value || '내용을 읽을 수 없습니다.'}
|
||||
${result.value || "내용을 읽을 수 없습니다."}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true;
|
||||
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
|
||||
// Excel 문서 렌더링
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
|
||||
const html = XLSX.utils.sheet_to_html(worksheet, {
|
||||
table: { className: 'excel-table' }
|
||||
table: { className: "excel-table" },
|
||||
});
|
||||
|
||||
|
||||
const htmlContent = `
|
||||
<div>
|
||||
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
|
||||
|
|
@ -118,7 +112,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true;
|
||||
} else if (fileExt === "doc") {
|
||||
|
|
@ -130,7 +124,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
<p style="color: #666; font-size: 14px;">(.docx 파일만 미리보기 지원)</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true;
|
||||
} else if (["ppt", "pptx"].includes(fileExt)) {
|
||||
|
|
@ -142,22 +136,22 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
<p style="color: #666; font-size: 14px;">파일을 다운로드하여 확인해주세요.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return false; // 지원하지 않는 형식
|
||||
} catch (error) {
|
||||
console.error("Office 문서 렌더링 오류:", error);
|
||||
|
||||
|
||||
const htmlContent = `
|
||||
<div style="color: red; text-align: center; padding: 20px;">
|
||||
Office 문서를 읽을 수 없습니다.<br>
|
||||
파일이 손상되었거나 지원하지 않는 형식일 수 있습니다.
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true; // 오류 메시지라도 표시
|
||||
} finally {
|
||||
|
|
@ -182,7 +176,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
const url = URL.createObjectURL(file._file);
|
||||
setPreviewUrl(url);
|
||||
setIsLoading(false);
|
||||
|
||||
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
|
|
@ -192,20 +186,35 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
const generatePreviewUrl = async () => {
|
||||
try {
|
||||
const fileExt = file.fileExt.toLowerCase();
|
||||
|
||||
|
||||
// 미리보기 지원 파일 타입 정의
|
||||
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
|
||||
const documentExtensions = ["pdf","doc", "docx", "xls", "xlsx", "ppt", "pptx", "rtf", "odt", "ods", "odp", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"];
|
||||
const documentExtensions = [
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"rtf",
|
||||
"odt",
|
||||
"ods",
|
||||
"odp",
|
||||
"hwp",
|
||||
"hwpx",
|
||||
"hwpml",
|
||||
"hcdt",
|
||||
"hpt",
|
||||
"pages",
|
||||
"numbers",
|
||||
"keynote",
|
||||
];
|
||||
const textExtensions = ["txt", "md", "json", "xml", "csv"];
|
||||
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
|
||||
|
||||
const supportedExtensions = [
|
||||
...imageExtensions,
|
||||
...documentExtensions,
|
||||
...textExtensions,
|
||||
...mediaExtensions
|
||||
];
|
||||
|
||||
|
||||
const supportedExtensions = [...imageExtensions, ...documentExtensions, ...textExtensions, ...mediaExtensions];
|
||||
|
||||
if (supportedExtensions.includes(fileExt)) {
|
||||
// 이미지나 PDF는 인증된 요청으로 Blob 생성
|
||||
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
|
||||
|
|
@ -213,15 +222,15 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
// 인증된 요청으로 파일 데이터 가져오기
|
||||
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("authToken")}`,
|
||||
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setPreviewUrl(blobUrl);
|
||||
|
||||
|
||||
// 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장
|
||||
cleanup = () => URL.revokeObjectURL(blobUrl);
|
||||
} else {
|
||||
|
|
@ -236,20 +245,20 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
|
||||
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
|
||||
// Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용
|
||||
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) {
|
||||
// CDN 라이브러리로 클라이언트 사이드 렌더링 시도
|
||||
try {
|
||||
const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName);
|
||||
|
||||
|
||||
if (!renderSuccess) {
|
||||
// 렌더링 실패 시 Blob URL 사용
|
||||
setPreviewUrl(blobUrl);
|
||||
|
|
@ -263,7 +272,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
// 기타 문서는 직접 Blob URL 사용
|
||||
setPreviewUrl(blobUrl);
|
||||
}
|
||||
|
||||
|
||||
return () => URL.revokeObjectURL(blobUrl); // Cleanup function
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
|
|
@ -291,7 +300,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
};
|
||||
|
||||
generatePreviewUrl();
|
||||
|
||||
|
||||
// cleanup 함수 반환
|
||||
return () => {
|
||||
if (cleanup) {
|
||||
|
|
@ -306,24 +315,20 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
const renderPreview = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (previewError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96">
|
||||
<AlertTriangle className="w-16 h-16 mb-4 text-yellow-500" />
|
||||
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
||||
<p className="text-sm text-center">{previewError}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownload?.(file)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<div className="flex h-96 flex-col items-center justify-center">
|
||||
<AlertTriangle className="mb-4 h-16 w-16 text-yellow-500" />
|
||||
<p className="mb-2 text-lg font-medium">미리보기 불가</p>
|
||||
<p className="text-center text-sm">{previewError}</p>
|
||||
<Button variant="outline" onClick={() => onDownload?.(file)} className="mt-4">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
파일 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -335,11 +340,11 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
// 이미지 파일
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center max-h-96 overflow-hidden">
|
||||
<div className="flex max-h-96 items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={previewUrl || ""}
|
||||
alt={file.realFileName}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-lg"
|
||||
onError={(e) => {
|
||||
console.error("이미지 로드 오류:", previewUrl, e);
|
||||
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
|
||||
|
|
@ -358,100 +363,83 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
<div className="h-96 overflow-auto">
|
||||
<iframe
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full border rounded-lg"
|
||||
className="h-full w-full rounded-lg border"
|
||||
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PDF 파일
|
||||
// PDF 파일 - 브라우저 기본 뷰어 사용
|
||||
if (fileExt === "pdf") {
|
||||
return (
|
||||
<div className="h-96 overflow-auto">
|
||||
<iframe
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full border rounded-lg"
|
||||
onError={() => setPreviewError("PDF 파일을 불러올 수 없습니다.")}
|
||||
/>
|
||||
<div className="h-[600px] overflow-auto rounded-lg border bg-gray-50">
|
||||
<object
|
||||
data={previewUrl || ""}
|
||||
type="application/pdf"
|
||||
className="h-full w-full rounded-lg"
|
||||
title="PDF Viewer"
|
||||
>
|
||||
<iframe src={previewUrl || ""} className="h-full w-full rounded-lg" title="PDF Viewer Fallback">
|
||||
<div className="flex h-full flex-col items-center justify-center p-8">
|
||||
<FileText className="mb-4 h-16 w-16 text-gray-400" />
|
||||
<p className="mb-2 text-lg font-medium">PDF를 표시할 수 없습니다</p>
|
||||
<p className="mb-4 text-center text-sm text-gray-600">
|
||||
브라우저가 PDF 표시를 지원하지 않습니다. 다운로드하여 확인해주세요.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => onDownload?.(file)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</iframe>
|
||||
</object>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Office 문서 (CDN 라이브러리 렌더링 또는 iframe)
|
||||
// Office 문서 - 모든 Office 문서는 다운로드 권장
|
||||
if (
|
||||
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"].includes(fileExt)
|
||||
[
|
||||
"doc",
|
||||
"docx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"hwp",
|
||||
"hwpx",
|
||||
"hwpml",
|
||||
"hcdt",
|
||||
"hpt",
|
||||
"pages",
|
||||
"numbers",
|
||||
"keynote",
|
||||
].includes(fileExt)
|
||||
) {
|
||||
// CDN 라이브러리로 렌더링된 콘텐츠가 있는 경우
|
||||
if (renderedContent) {
|
||||
return (
|
||||
<div className="relative h-96 overflow-auto">
|
||||
<div
|
||||
className="w-full h-full p-4 border rounded-lg bg-white"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// iframe 방식 (fallback)
|
||||
// Office 문서 안내 메시지 표시
|
||||
return (
|
||||
<div className="relative h-96 overflow-auto">
|
||||
<iframe
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full border rounded-lg"
|
||||
onError={() => {
|
||||
console.log("iframe 오류 발생, fallback 옵션 제공");
|
||||
setPreviewError("이 Office 문서는 브라우저에서 직접 미리보기할 수 없습니다. 다운로드하여 확인해주세요.");
|
||||
}}
|
||||
title={`${file.realFileName} 미리보기`}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||
onLoad={() => setIsLoading(false)}
|
||||
/>
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-gray-600">Office 문서를 처리하는 중...</p>
|
||||
<p className="text-xs text-gray-400 mt-1">잠시만 기다려주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 오류 발생 시 fallback 옵션 */}
|
||||
{previewError && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white">
|
||||
<FileText className="w-16 h-16 mb-4 text-orange-500" />
|
||||
<p className="text-lg font-medium mb-2">미리보기 제한</p>
|
||||
<p className="text-sm text-center mb-4 text-gray-600">
|
||||
{previewError}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownload?.(file)}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
다운로드
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
// 새 탭에서 파일 열기 시도
|
||||
const link = document.createElement('a');
|
||||
link.href = previewUrl || '';
|
||||
link.target = '_blank';
|
||||
link.click();
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
새 탭에서 열기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex h-96 flex-col items-center justify-center overflow-auto rounded-lg border bg-gradient-to-br from-blue-50 to-indigo-50 p-8">
|
||||
<FileText className="mb-6 h-20 w-20 text-blue-500" />
|
||||
<h3 className="mb-2 text-xl font-semibold text-gray-800">Office 문서</h3>
|
||||
<p className="mb-6 max-w-md text-center text-sm text-gray-600">
|
||||
{fileExt === "docx" || fileExt === "doc"
|
||||
? "Word 문서"
|
||||
: fileExt === "xlsx" || fileExt === "xls"
|
||||
? "Excel 문서"
|
||||
: fileExt === "pptx" || fileExt === "ppt"
|
||||
? "PowerPoint 문서"
|
||||
: "Office 문서"}
|
||||
는 브라우저에서 미리보기가 지원되지 않습니다.
|
||||
<br />
|
||||
다운로드하여 확인해주세요.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => onDownload?.(file)} size="lg" className="shadow-md">
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
다운로드하여 열기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -460,11 +448,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
if (["mp4", "webm", "ogg"].includes(fileExt)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<video
|
||||
controls
|
||||
className="w-full max-h-96"
|
||||
onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}
|
||||
>
|
||||
<video controls className="max-h-96 w-full" onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}>
|
||||
<source src={previewUrl || ""} type={`video/${fileExt}`} />
|
||||
</video>
|
||||
</div>
|
||||
|
|
@ -474,9 +458,9 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
// 오디오 파일
|
||||
if (["mp3", "wav", "ogg"].includes(fileExt)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96">
|
||||
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6">
|
||||
<svg className="w-16 h-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<div className="flex h-96 flex-col items-center justify-center">
|
||||
<div className="mb-6 flex h-32 w-32 items-center justify-center rounded-full bg-gray-100">
|
||||
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z"
|
||||
|
|
@ -484,11 +468,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
className="w-full max-w-md"
|
||||
onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}
|
||||
>
|
||||
<audio controls className="w-full max-w-md" onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}>
|
||||
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
|
||||
</audio>
|
||||
</div>
|
||||
|
|
@ -497,17 +477,12 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
|
||||
// 기타 파일 타입
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96">
|
||||
<FileText className="w-16 h-16 mb-4 text-gray-400" />
|
||||
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
||||
<p className="text-sm text-center mb-4">
|
||||
{file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownload?.(file)}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<div className="flex h-96 flex-col items-center justify-center">
|
||||
<FileText className="mb-4 h-16 w-16 text-gray-400" />
|
||||
<p className="mb-2 text-lg font-medium">미리보기 불가</p>
|
||||
<p className="mb-4 text-center text-sm">{file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.</p>
|
||||
<Button variant="outline" onClick={() => onDownload?.(file)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
파일 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -516,65 +491,53 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto [&>button]:hidden">
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<DialogTitle className="text-lg font-semibold truncate">
|
||||
{file.realFileName}
|
||||
</DialogTitle>
|
||||
<DialogTitle className="truncate text-lg font-semibold">{file.realFileName}</DialogTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{file.fileExt.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
파일 크기: {formatFileSize(file.size)} | 파일 형식: {file.fileExt.toUpperCase()}
|
||||
파일 크기: {formatFileSize(file.fileSize || file.size || 0)} | 파일 형식: {file.fileExt.toUpperCase()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{renderPreview()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">{renderPreview()}</div>
|
||||
|
||||
{/* 파일 정보 및 액션 버튼 */}
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-2">
|
||||
<span>크기: {formatFileSize(file.size)}</span>
|
||||
{file.uploadedAt && (
|
||||
<span>업로드: {new Date(file.uploadedAt).toLocaleString()}</span>
|
||||
<div className="mt-2 flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>크기: {formatFileSize(file.fileSize || file.size || 0)}</span>
|
||||
{(file.uploadedAt || file.regdate) && (
|
||||
<span>업로드: {new Date(file.uploadedAt || file.regdate || "").toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDownload?.(file)}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<div className="flex justify-end space-x-2 border-t pt-4">
|
||||
<Button variant="outline" size="sm" onClick={() => onDownload?.(file)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
다운로드
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={() => onDelete(file)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const FileUploadDefinition = createComponentDefinition({
|
|||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 250, height: 36 },
|
||||
defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시)
|
||||
configPanel: FileUploadConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const ImageDisplayDefinition = createComponentDefinition({
|
|||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 200, height: 36 },
|
||||
defaultSize: { width: 200, height: 200 },
|
||||
configPanel: ImageDisplayConfigPanel,
|
||||
icon: "Eye",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -81,22 +81,118 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(domProps);
|
||||
|
||||
// webType에 따른 step 값 결정
|
||||
const webType = component.componentConfig?.webType || "number";
|
||||
const defaultStep = webType === "decimal" ? "0.01" : "1";
|
||||
const step = componentConfig.step !== undefined ? componentConfig.step : defaultStep;
|
||||
|
||||
// 숫자 값 가져오기
|
||||
const rawValue =
|
||||
externalValue !== undefined
|
||||
? externalValue
|
||||
: isInteractive && formData && component.columnName
|
||||
? formData[component.columnName] || ""
|
||||
: component.value || "";
|
||||
|
||||
// 천 단위 구분자 추가 함수
|
||||
const formatNumberWithCommas = (value: string | number): string => {
|
||||
if (!value) return "";
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return String(value);
|
||||
return num.toLocaleString("ko-KR");
|
||||
};
|
||||
|
||||
// 천 단위 구분자 제거 함수
|
||||
const removeCommas = (value: string): string => {
|
||||
return value.replace(/,/g, "");
|
||||
};
|
||||
|
||||
// Currency 타입 전용 UI
|
||||
if (webType === "currency") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-1">
|
||||
{/* 통화 기호 */}
|
||||
<span className="pl-2 text-base font-semibold text-green-600">₩</span>
|
||||
|
||||
{/* 숫자 입력 */}
|
||||
<input
|
||||
type="text"
|
||||
value={formatNumberWithCommas(rawValue)}
|
||||
placeholder="0"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const inputValue = removeCommas(e.target.value);
|
||||
const numericValue = inputValue.replace(/[^0-9.]/g, "");
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, numericValue);
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-green-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Percentage 타입 전용 UI
|
||||
if (webType === "percentage") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-1">
|
||||
{/* 숫자 입력 */}
|
||||
<input
|
||||
type="text"
|
||||
value={rawValue}
|
||||
placeholder="0"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const numericValue = e.target.value.replace(/[^0-9.]/g, "");
|
||||
const num = parseFloat(numericValue);
|
||||
|
||||
// 0-100 범위 제한
|
||||
if (num > 100) return;
|
||||
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, numericValue);
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-blue-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
|
||||
{/* 퍼센트 기호 */}
|
||||
<span className="pr-2 text-base font-semibold text-blue-600">%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#64748b",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
@ -118,28 +214,8 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
readOnly={componentConfig.readonly || false}
|
||||
min={componentConfig.min}
|
||||
max={componentConfig.max}
|
||||
step={componentConfig.step || 1}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "#f97316";
|
||||
e.target.style.boxShadow = "0 0 0 3px rgba(249, 115, 22, 0.1)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "#d1d5db";
|
||||
e.target.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
step={step}
|
||||
className={`box-border h-full w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} placeholder:text-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const NumberInputDefinition = createComponentDefinition({
|
|||
max: 999999,
|
||||
step: 1,
|
||||
},
|
||||
defaultSize: { width: 150, height: 36 },
|
||||
defaultSize: { width: 200, height: 40 },
|
||||
configPanel: NumberInputConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { RadioBasicConfig } from "./types";
|
||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface RadioBasicComponentProps extends ComponentRendererProps {
|
||||
config?: RadioBasicConfig;
|
||||
|
|
@ -33,6 +35,12 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
...component.config,
|
||||
} as RadioBasicConfig;
|
||||
|
||||
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
|
||||
const webType = component.componentConfig?.webType || "radio";
|
||||
|
||||
// 상태 관리
|
||||
const [selectedValue, setSelectedValue] = useState<string>(component.value || "");
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
|
|
@ -53,185 +61,104 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
onClick?.();
|
||||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig: _componentConfig,
|
||||
component: _component,
|
||||
isSelected: _isSelected,
|
||||
onClick: _onClick,
|
||||
onDragStart: _onDragStart,
|
||||
onDragEnd: _onDragEnd,
|
||||
size: _size,
|
||||
position: _position,
|
||||
style: _style,
|
||||
screenId: _screenId,
|
||||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
...domProps
|
||||
} = props;
|
||||
const handleRadioChange = (value: string) => {
|
||||
setSelectedValue(value);
|
||||
if (component.onChange) {
|
||||
component.onChange(value);
|
||||
}
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#64748b",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(props);
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: componentConfig.direction === "horizontal" ? "row" : "column",
|
||||
gap: "12px",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e5e7eb",
|
||||
backgroundColor: "#f9fafb",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#e5e7eb";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{(componentConfig.options || []).map((option, index) => (
|
||||
<label
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
// 세부 타입별 렌더링
|
||||
const renderRadioByWebType = () => {
|
||||
const options = componentConfig.options || [
|
||||
{ value: "option1", label: "옵션 1" },
|
||||
{ value: "option2", label: "옵션 2" },
|
||||
];
|
||||
|
||||
// radio-horizontal: 가로 배치
|
||||
if (webType === "radio-horizontal") {
|
||||
return (
|
||||
<div className="flex flex-row gap-4">
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
onChange={() => handleRadioChange(option.value)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// radio-vertical: 세로 배치
|
||||
if (webType === "radio-vertical") {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
onChange={() => handleRadioChange(option.value)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// radio (기본 라디오 - direction 설정 따름)
|
||||
return (
|
||||
<div className={cn("flex gap-3", componentConfig.direction === "horizontal" ? "flex-row" : "flex-col")}>
|
||||
{options.map((option: any, index: number) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value={option.value}
|
||||
checked={component.value === option.value}
|
||||
disabled={componentConfig.disabled || false}
|
||||
checked={selectedValue === option.value}
|
||||
onChange={() => handleRadioChange(option.value)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
required={componentConfig.required || false}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-0"
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: "#212121",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
{(!componentConfig.options || componentConfig.options.length === 0) && (
|
||||
<>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value="option1"
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
/>
|
||||
<span>옵션 1</span>
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value="option2"
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
/>
|
||||
<span>옵션 2</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 세부 타입별 UI 렌더링 */}
|
||||
{renderRadioByWebType()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const RadioBasicDefinition = createComponentDefinition({
|
|||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 120, height: 24 },
|
||||
defaultSize: { width: 150, height: 80 }, // 40 * 2 (2개 옵션)
|
||||
configPanel: RadioBasicConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
|
||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
|
|
@ -63,10 +64,19 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
||||
const config = (props as any).webTypeConfig || componentConfig || {};
|
||||
|
||||
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
|
||||
const webType = component.componentConfig?.webType || "select";
|
||||
|
||||
// 외부에서 전달받은 value가 있으면 우선 사용, 없으면 config.value 사용
|
||||
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
|
||||
const [selectedLabel, setSelectedLabel] = useState("");
|
||||
|
||||
// multiselect의 경우 배열로 관리
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
|
||||
// autocomplete의 경우 검색어 관리
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
console.log("🔍 SelectBasicComponent 초기화 (React Query):", {
|
||||
componentId: component.id,
|
||||
externalValue,
|
||||
|
|
@ -298,6 +308,323 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
|
||||
const safeDomProps = filterDOMProps(otherProps);
|
||||
|
||||
// 세부 타입별 렌더링
|
||||
const renderSelectByWebType = () => {
|
||||
// code-radio: 라디오 버튼으로 코드 선택
|
||||
if (webType === "code-radio") {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{allOptions.map((option, index) => (
|
||||
<label key={index} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "code-radio-group"}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
onChange={() => handleOptionSelect(option.value, option.label)}
|
||||
disabled={isDesignMode}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// code-autocomplete: 코드 자동완성
|
||||
if (webType === "code-autocomplete") {
|
||||
const filteredOptions = allOptions.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
opt.value.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery || selectedLabel}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder="코드 또는 코드명 입력..."
|
||||
className={cn(
|
||||
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
||||
!isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
readOnly={isDesignMode}
|
||||
/>
|
||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{filteredOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className="cursor-pointer bg-white px-3 py-2 hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
handleOptionSelect(option.value, option.label);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">{option.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// code: 기본 코드 선택박스 (select와 동일)
|
||||
if (webType === "code") {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isDesignMode && "hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
isOpen && "border-orange-500",
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{isLoadingCodes ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
) : allOptions.length > 0 ? (
|
||||
allOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
|
||||
onClick={() => handleOptionSelect(option.value, option.label)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// multiselect: 여러 항목 선택 (태그 형식)
|
||||
if (webType === "multiselect") {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"box-border flex h-full w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isDesignMode && "hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
>
|
||||
{selectedValues.map((val, idx) => {
|
||||
const opt = allOptions.find((o) => o.value === val);
|
||||
return (
|
||||
<span key={idx} className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800">
|
||||
{opt?.label || val}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newVals = selectedValues.filter((v) => v !== val);
|
||||
setSelectedValues(newVals);
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newVals.join(","));
|
||||
}
|
||||
}}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={selectedValues.length > 0 ? "" : placeholder}
|
||||
className="min-w-[100px] flex-1 border-none bg-transparent outline-none"
|
||||
onClick={() => setIsOpen(true)}
|
||||
readOnly={isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// autocomplete: 검색 기능 포함
|
||||
if (webType === "autocomplete") {
|
||||
const filteredOptions = allOptions.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
opt.value.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
||||
!isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
readOnly={isDesignMode}
|
||||
/>
|
||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{filteredOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
setSearchQuery(option.label);
|
||||
setSelectedValue(option.value);
|
||||
setSelectedLabel(option.label);
|
||||
setIsOpen(false);
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, option.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// dropdown (검색 선택박스): 기본 select와 유사하지만 검색 가능
|
||||
if (webType === "dropdown") {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isDesignMode && "hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
isOpen && "border-orange-500",
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="w-full border-b border-gray-300 px-3 py-2 outline-none"
|
||||
/>
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{allOptions
|
||||
.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
opt.value.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
.map((option, index) => (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
|
||||
onClick={() => handleOptionSelect(option.value, option.label)}
|
||||
>
|
||||
{option.label || option.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// select (기본 선택박스)
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isDesignMode && "hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
isOpen && "border-orange-500",
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{isLoadingCodes ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
) : allOptions.length > 0 ? (
|
||||
allOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
|
||||
onClick={() => handleOptionSelect(option.value, option.label)}
|
||||
>
|
||||
{option.label || option.value}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={selectRef}
|
||||
|
|
@ -310,101 +637,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (component.style?.labelDisplay ?? true) && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#64748b",
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 커스텀 셀렉트 박스 */}
|
||||
<div
|
||||
className={`flex w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 ${isDesignMode ? "pointer-events-none" : "hover:border-orange-400"} ${isSelected ? "ring-2 ring-orange-500" : ""} ${isOpen ? "border-orange-500" : ""} `}
|
||||
onClick={handleToggle}
|
||||
style={{
|
||||
pointerEvents: isDesignMode ? "none" : "auto",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isDesignMode) {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isDesignMode) {
|
||||
e.currentTarget.style.borderColor = "#d1d5db";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||
|
||||
{/* 드롭다운 아이콘 */}
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 드롭다운 옵션 */}
|
||||
{isOpen && !isDesignMode && (
|
||||
<div
|
||||
className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||
style={{
|
||||
backgroundColor: "white",
|
||||
color: "black",
|
||||
zIndex: 99999, // 더 높은 z-index로 설정
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
console.log(`🎨 [${component.id}] 드롭다운 렌더링:`, {
|
||||
isOpen,
|
||||
isDesignMode,
|
||||
isLoadingCodes,
|
||||
allOptionsLength: allOptions.length,
|
||||
allOptions: allOptions.map((o: Option) => ({ value: o.value, label: o.label })),
|
||||
});
|
||||
return null;
|
||||
})()}
|
||||
{isLoadingCodes ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
) : allOptions.length > 0 ? (
|
||||
allOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
|
||||
style={{
|
||||
color: "black",
|
||||
backgroundColor: "white",
|
||||
minHeight: "32px",
|
||||
border: "1px solid #e5e7eb",
|
||||
}}
|
||||
onClick={() => handleOptionSelect(option.value, option.label)}
|
||||
>
|
||||
{option.label || option.value || `옵션 ${index + 1}`}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 세부 타입별 UI 렌더링 */}
|
||||
{renderSelectByWebType()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const SelectBasicDefinition = createComponentDefinition({
|
|||
options: [],
|
||||
placeholder: "선택하세요",
|
||||
},
|
||||
defaultSize: { width: 200, height: 36 },
|
||||
defaultSize: { width: 250, height: 40 },
|
||||
configPanel: SelectBasicConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const SliderBasicDefinition = createComponentDefinition({
|
|||
max: 999999,
|
||||
step: 1,
|
||||
},
|
||||
defaultSize: { width: 200, height: 36 },
|
||||
defaultSize: { width: 250, height: 40 },
|
||||
configPanel: SliderBasicConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { AutoGenerationConfig } from "@/types/screen";
|
|||
import { TextInputConfig } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { INPUT_CLASSES, cn, getInputClasses } from "../common/inputStyles";
|
||||
import { ChevronDown, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
|
||||
export interface TextInputComponentProps extends ComponentRendererProps {
|
||||
config?: TextInputConfig;
|
||||
|
|
@ -181,27 +185,441 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(domProps);
|
||||
|
||||
// webType에 따른 실제 input type 및 검증 규칙 결정
|
||||
const webType = component.componentConfig?.webType || "text";
|
||||
const inputType = (() => {
|
||||
switch (webType) {
|
||||
case "email":
|
||||
return "email";
|
||||
case "tel":
|
||||
return "tel";
|
||||
case "url":
|
||||
return "url";
|
||||
case "password":
|
||||
return "password";
|
||||
case "textarea":
|
||||
return "text"; // textarea는 별도 처리
|
||||
case "text":
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
})();
|
||||
|
||||
// webType별 검증 패턴
|
||||
const validationPattern = (() => {
|
||||
switch (webType) {
|
||||
case "tel":
|
||||
// 한국 전화번호 형식: 010-1234-5678, 02-1234-5678 등
|
||||
return "[0-9]{2,3}-[0-9]{3,4}-[0-9]{4}";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
// webType별 placeholder
|
||||
const defaultPlaceholder = (() => {
|
||||
switch (webType) {
|
||||
case "email":
|
||||
return "example@email.com";
|
||||
case "tel":
|
||||
return "010-1234-5678";
|
||||
case "url":
|
||||
return "https://example.com";
|
||||
case "password":
|
||||
return "비밀번호를 입력하세요";
|
||||
case "textarea":
|
||||
return "내용을 입력하세요";
|
||||
default:
|
||||
return "텍스트를 입력하세요";
|
||||
}
|
||||
})();
|
||||
|
||||
// 이메일 입력 상태 (username@domain 분리)
|
||||
const [emailUsername, setEmailUsername] = React.useState("");
|
||||
const [emailDomain, setEmailDomain] = React.useState("gmail.com");
|
||||
const [emailDomainOpen, setEmailDomainOpen] = React.useState(false);
|
||||
|
||||
// 전화번호 입력 상태 (3개 부분으로 분리)
|
||||
const [telPart1, setTelPart1] = React.useState("");
|
||||
const [telPart2, setTelPart2] = React.useState("");
|
||||
const [telPart3, setTelPart3] = React.useState("");
|
||||
|
||||
// URL 입력 상태 (프로토콜 + 도메인)
|
||||
const [urlProtocol, setUrlProtocol] = React.useState("https://");
|
||||
const [urlDomain, setUrlDomain] = React.useState("");
|
||||
|
||||
// 이메일 도메인 목록
|
||||
const emailDomains = ["gmail.com", "naver.com", "daum.net", "kakao.com", "직접입력"];
|
||||
|
||||
// 이메일 값 동기화
|
||||
React.useEffect(() => {
|
||||
if (webType === "email") {
|
||||
const currentValue =
|
||||
isInteractive && formData && component.columnName ? formData[component.columnName] : component.value || "";
|
||||
|
||||
if (currentValue && typeof currentValue === "string" && currentValue.includes("@")) {
|
||||
const [username, domain] = currentValue.split("@");
|
||||
setEmailUsername(username || "");
|
||||
setEmailDomain(domain || "gmail.com");
|
||||
}
|
||||
}
|
||||
}, [webType, component.value, formData, component.columnName, isInteractive]);
|
||||
|
||||
// 전화번호 값 동기화
|
||||
React.useEffect(() => {
|
||||
if (webType === "tel") {
|
||||
const currentValue =
|
||||
isInteractive && formData && component.columnName ? formData[component.columnName] : component.value || "";
|
||||
|
||||
if (currentValue && typeof currentValue === "string") {
|
||||
const parts = currentValue.split("-");
|
||||
setTelPart1(parts[0] || "");
|
||||
setTelPart2(parts[1] || "");
|
||||
setTelPart3(parts[2] || "");
|
||||
}
|
||||
}
|
||||
}, [webType, component.value, formData, component.columnName, isInteractive]);
|
||||
|
||||
// URL 값 동기화
|
||||
React.useEffect(() => {
|
||||
if (webType === "url") {
|
||||
const currentValue =
|
||||
isInteractive && formData && component.columnName ? formData[component.columnName] : component.value || "";
|
||||
|
||||
if (currentValue && typeof currentValue === "string") {
|
||||
if (currentValue.startsWith("https://")) {
|
||||
setUrlProtocol("https://");
|
||||
setUrlDomain(currentValue.substring(8));
|
||||
} else if (currentValue.startsWith("http://")) {
|
||||
setUrlProtocol("http://");
|
||||
setUrlDomain(currentValue.substring(7));
|
||||
} else {
|
||||
setUrlDomain(currentValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [webType, component.value, formData, component.columnName, isInteractive]);
|
||||
|
||||
// 이메일 타입 전용 UI
|
||||
if (webType === "email") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-2">
|
||||
{/* 사용자명 입력 */}
|
||||
<input
|
||||
type="text"
|
||||
value={emailUsername}
|
||||
placeholder="사용자명"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const newUsername = e.target.value;
|
||||
setEmailUsername(newUsername);
|
||||
const fullEmail = `${newUsername}@${emailDomain}`;
|
||||
|
||||
if (isInteractive && formData && onFormDataChange && component.columnName) {
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
[component.columnName]: fullEmail,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
|
||||
{/* @ 구분자 */}
|
||||
<span className="text-base font-medium text-gray-500">@</span>
|
||||
|
||||
{/* 도메인 선택/입력 (Combobox) */}
|
||||
<Popover open={emailDomainOpen} onOpenChange={setEmailDomainOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={emailDomainOpen}
|
||||
disabled={componentConfig.disabled || false}
|
||||
className={cn(
|
||||
"flex h-full flex-1 items-center justify-between rounded-md border px-3 py-2 text-sm transition-all duration-200",
|
||||
isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300",
|
||||
componentConfig.disabled ? "cursor-not-allowed bg-gray-100 text-gray-400" : "bg-white text-gray-900",
|
||||
"hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 focus:outline-none",
|
||||
emailDomainOpen && "border-orange-500 ring-2 ring-orange-100",
|
||||
)}
|
||||
>
|
||||
<span className={cn("truncate", !emailDomain && "text-gray-400")}>{emailDomain || "도메인 선택"}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="도메인 검색 또는 입력..."
|
||||
value={emailDomain}
|
||||
onValueChange={(value) => {
|
||||
setEmailDomain(value);
|
||||
const fullEmail = `${emailUsername}@${value}`;
|
||||
|
||||
if (isInteractive && formData && onFormDataChange && component.columnName) {
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
[component.columnName]: fullEmail,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>직접 입력한 도메인: {emailDomain}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{emailDomains
|
||||
.filter((d) => d !== "직접입력")
|
||||
.map((domain) => (
|
||||
<CommandItem
|
||||
key={domain}
|
||||
value={domain}
|
||||
onSelect={(currentValue) => {
|
||||
setEmailDomain(currentValue);
|
||||
const fullEmail = `${emailUsername}@${currentValue}`;
|
||||
|
||||
if (isInteractive && formData && onFormDataChange && component.columnName) {
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
[component.columnName]: fullEmail,
|
||||
});
|
||||
}
|
||||
setEmailDomainOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", emailDomain === domain ? "opacity-100" : "opacity-0")} />
|
||||
{domain}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 전화번호 타입 전용 UI
|
||||
if (webType === "tel") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-1.5">
|
||||
{/* 첫 번째 부분 (지역번호) */}
|
||||
<input
|
||||
type="text"
|
||||
value={telPart1}
|
||||
placeholder="010"
|
||||
maxLength={3}
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/[^0-9]/g, "");
|
||||
setTelPart1(value);
|
||||
const fullTel = `${value}-${telPart2}-${telPart3}`;
|
||||
|
||||
if (isInteractive && formData && onFormDataChange && component.columnName) {
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
[component.columnName]: fullTel,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
|
||||
<span className="text-base font-medium text-gray-500">-</span>
|
||||
|
||||
{/* 두 번째 부분 */}
|
||||
<input
|
||||
type="text"
|
||||
value={telPart2}
|
||||
placeholder="1234"
|
||||
maxLength={4}
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/[^0-9]/g, "");
|
||||
setTelPart2(value);
|
||||
const fullTel = `${telPart1}-${value}-${telPart3}`;
|
||||
|
||||
if (isInteractive && formData && onFormDataChange && component.columnName) {
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
[component.columnName]: fullTel,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
|
||||
<span className="text-base font-medium text-gray-500">-</span>
|
||||
|
||||
{/* 세 번째 부분 */}
|
||||
<input
|
||||
type="text"
|
||||
value={telPart3}
|
||||
placeholder="5678"
|
||||
maxLength={4}
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/[^0-9]/g, "");
|
||||
setTelPart3(value);
|
||||
const fullTel = `${telPart1}-${telPart2}-${value}`;
|
||||
|
||||
if (isInteractive && formData && onFormDataChange && component.columnName) {
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
[component.columnName]: fullTel,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// URL 타입 전용 UI
|
||||
if (webType === "url") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="box-border flex h-full w-full items-center gap-1">
|
||||
{/* 프로토콜 선택 */}
|
||||
<select
|
||||
value={urlProtocol}
|
||||
disabled={componentConfig.disabled || false}
|
||||
onChange={(e) => {
|
||||
const newProtocol = e.target.value;
|
||||
setUrlProtocol(newProtocol);
|
||||
const fullUrl = `${newProtocol}${urlDomain}`;
|
||||
|
||||
if (isInteractive && formData && onFormDataChange && component.columnName) {
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
[component.columnName]: fullUrl,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`h-full w-[100px] cursor-pointer rounded-md border px-2 py-2 text-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<option value="https://">https://</option>
|
||||
<option value="http://">http://</option>
|
||||
</select>
|
||||
|
||||
{/* 도메인 입력 */}
|
||||
<input
|
||||
type="text"
|
||||
value={urlDomain}
|
||||
placeholder="www.example.com"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const newDomain = e.target.value;
|
||||
setUrlDomain(newDomain);
|
||||
const fullUrl = `${urlProtocol}${newDomain}`;
|
||||
|
||||
if (isInteractive && formData && onFormDataChange && component.columnName) {
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
[component.columnName]: fullUrl,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// textarea 타입인 경우 별도 렌더링
|
||||
if (webType === "textarea") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={(() => {
|
||||
let displayValue = "";
|
||||
|
||||
if (isInteractive && formData && component.columnName) {
|
||||
displayValue = formData[component.columnName] || autoGeneratedValue || "";
|
||||
} else {
|
||||
displayValue = component.value || autoGeneratedValue || "";
|
||||
}
|
||||
|
||||
return displayValue;
|
||||
})()}
|
||||
placeholder={
|
||||
testAutoGeneration.enabled && testAutoGeneration.type !== "none"
|
||||
? `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
|
||||
: componentConfig.placeholder || defaultPlaceholder
|
||||
}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
if (isInteractive && formData && onFormDataChange && component.columnName) {
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
[component.columnName]: e.target.value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`box-border h-full w-full max-w-full resize-none rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} placeholder:text-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||
<div className={`relative w-full max-w-full overflow-hidden ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#64748b",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
type={inputType}
|
||||
value={(() => {
|
||||
let displayValue = "";
|
||||
|
||||
|
|
@ -228,32 +646,14 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
placeholder={
|
||||
testAutoGeneration.enabled && testAutoGeneration.type !== "none"
|
||||
? `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
|
||||
: componentConfig.placeholder || ""
|
||||
: componentConfig.placeholder || defaultPlaceholder
|
||||
}
|
||||
pattern={validationPattern}
|
||||
title={webType === "tel" ? "전화번호 형식: 010-1234-5678" : undefined}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "#f97316";
|
||||
e.target.style.boxShadow = "0 0 0 3px rgba(249, 115, 22, 0.1)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "#d1d5db";
|
||||
e.target.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
className={`box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} placeholder:text-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const TextInputDefinition = createComponentDefinition({
|
|||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
},
|
||||
defaultSize: { width: 200, height: 36 },
|
||||
defaultSize: { width: 300, height: 40 },
|
||||
configPanel: TextInputConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: ["텍스트", "입력", "폼"],
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const TextareaBasicDefinition = createComponentDefinition({
|
|||
rows: 3,
|
||||
maxLength: 1000,
|
||||
},
|
||||
defaultSize: { width: 200, height: 80 },
|
||||
defaultSize: { width: 400, height: 100 },
|
||||
configPanel: TextareaBasicConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const ToggleSwitchDefinition = createComponentDefinition({
|
|||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 200, height: 36 },
|
||||
defaultSize: { width: 180, height: 40 },
|
||||
configPanel: ToggleSwitchConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const REACT_ONLY_PROPS = new Set([
|
|||
"onZoneClick",
|
||||
"onSelectedRowsChange",
|
||||
"onUpdateLayout",
|
||||
"onConfigChange",
|
||||
|
||||
// 데이터 관련
|
||||
"formData",
|
||||
|
|
@ -30,6 +31,7 @@ const REACT_ONLY_PROPS = new Set([
|
|||
"selectedScreen",
|
||||
"allComponents",
|
||||
"refreshKey",
|
||||
"value", // 커스텀 value prop (input의 value와 구별)
|
||||
|
||||
// 화면/테이블 관련
|
||||
"screenId",
|
||||
|
|
@ -43,8 +45,9 @@ const REACT_ONLY_PROPS = new Set([
|
|||
"selectedRows",
|
||||
"selectedRowsData",
|
||||
|
||||
// 추가된 React 전용 props
|
||||
"allComponents",
|
||||
// 컴포넌트 기능 관련
|
||||
"autoGeneration",
|
||||
"hidden", // 이미 SAFE_DOM_PROPS에 있지만 커스텀 구현을 위해 제외
|
||||
]);
|
||||
|
||||
// DOM에 안전하게 전달할 수 있는 표준 HTML 속성들
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* 입력 타입(Input Type)과 세부 타입(Detail Type) 매핑 정의
|
||||
*
|
||||
* 테이블 타입 관리의 8개 핵심 입력 타입을 기반으로
|
||||
* 화면 관리에서 선택 가능한 세부 타입들을 정의합니다.
|
||||
*/
|
||||
|
||||
import { WebType } from "./unified-core";
|
||||
|
||||
/**
|
||||
* 8개 핵심 입력 타입
|
||||
*/
|
||||
export type BaseInputType =
|
||||
| "text" // 텍스트
|
||||
| "number" // 숫자
|
||||
| "date" // 날짜
|
||||
| "code" // 코드
|
||||
| "entity" // 엔티티
|
||||
| "select" // 선택박스
|
||||
| "checkbox" // 체크박스
|
||||
| "radio"; // 라디오버튼
|
||||
|
||||
/**
|
||||
* 세부 타입 옵션 인터페이스
|
||||
*/
|
||||
export interface DetailTypeOption {
|
||||
value: WebType;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 타입별 세부 타입 매핑
|
||||
*/
|
||||
export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]> = {
|
||||
// 텍스트 → text, email, tel, url, textarea, password
|
||||
text: [
|
||||
{ value: "text", label: "일반 텍스트", description: "기본 텍스트 입력" },
|
||||
{ value: "email", label: "이메일", description: "이메일 주소 입력" },
|
||||
{ value: "tel", label: "전화번호", description: "전화번호 입력" },
|
||||
{ value: "url", label: "URL", description: "웹사이트 주소 입력" },
|
||||
{ value: "textarea", label: "여러 줄 텍스트", description: "긴 텍스트 입력" },
|
||||
{ value: "password", label: "비밀번호", description: "비밀번호 입력 (마스킹)" },
|
||||
],
|
||||
|
||||
// 숫자 → number, decimal, currency, percentage
|
||||
number: [
|
||||
{ value: "number", label: "정수", description: "정수 숫자 입력" },
|
||||
{ value: "decimal", label: "소수", description: "소수점 포함 숫자 입력" },
|
||||
{ value: "currency", label: "통화", description: "통화 형식 (₩ 1,000)" },
|
||||
{ value: "percentage", label: "퍼센트", description: "퍼센트 형식 (50%)" },
|
||||
],
|
||||
|
||||
// 날짜 → date, datetime, time, daterange, month, year
|
||||
date: [
|
||||
{ value: "date", label: "날짜", description: "날짜 선택 (YYYY-MM-DD)" },
|
||||
{ value: "datetime", label: "날짜+시간", description: "날짜와 시간 선택" },
|
||||
{ value: "time", label: "시간", description: "시간 선택 (HH:mm)" },
|
||||
{ value: "daterange", label: "기간", description: "시작일 ~ 종료일" },
|
||||
{ value: "month", label: "월", description: "년/월 선택 (YYYY-MM)" },
|
||||
{ value: "year", label: "년", description: "년도 선택 (YYYY)" },
|
||||
],
|
||||
|
||||
// 코드 → code, code-autocomplete, code-radio
|
||||
code: [
|
||||
{ value: "code", label: "코드 선택박스", description: "드롭다운으로 코드 선택" },
|
||||
{ value: "code-autocomplete", label: "코드 자동완성", description: "코드/코드명 검색" },
|
||||
{ value: "code-radio", label: "코드 라디오", description: "라디오 버튼으로 선택" },
|
||||
],
|
||||
|
||||
// 엔티티 → entity (세부 타입 없음, 참조 테이블만 선택)
|
||||
entity: [{ value: "entity", label: "엔티티 참조", description: "다른 테이블 데이터 참조" }],
|
||||
|
||||
// 선택박스 → select, dropdown, multiselect, autocomplete
|
||||
select: [
|
||||
{ value: "select", label: "선택박스", description: "드롭다운 선택 (단일)" },
|
||||
{ value: "dropdown", label: "검색 선택박스", description: "검색 기능 포함" },
|
||||
{ value: "multiselect", label: "다중 선택", description: "여러 항목 선택 (태그)" },
|
||||
{ value: "autocomplete", label: "자동완성", description: "입력하면 자동완성 제안" },
|
||||
],
|
||||
|
||||
// 체크박스 → checkbox, boolean, checkbox-group
|
||||
checkbox: [
|
||||
{ value: "checkbox", label: "체크박스", description: "단일 체크박스" },
|
||||
{ value: "boolean", label: "스위치", description: "On/Off 스위치" },
|
||||
{ value: "checkbox-group", label: "체크박스 그룹", description: "여러 체크박스" },
|
||||
],
|
||||
|
||||
// 라디오버튼 → radio, radio-horizontal, radio-vertical
|
||||
radio: [
|
||||
{ value: "radio", label: "라디오버튼", description: "기본 라디오 버튼" },
|
||||
{ value: "radio-horizontal", label: "가로 라디오", description: "가로 배치" },
|
||||
{ value: "radio-vertical", label: "세로 라디오", description: "세로 배치" },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 웹타입에서 기본 입력 타입 추출
|
||||
*/
|
||||
export function getBaseInputType(webType: WebType): BaseInputType {
|
||||
// text 계열
|
||||
if (["text", "email", "tel", "url", "textarea", "password"].includes(webType)) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
// number 계열
|
||||
if (["number", "decimal", "currency", "percentage"].includes(webType)) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
// date 계열
|
||||
if (["date", "datetime", "time", "daterange", "month", "year"].includes(webType)) {
|
||||
return "date";
|
||||
}
|
||||
|
||||
// code 계열
|
||||
if (["code", "code-autocomplete", "code-radio"].includes(webType)) {
|
||||
return "code";
|
||||
}
|
||||
|
||||
// select 계열
|
||||
if (["select", "dropdown", "multiselect", "autocomplete"].includes(webType)) {
|
||||
return "select";
|
||||
}
|
||||
|
||||
// checkbox 계열
|
||||
if (["checkbox", "boolean", "checkbox-group"].includes(webType)) {
|
||||
return "checkbox";
|
||||
}
|
||||
|
||||
// radio 계열
|
||||
if (["radio", "radio-horizontal", "radio-vertical"].includes(webType)) {
|
||||
return "radio";
|
||||
}
|
||||
|
||||
// entity
|
||||
if (webType === "entity") return "entity";
|
||||
|
||||
// 기본값: text
|
||||
return "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 타입에 해당하는 세부 타입 목록 가져오기
|
||||
*/
|
||||
export function getDetailTypes(baseInputType: BaseInputType): DetailTypeOption[] {
|
||||
return INPUT_TYPE_DETAIL_TYPES[baseInputType] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 타입에 해당하는 기본 세부 타입 가져오기
|
||||
*/
|
||||
export function getDefaultDetailType(baseInputType: BaseInputType): WebType {
|
||||
const detailTypes = getDetailTypes(baseInputType);
|
||||
return detailTypes[0]?.value || "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 타입 옵션 (PropertiesPanel에서 사용)
|
||||
*/
|
||||
export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: string; description: string }> = [
|
||||
{ value: "text", label: "텍스트", description: "텍스트 입력 필드" },
|
||||
{ value: "number", label: "숫자", description: "숫자 입력 필드" },
|
||||
{ value: "date", label: "날짜", description: "날짜/시간 선택" },
|
||||
{ value: "code", label: "코드", description: "공통 코드 선택" },
|
||||
{ value: "entity", label: "엔티티", description: "다른 테이블 참조" },
|
||||
{ value: "select", label: "선택박스", description: "드롭다운 선택" },
|
||||
{ value: "checkbox", label: "체크박스", description: "체크박스/스위치" },
|
||||
{ value: "radio", label: "라디오버튼", description: "라디오 버튼 그룹" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 입력 타입 검증
|
||||
*/
|
||||
export function isValidBaseInputType(value: string): value is BaseInputType {
|
||||
return BASE_INPUT_TYPE_OPTIONS.some((opt) => opt.value === value);
|
||||
}
|
||||
Loading…
Reference in New Issue