컬럼 세부 타입 설정
This commit is contained in:
parent
dadd49b98f
commit
55f52ed1b5
|
|
@ -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();
|
||||
|
|
@ -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)
|
||||
|
|
@ -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`,
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -335,7 +343,7 @@ export default function ScreenViewPage() {
|
|||
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`,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
// 레거시 ButtonConfigPanel 제거됨
|
||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||
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 +48,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 +60,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 +80,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 +101,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 +232,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 +348,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 +368,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 +390,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 +412,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 +434,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 +458,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 +471,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 +516,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 +542,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 +556,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 +581,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 +600,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 +619,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 +634,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 +674,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 +686,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 +713,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 변경 감지 보장
|
||||
|
|
@ -761,17 +775,17 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
|
||||
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 +861,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 +894,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 +942,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,36 +1009,77 @@ 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
|
||||
|
|
@ -1036,11 +1093,11 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
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"),
|
||||
// currentTable,
|
||||
// columns: currentTable?.columns,
|
||||
// columnsLength: currentTable?.columns?.length,
|
||||
// sampleColumn: currentTable?.columns?.[0],
|
||||
// deptCodeColumn: currentTable?.columns?.find((col) => col.columnName === "dept_code"),
|
||||
// });
|
||||
return currentTable?.columns || [];
|
||||
})()}
|
||||
|
|
@ -1060,21 +1117,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<{
|
||||
|
|
@ -518,24 +524,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>
|
||||
|
|
|
|||
|
|
@ -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,122 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{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 : {}),
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
<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-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// checkbox (기본 체크박스)
|
||||
return (
|
||||
<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}
|
||||
className={cn(
|
||||
"flex h-full w-full cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3",
|
||||
"transition-all hover:border-orange-400 hover:shadow-md",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={component.value === true || component.value === "true"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
checked={isChecked}
|
||||
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.checked);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => handleCheckboxChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: "#212121",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{componentConfig.checkboxLabel || component.text || "체크박스"}
|
||||
</span>
|
||||
<span className="text-sm text-gray-900">{componentConfig.checkboxLabel || component.text || "체크박스"}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (component.style?.labelDisplay ?? true) && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
{component.label}
|
||||
{component.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 세부 타입별 UI 렌더링 */}
|
||||
{renderCheckboxByWebType()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Tailwind CSS 기반 Input 컴포넌트 공통 스타일
|
||||
*/
|
||||
|
||||
export const INPUT_CLASSES = {
|
||||
// 기본 input 스타일
|
||||
base: `
|
||||
w-full h-10 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
|
||||
`,
|
||||
|
||||
// 선택된 상태
|
||||
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
|
||||
`,
|
||||
|
||||
// textarea
|
||||
textarea: `
|
||||
w-full min-h-[80px] 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-vertical
|
||||
`,
|
||||
|
||||
// select
|
||||
select: `
|
||||
w-full h-10 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
|
||||
`,
|
||||
|
||||
// flex 컨테이너 (email, tel, url 등)
|
||||
flexContainer: `
|
||||
flex items-center gap-2 w-full h-10
|
||||
`,
|
||||
|
||||
// 구분자 (@ , ~ 등)
|
||||
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="flex h-10 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={`h-10 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={`h-10 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}
|
||||
|
|
|
|||
|
|
@ -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="flex h-10 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="flex h-10 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={`h-10 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}
|
||||
|
|
|
|||
|
|
@ -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,123 @@ 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>
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeDomProps = filterDOMProps(props);
|
||||
|
||||
// 세부 타입별 렌더링
|
||||
const renderRadioByWebType = () => {
|
||||
const options = componentConfig.options || [
|
||||
{ value: "option1", label: "옵션 1" },
|
||||
{ value: "option2", label: "옵션 2" },
|
||||
];
|
||||
|
||||
// radio-horizontal: 가로 배치
|
||||
if (webType === "radio-horizontal") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row gap-4 rounded-lg border border-gray-200 bg-gray-50 p-3",
|
||||
"transition-all hover:border-orange-400 hover:shadow-md",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
>
|
||||
{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-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// radio-vertical: 세로 배치
|
||||
if (webType === "radio-vertical") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-lg border border-gray-200 bg-gray-50 p-3",
|
||||
"transition-all hover:border-orange-400 hover:shadow-md",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
>
|
||||
{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-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// radio (기본 라디오 - direction 설정 따름)
|
||||
return (
|
||||
<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}
|
||||
className={cn(
|
||||
"flex gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3",
|
||||
"transition-all hover:border-orange-400 hover:shadow-md",
|
||||
componentConfig.direction === "horizontal" ? "flex-row" : "flex-col",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
>
|
||||
{(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 : {}),
|
||||
}}
|
||||
>
|
||||
{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-blue-500"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"flex min-h-[40px] 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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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";
|
||||
|
||||
export interface TextInputComponentProps extends ComponentRendererProps {
|
||||
config?: TextInputConfig;
|
||||
|
|
@ -181,27 +182,431 @@ 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 [isCustomDomain, setIsCustomDomain] = 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 || "");
|
||||
if (domain && emailDomains.includes(domain)) {
|
||||
setEmailDomain(domain);
|
||||
setIsCustomDomain(false);
|
||||
} else {
|
||||
setEmailDomain(domain || "");
|
||||
setIsCustomDomain(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [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="flex h-10 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>
|
||||
|
||||
{/* 도메인 선택/입력 */}
|
||||
{isCustomDomain ? (
|
||||
<input
|
||||
type="text"
|
||||
value={emailDomain}
|
||||
placeholder="도메인"
|
||||
disabled={componentConfig.disabled || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
onChange={(e) => {
|
||||
const newDomain = e.target.value;
|
||||
setEmailDomain(newDomain);
|
||||
const fullEmail = `${emailUsername}@${newDomain}`;
|
||||
|
||||
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`}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={emailDomain}
|
||||
disabled={componentConfig.disabled || false}
|
||||
onChange={(e) => {
|
||||
const newDomain = e.target.value;
|
||||
if (newDomain === "직접입력") {
|
||||
setIsCustomDomain(true);
|
||||
setEmailDomain("");
|
||||
} else {
|
||||
setEmailDomain(newDomain);
|
||||
const fullEmail = `${emailUsername}@${newDomain}`;
|
||||
|
||||
if (isInteractive && formData && onFormDataChange && component.columnName) {
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
[component.columnName]: fullEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`h-full flex-1 cursor-pointer 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`}
|
||||
>
|
||||
{emailDomains.map((domain) => (
|
||||
<option key={domain} value={domain}>
|
||||
{domain}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</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="flex h-10 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="flex h-10 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={`min-h-[80px] w-full resize-y 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="text"
|
||||
type={inputType}
|
||||
value={(() => {
|
||||
let displayValue = "";
|
||||
|
||||
|
|
@ -228,32 +633,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={`h-10 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,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