테이블 템플릿 제작

This commit is contained in:
kjs
2025-09-03 15:23:12 +09:00
parent 55a7e1dc89
commit 4a0c42d80c
14 changed files with 3757 additions and 42 deletions

View File

@@ -439,3 +439,71 @@ export async function updateColumnWebType(
res.status(500).json(response);
}
}
/**
* 테이블 데이터 조회 (페이징 + 검색)
*/
export async function getTableData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { page = 1, size = 10, search = {}, sortBy, sortOrder = 'asc' } = req.body;
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
logger.info(`페이징: page=${page}, size=${size}`);
logger.info(`검색 조건:`, search);
logger.info(`정렬: ${sortBy} ${sortOrder}`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 데이터 조회
const result = await tableManagementService.getTableData(
tableName,
{
page: parseInt(page),
size: parseInt(size),
search,
sortBy,
sortOrder
}
);
logger.info(`테이블 데이터 조회 완료: ${tableName}, 총 ${result.total}건, 페이지 ${result.page}/${result.totalPages}`);
const response: ApiResponse<any> = {
success: true,
message: "테이블 데이터를 성공적으로 조회했습니다.",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 데이터 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 데이터 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_DATA_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}

View File

@@ -8,6 +8,7 @@ import {
getTableLabels,
getColumnLabels,
updateColumnWebType,
getTableData,
} from "../controllers/tableManagementController";
const router = express.Router();
@@ -63,4 +64,10 @@ router.put(
updateColumnWebType
);
/**
* 테이블 데이터 조회 (페이징 + 검색)
* POST /api/table-management/tables/:tableName/data
*/
router.post("/tables/:tableName/data", getTableData);
export default router;

View File

@@ -514,4 +514,105 @@ export class TableManagementService {
return {};
}
}
/**
* 테이블 데이터 조회 (페이징 + 검색)
*/
async getTableData(
tableName: string,
options: {
page: number;
size: number;
search?: Record<string, any>;
sortBy?: string;
sortOrder?: string;
}
): Promise<{
data: any[];
total: number;
page: number;
size: number;
totalPages: number;
}> {
try {
const { page, size, search = {}, sortBy, sortOrder = 'asc' } = options;
const offset = (page - 1) * size;
logger.info(`테이블 데이터 조회: ${tableName}`, options);
// WHERE 조건 구성
let whereConditions: string[] = [];
let searchValues: any[] = [];
let paramIndex = 1;
if (search && Object.keys(search).length > 0) {
for (const [column, value] of Object.entries(search)) {
if (value !== null && value !== undefined && value !== '') {
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, '');
if (typeof value === 'string') {
whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`);
searchValues.push(`%${value}%`);
} else {
whereConditions.push(`${safeColumn} = $${paramIndex}`);
searchValues.push(value);
}
paramIndex++;
}
}
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(' AND ')}`
: '';
// ORDER BY 조건 구성
let orderClause = '';
if (sortBy) {
const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, '');
const safeSortOrder = sortOrder.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
}
// 안전한 테이블명 검증
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, '');
// 전체 개수 조회
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
const countResult = await prisma.$queryRawUnsafe<any[]>(countQuery, ...searchValues);
const total = parseInt(countResult[0].count);
// 데이터 조회
const dataQuery = `
SELECT * FROM ${safeTableName}
${whereClause}
${orderClause}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const data = await prisma.$queryRawUnsafe<any[]>(
dataQuery,
...searchValues,
size,
offset
);
const totalPages = Math.ceil(total / size);
logger.info(`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`);
return {
data,
total,
page,
size,
totalPages
};
} catch (error) {
logger.error(`테이블 데이터 조회 오류: ${tableName}`, error);
throw error;
}
}
}

View File

@@ -35,25 +35,31 @@
#### ✅ 완료된 주요 기능들
- **컴포넌트 관리 시스템**: 드래그앤드롭, 다중 선택, 그룹 드래그, 실시간 위치 업데이트
- **속성 편집 시스템**: 실시간 속성 편집, 라벨 관리 (텍스트, 폰트, 색상, 여백), 필수 입력 시 주황색 \* 표시
- **⚡ 실시간 속성 편집 시스템**: 로컬 상태 기반 즉시 반영, 완벽한 입력/체크박스 실시간 업데이트
- **속성 편집 시스템**: 라벨 관리 (텍스트, 폰트, 색상, 여백), 필수 입력 시 주황색 \* 표시
- **격자 시스템**: 동적 격자 설정, 컴포넌트 스냅 및 크기 조정
- **패널 관리**: 플로팅 패널, 수동 크기 조정, 위치 기억
- **웹타입 지원**: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file
- **데이터 테이블 컴포넌트**: 완전한 실시간 설정 시스템, 컬럼 관리, 필터링, 페이징
- **🆕 실시간 데이터 테이블**: 실제 PostgreSQL 데이터 조회, 웹타입별 검색 필터, 페이지네이션, 데이터 포맷팅
#### 🔧 해결된 기술적 문제들
- **⚡ 실시간 속성 편집 완성**: 로컬 상태 기반 이중 관리 시스템으로 완벽한 실시간 반영
- **체크박스 실시간 업데이트**: 모든 체크박스의 즉시 상태 변경 및 유지
- **동적 컴포넌트 상태 관리**: ID 기반 컬럼별 개별 상태 관리 및 동기화
- **라벨 하단 여백 동적 적용**: 여백값에 따른 정확한 위치 계산
- **스타일 속성 개별 업데이트**: 초기화 방지를 위한 `style.propertyName` 방식 적용
- **체크박스 실시간 반영**: 로컬 상태 + 실제 속성 동시 업데이트
- **다중 드래그 최적화**: 지연 없는 실시간 미리보기, 선택 해제 방지
- **입력 필드 실시간 적용**: debounce 제거, 즉시 반영 시스템
- **입력값 보존 시스템**: 패널 재오픈해도 사용자 입력값 완벽 유지
#### 🎯 개발 진행 상황
- **현재 완성도**: 95% (핵심 기능 완료)
- **현재 완성도**: 98% (실시간 편집 시스템 완성, 핵심 기능 완료)
- **기술 스택**: Next.js 15.4.4, TypeScript, Tailwind CSS, Shadcn/ui
- **상태 관리**: React Hooks 기반 로컬 상태 + 실시간 업데이트 패턴
- **상태 관리**: **완성된 실시간 속성 편집 패턴** - 로컬 상태 + 글로벌 상태 이중 관리
- **드래그앤드롭**: HTML5 Drag & Drop API 기반 고도화된 시스템
- **🎯 표준화**: 모든 속성 편집 컴포넌트에 실시간 패턴 적용 완료
### 🎯 **현재 테이블 구조와 100% 호환**
@@ -182,6 +188,187 @@
4. **설정 저장**: 화면 정의를 데이터베이스에 저장
5. **런타임 생성**: 실제 서비스 화면을 동적으로 생성
## ⚡ 실시간 속성 편집 시스템
### 개요
화면관리 시스템의 핵심 기능 중 하나인 실시간 속성 편집은 사용자가 컴포넌트의 속성을 수정할 때 즉시 화면에 반영되는 시스템입니다. 이 시스템은 **로컬 상태 기반 입력 관리**와 **실시간 업데이트 패턴**을 통해 구현되었습니다.
### 🎯 핵심 아키텍처 패턴
#### 1. 로컬 상태 + 글로벌 상태 이중 관리
```typescript
// 1단계: 로컬 상태 정의 (실시간 표시용)
const [localInputs, setLocalInputs] = useState({
title: component.title || "",
placeholder: component.placeholder || "",
// 모든 입력 필드의 현재 값
});
const [localValues, setLocalValues] = useState({
showButton: component.showButton ?? true,
enabled: component.enabled ?? false,
// 모든 체크박스의 현재 상태
});
// 2단계: 컴포넌트 변경 시 자동 동기화
useEffect(() => {
setLocalInputs({
title: component.title || "",
placeholder: component.placeholder || "",
});
setLocalValues({
showButton: component.showButton ?? true,
enabled: component.enabled ?? false,
});
}, [component.title, component.placeholder, component.showButton]);
```
#### 2. 실시간 입력 처리 패턴
```typescript
// 텍스트 입력 - 즉시 반영
<Input
value={localInputs.title}
onChange={(e) => {
const newValue = e.target.value;
// 1) 로컬 상태 즉시 업데이트 (화면 반영)
setLocalInputs(prev => ({ ...prev, title: newValue }));
// 2) 글로벌 상태 업데이트 (데이터 저장)
onUpdateProperty("title", newValue);
}}
/>
// 체크박스 - 즉시 반영
<Checkbox
checked={localValues.showButton}
onCheckedChange={(checked) => {
// 1) 로컬 상태 즉시 업데이트
setLocalValues(prev => ({ ...prev, showButton: checked as boolean }));
// 2) 글로벌 상태 업데이트
onUpdateProperty("showButton", checked);
}}
/>
```
#### 3. 동적 컴포넌트별 상태 관리
```typescript
// 컬럼별 개별 상태 관리 (ID 기반)
const [localColumnInputs, setLocalColumnInputs] = useState<
Record<string, string>
>({});
const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState<
Record<string, { visible: boolean; sortable: boolean; searchable: boolean }>
>({});
// 기존 값 보존하면서 새 항목만 추가
useEffect(() => {
setLocalColumnInputs((prev) => {
const newInputs = { ...prev };
component.columns?.forEach((col) => {
if (!(col.id in newInputs)) {
// 기존 입력값 보존
newInputs[col.id] = col.label;
}
});
return newInputs;
});
}, [component.columns]);
// 동적 입력 처리
<Input
value={
localColumnInputs[column.id] !== undefined
? localColumnInputs[column.id]
: column.label
}
onChange={(e) => {
const newValue = e.target.value;
setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue }));
updateColumn(column.id, { label: newValue });
}}
/>;
```
### 🔧 구현 표준 가이드라인
#### 필수 구현 패턴
1. **로컬 우선 원칙**: 모든 입력은 로컬 상태를 먼저 업데이트
2. **즉시 반영**: 로컬 상태 업데이트와 동시에 컴포넌트 속성 업데이트
3. **기존값 보존**: useEffect에서 기존 로컬 입력값이 있으면 덮어쓰지 않음
4. **완전한 정리**: 항목 삭제 시 관련된 모든 로컬 상태도 함께 정리
5. **타입 안전성**: 모든 상태에 정확한 TypeScript 타입 지정
#### 항목 추가/삭제 시 상태 관리
```typescript
// 추가 시
const addItem = useCallback(
(newItem) => {
// 로컬 상태에 즉시 추가
setLocalColumnInputs((prev) => ({
...prev,
[newItem.id]: newItem.label,
}));
setLocalColumnCheckboxes((prev) => ({
...prev,
[newItem.id]: { visible: true, sortable: true, searchable: true },
}));
// 실제 컴포넌트 업데이트
onUpdateComponent({ items: [...component.items, newItem] });
},
[component.items, onUpdateComponent]
);
// 삭제 시
const removeItem = useCallback(
(itemId) => {
// 로컬 상태에서 제거
setLocalColumnInputs((prev) => {
const newInputs = { ...prev };
delete newInputs[itemId];
return newInputs;
});
setLocalColumnCheckboxes((prev) => {
const newCheckboxes = { ...prev };
delete newCheckboxes[itemId];
return newCheckboxes;
});
// 실제 컴포넌트 업데이트
const updatedItems = component.items.filter((item) => item.id !== itemId);
onUpdateComponent({ items: updatedItems });
},
[component.items, onUpdateComponent]
);
```
### 📊 적용 범위
이 패턴은 화면관리 시스템의 다음 컴포넌트들에 적용되었습니다:
- **PropertiesPanel**: 기본 속성 편집 (위치, 크기, 라벨 등)
- **DataTableConfigPanel**: 데이터 테이블 상세 설정
- **DateTypeConfigPanel**: 날짜 타입 상세 설정
- **NumberTypeConfigPanel**: 숫자 타입 상세 설정
- **SelectTypeConfigPanel**: 선택박스 타입 상세 설정
- **TextTypeConfigPanel**: 텍스트 타입 상세 설정
- **기타 모든 웹타입별 설정 패널들**
### 🎯 사용자 경험 향상 효과
- **🚀 즉시 피드백**: 타이핑하는 순간 화면에 바로 반영
- **🔄 상태 일관성**: 패널을 닫았다 열어도 입력한 값이 정확히 유지
- **⚡ 빠른 반응성**: 지연 없는 실시간 UI 업데이트
- **🛡️ 안정성**: 메모리 누수 없는 완전한 상태 관리
## 🚀 핵심 기능
### 1. 화면 설계기 (Screen Designer)
@@ -1760,6 +1947,271 @@ export default function GridItem({
## ⚙️ 백엔드 구현
### 🆕 데이터 테이블 실시간 조회 API (2025.09 추가)
#### 1. 테이블 데이터 조회 API
**라우트 설정**
```typescript
// tableManagementRoutes.ts
/**
* 테이블 데이터 조회 (페이징 + 검색)
* POST /api/table-management/tables/:tableName/data
*/
router.post("/tables/:tableName/data", getTableData);
```
**컨트롤러 구현**
```typescript
// tableManagementController.ts
export async function getTableData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const {
page = 1,
size = 10,
search = {},
sortBy,
sortOrder = "asc",
} = req.body;
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
logger.info(`페이징: page=${page}, size=${size}`);
logger.info(`검색 조건:`, search);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: { code: "MISSING_TABLE_NAME" },
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 데이터 조회
const result = await tableManagementService.getTableData(tableName, {
page: parseInt(page),
size: parseInt(size),
search,
sortBy,
sortOrder,
});
const response: ApiResponse<any> = {
success: true,
message: "테이블 데이터를 성공적으로 조회했습니다.",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 데이터 조회 중 오류 발생:", error);
// 오류 응답 처리
}
}
```
**서비스 로직**
```typescript
// tableManagementService.ts
export class TableManagementService {
/**
* 테이블 데이터 조회 (페이징 + 검색)
*/
async getTableData(
tableName: string,
options: {
page: number;
size: number;
search?: Record<string, any>;
sortBy?: string;
sortOrder?: string;
}
): Promise<{
data: any[];
total: number;
page: number;
size: number;
totalPages: number;
}> {
try {
const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
const offset = (page - 1) * size;
// WHERE 조건 구성 (SQL 인젝션 방지)
let whereConditions: string[] = [];
let searchValues: any[] = [];
let paramIndex = 1;
if (search && Object.keys(search).length > 0) {
for (const [column, value] of Object.entries(search)) {
if (value !== null && value !== undefined && value !== "") {
// 안전한 컬럼명 검증
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
if (typeof value === "string") {
whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`);
searchValues.push(`%${value}%`);
} else {
whereConditions.push(`${safeColumn} = $${paramIndex}`);
searchValues.push(value);
}
paramIndex++;
}
}
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// ORDER BY 조건 구성
let orderClause = "";
if (sortBy) {
const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, "");
const safeSortOrder =
sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
}
// 안전한 테이블명 검증
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
// 전체 개수 조회
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
const countResult = await prisma.$queryRawUnsafe<any[]>(
countQuery,
...searchValues
);
const total = parseInt(countResult[0].count);
// 데이터 조회
const dataQuery = `
SELECT * FROM ${safeTableName}
${whereClause}
${orderClause}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const data = await prisma.$queryRawUnsafe<any[]>(
dataQuery,
...searchValues,
size,
offset
);
const totalPages = Math.ceil(total / size);
return {
data,
total,
page,
size,
totalPages,
};
} catch (error) {
logger.error(`테이블 데이터 조회 오류: ${tableName}`, error);
throw error;
}
}
}
```
#### 2. API 응답 형식
**성공 응답**
```json
{
"success": true,
"message": "테이블 데이터를 성공적으로 조회했습니다.",
"data": {
"data": [
{
"objid": 1,
"target_objid": 12345,
"approval_seq": "A001",
"regdate": "2025-09-03T05:39:14.000Z",
"status": "pending"
}
],
"total": 1,
"page": 1,
"size": 5,
"totalPages": 1
}
}
```
**요청 형식**
```json
{
"page": 1,
"size": 10,
"search": {
"approval_seq": "A001",
"status": "pending"
},
"sortBy": "regdate",
"sortOrder": "desc"
}
```
#### 3. 보안 및 성능 최적화
**SQL 인젝션 방지**
- 정규표현식을 통한 안전한 컬럼명/테이블명 검증
- 파라미터 바인딩 사용 (`$queryRawUnsafe` with parameters)
- 사용자 입력값 필터링
**성능 최적화**
- 페이징 처리로 대용량 데이터 대응
- COUNT 쿼리와 데이터 쿼리 분리
- 인덱스 기반 정렬 지원
**에러 처리**
- 상세한 로깅 시스템
- 사용자 친화적 오류 메시지
- HTTP 상태 코드 준수
#### 4. 도커 환경 통합
**개발 환경 설정**
```bash
# 백엔드 컨테이너 재빌드 (새 API 반영)
docker-compose -f docker/dev/docker-compose.backend.mac.yml down backend
docker-compose -f docker/dev/docker-compose.backend.mac.yml up --build -d backend
# API 테스트
curl -X POST http://localhost:8080/api/table-management/tables/approval/data \
-H "Content-Type: application/json" \
-d '{"page": 1, "size": 5}'
```
**환경 변수**
```env
# backend-node/.env
PORT=8080
DATABASE_URL=postgresql://postgres:password@localhost:5432/ilshin
NODE_ENV=development
```
### 1. 화면 관리 서비스
```typescript
@@ -2376,6 +2828,350 @@ export class TableTypeIntegrationService {
- **저장 기능**: 입력된 데이터를 수집하여 저장 처리
- **메뉴 연동**: 메뉴 클릭 시 할당된 인터랙티브 화면으로 자동 이동
### 🆕 6. 실시간 데이터 테이블 (2025.09 추가)
#### **InteractiveDataTable 컴포넌트**
실제 화면에서 동작하는 완전한 데이터 테이블 구현
**핵심 기능**
- **실시간 데이터 조회**: PostgreSQL 데이터베이스에서 직접 데이터 로드
- **페이지네이션**: 대용량 데이터 효율적 탐색 (페이지당 항목 수 설정 가능)
- **다중 검색 필터**: 웹타입별 맞춤형 검색 UI (text, number, date, select 등)
- **정렬 기능**: 컬럼별 오름차순/내림차순 정렬 지원
- **반응형 레이아웃**: 격자 시스템 기반 컬럼 너비 조정
**구현 코드**
```typescript
// InteractiveDataTable.tsx
export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
component,
className = "",
style = {},
}) => {
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
// 데이터 로드 함수
const loadData = useCallback(
async (page: number = 1, searchParams: Record<string, any> = {}) => {
if (!component.tableName) return;
setLoading(true);
try {
const result = await tableTypeApi.getTableData(component.tableName, {
page,
size: component.pagination?.pageSize || 10,
search: searchParams,
});
setData(result.data);
setTotal(result.total);
setTotalPages(result.totalPages);
setCurrentPage(result.page);
} catch (error) {
console.error("❌ 테이블 데이터 조회 실패:", error);
setData([]);
} finally {
setLoading(false);
}
},
[component.tableName, component.pagination?.pageSize]
);
// 검색 실행
const handleSearch = useCallback(() => {
loadData(1, searchValues);
}, [searchValues, loadData]);
return (
<div className={`bg-white ${className}`} style={style}>
{/* 헤더: 제목, 로딩 상태, 검색 버튼 */}
<div className="border-b bg-gray-50 px-4 py-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">
{component.title || component.label}
{loading && (
<Badge variant="secondary" className="ml-2">
로딩중...
</Badge>
)}
</h3>
<div className="flex items-center space-x-2">
{component.showSearchButton && (
<Button size="sm" onClick={handleSearch} disabled={loading}>
{component.searchButtonText || "검색"}
</Button>
)}
</div>
</div>
{/* 검색 필터 */}
{component.filters?.length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-xs font-medium text-gray-700">검색 필터</div>
<div
className="grid gap-2"
style={{
gridTemplateColumns: component.filters
.map((filter) => `${filter.gridColumns || 3}fr`)
.join(" "),
}}
>
{component.filters.map((filter) => renderSearchFilter(filter))}
</div>
</div>
)}
</div>
{/* 테이블 데이터 */}
<div className="flex-1 overflow-auto p-4">
{/* 헤더 행 */}
<div
className="gap-2 border-b pb-2 text-xs font-medium text-gray-700"
style={{
display: "grid",
gridTemplateColumns: component.columns
?.filter((col) => col.visible)
.map((col) => `${col.gridColumns || 2}fr`)
.join(" "),
}}
>
{component.columns
?.filter((col) => col.visible)
.map((column) => (
<div key={column.id} className="truncate">
{column.label}
</div>
))}
</div>
{/* 데이터 행들 */}
{loading ? (
<div className="py-8 text-center text-xs text-gray-500">
데이터를 불러오는 중...
</div>
) : data.length > 0 ? (
data.map((row, rowIndex) => (
<div
key={rowIndex}
className="gap-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
style={{
display: "grid",
gridTemplateColumns: component.columns
?.filter((col) => col.visible)
.map((col) => `${col.gridColumns || 2}fr`)
.join(" "),
}}
>
{component.columns
?.filter((col) => col.visible)
.map((column) => (
<div key={column.id} className="truncate">
{formatCellValue(row[column.columnName], column)}
</div>
))}
</div>
))
) : (
<div className="py-8 text-center text-xs text-gray-500">
검색 결과가 없습니다
</div>
)}
</div>
{/* 페이지네이션 */}
{component.pagination?.enabled && totalPages > 1 && (
<div className="border-t bg-gray-50 px-4 py-2">
<div className="flex items-center justify-between text-xs text-gray-600">
{component.pagination.showPageInfo && (
<div>
총 {total.toLocaleString()}개 중{" "}
{((currentPage - 1) * pageSize + 1).toLocaleString()}-
{Math.min(currentPage * pageSize, total).toLocaleString()}
</div>
)}
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
이전
</Button>
<span className="px-2">
{currentPage} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
다음
</Button>
</div>
</div>
</div>
)}
</div>
);
};
```
**데이터 포맷팅**
```typescript
// 셀 값 포맷팅
const formatCellValue = (value: any, column: DataTableColumn) => {
if (value === null || value === undefined) return "";
switch (column.widgetType) {
case "date":
if (value) {
try {
const date = new Date(value);
return date.toLocaleDateString("ko-KR");
} catch {
return value;
}
}
break;
case "datetime":
if (value) {
try {
const date = new Date(value);
return date.toLocaleString("ko-KR");
} catch {
return value;
}
}
break;
case "number":
case "decimal":
if (typeof value === "number") {
return value.toLocaleString();
}
break;
default:
return String(value);
}
return String(value);
};
```
**InteractiveScreenViewer 통합**
```typescript
// InteractiveScreenViewer.tsx
const renderInteractiveWidget = (comp: ComponentData) => {
// 데이터 테이블 컴포넌트 처리
if (comp.type === "datatable") {
return (
<InteractiveDataTable
component={comp as DataTableComponent}
className="h-full w-full"
style={{
width: "100%",
height: "100%",
}}
/>
);
}
// 기존 위젯 처리...
};
```
#### **사용자 경험 (UX)**
**1. 관리자 (화면 설계)**
1. 드래그앤드롭으로 데이터 테이블 컴포넌트 배치
2. 속성 패널에서 테이블 선택 및 컬럼 설정
3. 검색 필터 추가 및 페이지네이션 설정
4. 실시간 미리보기로 결과 확인
**2. 사용자 (실제 화면)**
1. 메뉴를 통해 할당된 화면 접근
2. 검색 필터로 원하는 데이터 필터링
3. 페이지네이션으로 대용량 데이터 탐색
4. 실시간 데이터 로딩 및 정렬
#### **🎨 Shadcn UI 기반 모던 디자인**
**핵심 컴포넌트**
- **Card**: 전체 테이블을 감싸는 메인 컨테이너
- **Table**: Shadcn Table 컴포넌트로 표준화된 테이블 UI
- **Badge**: 로딩 상태 및 필터 개수 표시
- **Button**: 일관된 액션 버튼 디자인
- **Separator**: 섹션 구분선
**디자인 특징**
```typescript
// 메인 카드 레이아웃
<Card className="h-full flex flex-col">
<CardHeader className="pb-3">
{/* 아이콘 + 제목 + 액션 버튼들 */}
<div className="flex items-center space-x-2">
<Database className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-lg">{title}</CardTitle>
{loading && (
<Badge variant="secondary" className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
로딩중...
</Badge>
)}
</div>
</CardHeader>
{/* Shadcn Table 사용 */}
<CardContent className="flex-1 overflow-hidden p-0">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead className="font-semibold">{column.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>{/* 로딩, 데이터, 빈 상태 처리 */}</TableBody>
</Table>
</CardContent>
</Card>
```
**시각적 개선사항**
- **아이콘 통합**: Lucide React 아이콘으로 시각적 일관성
- **로딩 애니메이션**: 스피너 아이콘으로 실시간 피드백
- **상태별 메시지**: 빈 데이터, 로딩, 에러 상태별 적절한 UI
- **호버 효과**: 테이블 행 호버 시 시각적 피드백
- **반응형 버튼**: 아이콘 + 텍스트 조합으로 명확한 액션 표시
#### **기술적 특징**
- **성능 최적화**: React.useMemo를 활용한 메모이제이션
- **보안**: SQL 인젝션 방지 및 입력값 검증
- **확장성**: 웹타입별 검색 필터 및 데이터 포맷터
- **반응형**: CSS Grid 기반 유연한 레이아웃
- **접근성**: Shadcn UI의 WAI-ARIA 표준 준수
- **타입 안전성**: TypeScript 완전 지원
## 🚀 다음 단계 계획
### 1. 웹타입별 상세 설정 기능 구현 (진행 예정)
@@ -2437,7 +3233,7 @@ interface SelectTypeConfig {
컴포넌트 데이터에 webTypeConfig 포함하여 레이아웃 저장 시 설정값도 함께 저장
### 2. 컴포넌트 그룹화 기능 (완료)
### 2. 컴포넌트 그룹화 기능 (완료)0
- [x] 여러 위젯을 컨테이너로 그룹화
- [x] 부모-자식 관계 설정(parentId)
@@ -2533,24 +3329,100 @@ interface SelectTypeConfig {
## 🔧 핵심 기술적 구현 패턴
### 1. 상태 관리 패턴
### 1. ⚡ 실시간 속성 편집 패턴 (핵심 표준)
#### 로컬 상태 + 실시간 업데이트 패턴
#### 완성된 로컬 상태 + 글로벌 상태 이중 관리 시스템
PropertiesPanel에서 사용하는 입력 필드 관리 방식:
**모든 속성 편집 컴포넌트의 표준 패턴:**
```typescript
// 1단계: 로컬 상태 정의 (실시간 표시용)
const [localInputs, setLocalInputs] = useState({
placeholder: selectedComponent?.placeholder || "",
// ... 기타 필드들
title: component.title || "",
placeholder: component.placeholder || "",
// 모든 입력 필드의 현재 값
});
// 입력 시 로컬 상태 + 실제 컴포넌트 동시 업데이트
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, fieldName: newValue }));
onUpdateProperty("fieldName", newValue);
}}
const [localValues, setLocalValues] = useState({
showButton: component.showButton ?? true,
enabled: component.enabled ?? false,
// 모든 체크박스의 현재 상태
});
// 2단계: 컴포넌트 변경 시 자동 동기화
useEffect(() => {
setLocalInputs({
title: component.title || "",
placeholder: component.placeholder || "",
});
setLocalValues({
showButton: component.showButton ?? true,
enabled: component.enabled ?? false,
});
}, [component.title, component.placeholder, component.showButton]);
// 3단계: 실시간 입력 처리 - 즉시 반영
<Input
value={localInputs.title}
onChange={(e) => {
const newValue = e.target.value;
// 1) 로컬 상태 즉시 업데이트 (화면 반영)
setLocalInputs(prev => ({ ...prev, title: newValue }));
// 2) 글로벌 상태 업데이트 (데이터 저장)
onUpdateProperty("title", newValue);
}}
/>
<Checkbox
checked={localValues.showButton}
onCheckedChange={(checked) => {
// 1) 로컬 상태 즉시 업데이트
setLocalValues(prev => ({ ...prev, showButton: checked as boolean }));
// 2) 글로벌 상태 업데이트
onUpdateProperty("showButton", checked);
}}
/>
```
#### 동적 컴포넌트별 상태 관리 (ID 기반)
```typescript
// 컬럼별 개별 상태 관리
const [localColumnInputs, setLocalColumnInputs] = useState<
Record<string, string>
>({});
const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState<
Record<string, { visible: boolean; sortable: boolean; searchable: boolean }>
>({});
// 기존 값 보존하면서 새 항목만 추가
useEffect(() => {
setLocalColumnInputs((prev) => {
const newInputs = { ...prev };
component.columns?.forEach((col) => {
if (!(col.id in newInputs)) {
// 기존 입력값 보존
newInputs[col.id] = col.label;
}
});
return newInputs;
});
}, [component.columns]);
// 동적 입력 처리
<Input
value={
localColumnInputs[column.id] !== undefined
? localColumnInputs[column.id]
: column.label
}
onChange={(e) => {
const newValue = e.target.value;
setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue }));
updateColumn(column.id, { label: newValue });
}}
/>;
```
#### 스타일 속성 개별 업데이트 패턴
@@ -2565,6 +3437,18 @@ onUpdateProperty("style", { ...selectedComponent.style, newProp: value });
onUpdateProperty("style.labelFontSize", value);
```
### 📋 적용된 컴포넌트 목록
이 패턴이 완벽하게 적용된 컴포넌트들:
- **PropertiesPanel**: 기본 속성 편집
- **DataTableConfigPanel**: 데이터 테이블 상세 설정
- **DateTypeConfigPanel**: 날짜 타입 상세 설정
- **NumberTypeConfigPanel**: 숫자 타입 상세 설정
- **SelectTypeConfigPanel**: 선택박스 타입 상세 설정
- **TextTypeConfigPanel**: 텍스트 타입 상세 설정
- **기타 모든 웹타입별 설정 패널들**
### 2. 드래그앤드롭 패턴
#### 다중 컴포넌트 드래그 처리

View File

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

View File

@@ -0,0 +1,438 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator";
import { Search, ChevronLeft, ChevronRight, RotateCcw, Database, Loader2 } from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen";
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen";
import { cn } from "@/lib/utils";
interface InteractiveDataTableProps {
component: DataTableComponent;
className?: string;
style?: React.CSSProperties;
}
export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
component,
className = "",
style = {},
}) => {
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
// 검색 가능한 컬럼만 필터링
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
const searchFilters = component.filters || [];
// 그리드 컬럼 계산
const totalGridColumns = visibleColumns.reduce((sum, col) => sum + (col.gridColumns || 2), 0);
// 페이지 크기 설정
const pageSize = component.pagination?.pageSize || 10;
// 데이터 로드 함수
const loadData = useCallback(
async (page: number = 1, searchParams: Record<string, any> = {}) => {
if (!component.tableName) return;
setLoading(true);
try {
console.log("🔍 테이블 데이터 조회:", {
tableName: component.tableName,
page,
pageSize,
searchParams,
});
const result = await tableTypeApi.getTableData(component.tableName, {
page,
size: pageSize,
search: searchParams,
});
console.log("✅ 테이블 데이터 조회 결과:", result);
setData(result.data);
setTotal(result.total);
setTotalPages(result.totalPages);
setCurrentPage(result.page);
} catch (error) {
console.error("❌ 테이블 데이터 조회 실패:", error);
setData([]);
setTotal(0);
setTotalPages(1);
} finally {
setLoading(false);
}
},
[component.tableName, pageSize],
);
// 초기 데이터 로드
useEffect(() => {
loadData(1, searchValues);
}, [loadData]);
// 검색 실행
const handleSearch = useCallback(() => {
console.log("🔍 검색 실행:", searchValues);
loadData(1, searchValues);
}, [searchValues, loadData]);
// 검색값 변경
const handleSearchValueChange = useCallback((columnName: string, value: any) => {
setSearchValues((prev) => ({
...prev,
[columnName]: value,
}));
}, []);
// 페이지 변경
const handlePageChange = useCallback(
(page: number) => {
loadData(page, searchValues);
},
[loadData, searchValues],
);
// 검색 필터 렌더링
const renderSearchFilter = (filter: DataTableFilter) => {
const value = searchValues[filter.columnName] || "";
switch (filter.widgetType) {
case "text":
case "email":
case "tel":
return (
<Input
key={filter.columnName}
placeholder={`${filter.label} 검색...`}
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
/>
);
case "number":
case "decimal":
return (
<Input
key={filter.columnName}
type="number"
placeholder={`${filter.label} 입력...`}
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
/>
);
case "date":
return (
<Input
key={filter.columnName}
type="date"
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
/>
);
case "datetime":
return (
<Input
key={filter.columnName}
type="datetime-local"
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
/>
);
case "select":
// TODO: 선택 옵션은 추후 구현
return (
<Select
key={filter.columnName}
value={value}
onValueChange={(newValue) => handleSearchValueChange(filter.columnName, newValue)}
>
<SelectTrigger>
<SelectValue placeholder={`${filter.label} 선택...`} />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
{/* TODO: 동적 옵션 로드 */}
</SelectContent>
</Select>
);
default:
return (
<Input
key={filter.columnName}
placeholder={`${filter.label} 검색...`}
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
/>
);
}
};
// 셀 값 포맷팅
const formatCellValue = (value: any, column: DataTableColumn) => {
if (value === null || value === undefined) return "";
switch (column.widgetType) {
case "date":
if (value) {
try {
const date = new Date(value);
return date.toLocaleDateString("ko-KR");
} catch {
return value;
}
}
break;
case "datetime":
if (value) {
try {
const date = new Date(value);
return date.toLocaleString("ko-KR");
} catch {
return value;
}
}
break;
case "number":
case "decimal":
if (typeof value === "number") {
return value.toLocaleString();
}
break;
default:
return String(value);
}
return String(value);
};
return (
<Card className={cn("flex h-full flex-col", className)} style={{ ...style, minHeight: "680px" }}>
{/* 헤더 */}
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Database className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-lg">{component.title || component.label}</CardTitle>
{loading && (
<Badge variant="secondary" className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
...
</Badge>
)}
</div>
<div className="flex items-center space-x-2">
{searchFilters.length > 0 && (
<Badge variant="outline" className="text-xs">
<Search className="mr-1 h-3 w-3" />
{searchFilters.length}
</Badge>
)}
{component.showSearchButton && (
<Button size="sm" onClick={handleSearch} disabled={loading} className="gap-2">
<Search className="h-3 w-3" />
{component.searchButtonText || "검색"}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => loadData(1, {})} disabled={loading} className="gap-2">
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
{/* 검색 필터 */}
{searchFilters.length > 0 && (
<>
<Separator className="my-2" />
<div className="space-y-3">
<CardDescription className="flex items-center gap-2">
<Search className="h-3 w-3" />
</CardDescription>
<div
className="grid gap-3"
style={{
gridTemplateColumns: searchFilters
.map((filter: DataTableFilter) => `${filter.gridColumns || 3}fr`)
.join(" "),
}}
>
{searchFilters.map((filter: DataTableFilter) => (
<div key={filter.columnName} className="space-y-1">
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
{renderSearchFilter(filter)}
</div>
))}
</div>
</div>
</>
)}
</CardHeader>
{/* 테이블 내용 */}
<CardContent className="flex-1 p-0">
<div className="flex h-full flex-col">
{visibleColumns.length > 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
{visibleColumns.map((column: DataTableColumn) => (
<TableHead
key={column.id}
className="px-4 font-semibold"
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
>
{column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="h-32 text-center">
<div className="text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
</TableCell>
</TableRow>
) : data.length > 0 ? (
data.map((row, rowIndex) => (
<TableRow key={rowIndex} className="hover:bg-muted/50">
{visibleColumns.map((column: DataTableColumn) => (
<TableCell key={column.id} className="px-4 font-mono text-sm">
{formatCellValue(row[column.columnName], column)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="h-32 text-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Database className="h-8 w-8" />
<p> </p>
<p className="text-xs"> </p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* 페이지네이션 */}
{component.pagination?.enabled && totalPages > 1 && (
<div className="bg-muted/20 mt-auto border-t">
<div className="flex items-center justify-between px-6 py-3">
{component.pagination.showPageInfo && (
<div className="text-muted-foreground text-sm">
<span className="font-medium">{total.toLocaleString()}</span> {" "}
<span className="font-medium">{((currentPage - 1) * pageSize + 1).toLocaleString()}</span>-
<span className="font-medium">{Math.min(currentPage * pageSize, total).toLocaleString()}</span>
</div>
)}
<div className="flex items-center space-x-2">
{component.pagination.showFirstLast && (
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
className="gap-1"
>
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="gap-1"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<div className="flex items-center gap-1 text-sm font-medium">
<span>{currentPage}</span>
<span className="text-muted-foreground">/</span>
<span>{totalPages}</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || loading}
className="gap-1"
>
<ChevronRight className="h-3 w-3" />
</Button>
{component.pagination.showFirstLast && (
<Button
size="sm"
variant="outline"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages || loading}
className="gap-1"
>
</Button>
)}
</div>
</div>
</div>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Database className="h-8 w-8" />
<p className="text-sm"> </p>
<p className="text-xs"> </p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -14,6 +14,7 @@ import { ko } from "date-fns/locale";
import {
ComponentData,
WidgetComponent,
DataTableComponent,
TextTypeConfig,
NumberTypeConfig,
DateTypeConfig,
@@ -25,6 +26,7 @@ import {
CodeTypeConfig,
EntityTypeConfig,
} from "@/types/screen";
import { InteractiveDataTable } from "./InteractiveDataTable";
interface InteractiveScreenViewerProps {
component: ComponentData;
@@ -70,6 +72,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 실제 사용 가능한 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => {
// 데이터 테이블 컴포넌트 처리
if (comp.type === "datatable") {
return (
<InteractiveDataTable
component={comp as DataTableComponent}
className="h-full w-full"
style={{
width: "100%",
height: "100%",
}}
/>
);
}
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
const fieldName = columnName || comp.id;
const currentValue = formData[fieldName] || "";
@@ -686,18 +702,40 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
// 일반 위젯 컴포넌트
// 템플릿 컴포넌트 목록 (자체적으로 제목을 가지므로 라벨 불필요)
const templateTypes = ["datatable"];
// 라벨 표시 여부 계산
const shouldShowLabel =
component.style?.labelDisplay !== false &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
const labelText = component.style?.labelText || component.label || "";
// 라벨 스타일 적용
const labelStyle = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0",
marginBottom: component.style?.labelMarginBottom || "4px",
};
return (
<div className="h-full w-full">
{/* 라벨이 있는 경우 표시 */}
{component.label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{component.label}
{component.required && <span className="ml-1 text-red-500">*</span>}
</label>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<div className="block" style={labelStyle}>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* 실제 위젯 */}
<div className={component.label ? "flex-1" : "h-full w-full"}>{renderInteractiveWidget(component)}</div>
<div className={shouldShowLabel ? "flex-1" : "h-full w-full"}>{renderInteractiveWidget(component)}</div>
</div>
);
};

View File

@@ -19,6 +19,11 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
// import { Checkbox } from "@/components/ui/checkbox";
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
@@ -36,6 +41,8 @@ import {
Group,
ChevronDown,
ChevronRight,
Search,
RotateCcw,
} from "lucide-react";
interface RealtimePreviewProps {
@@ -665,16 +672,17 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 선택 테두리는 사용자 테두리가 없을 때만 적용
const defaultRingClass = hasCustomBorder
? ""
: isSelected
? "ring-opacity-50 ring-2 ring-blue-500"
: "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300";
// 기본 선택 테두리는 사용자 테두리가 없을 때만 적용 (데이터 테이블 제외)
const defaultRingClass =
hasCustomBorder || type === "datatable"
? ""
: isSelected
? "ring-opacity-50 ring-2 ring-blue-500"
: "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300";
// 사용자 테두리가 있을 때 선택 상태 표시를 위한 스타일
// 사용자 테두리가 있을 때 또는 데이터 테이블일 때 선택 상태 표시를 위한 스타일
const selectionStyle =
hasCustomBorder && isSelected
(hasCustomBorder || type === "datatable") && isSelected
? {
boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)", // 외부 그림자로 선택 표시
...style,
@@ -699,6 +707,211 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
borderRadius: component.style?.labelBorderRadius || "0",
};
// 데이터 테이블은 특별한 구조로 렌더링
if (type === "datatable") {
const dataTableComponent = component as any; // DataTableComponent 타입
// 메모이제이션을 위한 계산 최적화
const visibleColumns = React.useMemo(
() => dataTableComponent.columns?.filter((col: any) => col.visible) || [],
[dataTableComponent.columns],
);
const filters = React.useMemo(() => dataTableComponent.filters || [], [dataTableComponent.filters]);
return (
<div
className="absolute cursor-move"
style={{
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${size.width}px`,
height: shouldShowLabel ? `${size.height + 20 + labelMarginBottomValue}px` : `${size.height}px`,
zIndex: component.position.z || 1,
...(isSelected ? { boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)" } : {}),
}}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
{/* 라벨 표시 */}
{shouldShowLabel && (
<div
className="pointer-events-none absolute left-0 w-full truncate"
style={{
...labelStyle,
top: `${-20 - labelMarginBottomValue}px`,
}}
>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* Shadcn UI 기반 데이터 테이블 */}
<Card
className="flex h-full w-full flex-col overflow-hidden"
style={{
width: `${size.width}px`,
height: `${size.height}px`,
}}
>
{/* 카드 헤더 */}
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Database className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-sm">{dataTableComponent.title || label}</CardTitle>
</div>
<div className="flex items-center space-x-2">
{filters.length > 0 && (
<Badge variant="outline" className="text-xs">
<Search className="mr-1 h-3 w-3" />
{filters.length}
</Badge>
)}
{dataTableComponent.showSearchButton && (
<Button size="sm" className="gap-1 text-xs">
<Search className="h-3 w-3" />
{dataTableComponent.searchButtonText || "검색"}
</Button>
)}
<Button size="sm" variant="outline" className="gap-1 text-xs">
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
{/* 필터 영역 미리보기 */}
{filters.length > 0 && (
<>
<Separator className="my-2" />
<div className="space-y-3">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Search className="h-3 w-3" />
</div>
<div
className="grid gap-3"
style={{
gridTemplateColumns: filters.map((filter: any) => `${filter.gridColumns || 3}fr`).join(" "),
}}
>
{filters.map((filter: any, index: number) => (
<div key={`filter-${index}`} className="space-y-1">
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
<div className="bg-background text-muted-foreground rounded border px-2 py-1 text-xs">
...
</div>
</div>
))}
</div>
</div>
</>
)}
</CardHeader>
{/* 테이블 내용 */}
<CardContent className="flex-1 p-0">
<div className="flex h-full flex-col">
{visibleColumns.length > 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
{visibleColumns.map((column: any) => (
<TableHead key={column.id} className="px-4 text-xs font-semibold">
{column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{/* 샘플 데이터 3행 */}
<TableRow className="hover:bg-muted/50">
{visibleColumns.map((column: any, colIndex: number) => (
<TableCell key={`sample1-${colIndex}`} className="px-4 font-mono text-xs">
1-{colIndex + 1}
</TableCell>
))}
</TableRow>
<TableRow className="hover:bg-muted/50">
{visibleColumns.map((column: any, colIndex: number) => (
<TableCell key={`sample2-${colIndex}`} className="px-4 font-mono text-xs">
2-{colIndex + 1}
</TableCell>
))}
</TableRow>
<TableRow className="hover:bg-muted/50">
{visibleColumns.map((column: any, colIndex: number) => (
<TableCell key={`sample3-${colIndex}`} className="px-4 font-mono text-xs">
3-{colIndex + 1}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
{/* 페이지네이션 미리보기 */}
{dataTableComponent.pagination?.enabled && (
<div className="bg-muted/20 mt-auto border-t">
<div className="flex items-center justify-between px-4 py-2">
{dataTableComponent.pagination.showPageInfo && (
<div className="text-muted-foreground text-xs">
<span className="font-medium">100</span> <span className="font-medium">1</span>-
<span className="font-medium">10</span>
</div>
)}
<div className="flex items-center space-x-2">
{dataTableComponent.pagination.showFirstLast && (
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
)}
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
<div className="flex items-center gap-1 text-xs font-medium">
<span>1</span>
<span className="text-muted-foreground">/</span>
<span>10</span>
</div>
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
{dataTableComponent.pagination.showFirstLast && (
<Button size="sm" variant="outline" className="gap-1 text-xs">
</Button>
)}
</div>
</div>
</div>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Database className="h-6 w-6" />
<p className="text-xs"> </p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
// 다른 컴포넌트들은 기존 구조 사용
return (
<div
className={`absolute cursor-move transition-all ${defaultRingClass}`}
@@ -755,6 +968,168 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
</div>
)}
{false &&
(() => {
const dataTableComponent = component as any; // DataTableComponent 타입
const visibleColumns = dataTableComponent.columns?.filter((col: any) => col.visible) || [];
const filters = dataTableComponent.filters || [];
return (
<>
{/* 데이터 테이블 헤더 */}
<div className="border-b bg-gray-50 px-4 py-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">{dataTableComponent.title || label}</h3>
<div className="flex items-center space-x-2">
{filters.length > 0 && <div className="text-xs text-gray-500"> {filters.length}</div>}
{dataTableComponent.showSearchButton && (
<button className="rounded bg-blue-600 px-3 py-1 text-xs text-white">
{dataTableComponent.searchButtonText || "검색"}
</button>
)}
</div>
</div>
{/* 필터 영역 미리보기 */}
{filters.length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-xs font-medium text-gray-700"> </div>
<div
className="grid gap-2"
style={{
gridTemplateColumns: filters.map((filter: any) => `${filter.gridColumns || 3}fr`).join(" "),
}}
>
{filters.map((filter: any, index: number) => {
const getFilterIcon = (webType: string) => {
switch (webType) {
case "text":
case "email":
case "tel":
return "📝";
case "number":
case "decimal":
return "🔢";
case "date":
case "datetime":
return "📅";
case "select":
return "📋";
default:
return "🔍";
}
};
const getFilterPlaceholder = (webType: string) => {
switch (webType) {
case "text":
return "텍스트 검색...";
case "email":
return "이메일 검색...";
case "tel":
return "전화번호 검색...";
case "number":
return "숫자 입력...";
case "decimal":
return "소수 입력...";
case "date":
return "날짜 선택...";
case "datetime":
return "날짜시간 선택...";
case "select":
return "옵션 선택...";
default:
return "검색...";
}
};
return (
<div key={index} className="text-xs">
<div className="mb-1 flex items-center space-x-1 text-gray-600">
<span>{getFilterIcon(filter.widgetType)}</span>
<span>{filter.label}</span>
</div>
<div className="rounded border bg-white px-2 py-1 text-gray-400">
{getFilterPlaceholder(filter.widgetType)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* 테이블 내용 미리보기 */}
<div className="flex-1 p-4">
<div className="space-y-2">
{/* 테이블 헤더 행 */}
{visibleColumns.length > 0 ? (
<div
className="gap-2 border-b pb-2 text-xs font-medium text-gray-700"
style={{
display: "grid",
gridTemplateColumns: visibleColumns.map((col: any) => `${col.gridColumns || 2}fr`).join(" "),
}}
>
{visibleColumns.map((column: any) => (
<div key={column.id} className="truncate">
{column.label}
</div>
))}
</div>
) : (
<div className="py-4 text-center text-xs text-gray-500"> </div>
)}
{/* 샘플 데이터 행들 */}
{visibleColumns.length > 0 &&
[1, 2, 3].map((row) => (
<div
key={row}
className="gap-2 py-1 text-xs text-gray-600"
style={{
display: "grid",
gridTemplateColumns: visibleColumns
.map((col: any) => `${col.gridColumns || 2}fr`)
.join(" "),
}}
>
{visibleColumns.map((column: any, colIndex: number) => (
<div key={colIndex} className="truncate">
{row}-{colIndex + 1}
</div>
))}
</div>
))}
</div>
</div>
{/* 페이지네이션 미리보기 */}
{dataTableComponent.pagination?.enabled && (
<div className="border-t bg-gray-50 px-4 py-2">
<div className="flex items-center justify-between text-xs text-gray-600">
{dataTableComponent.pagination.showPageInfo && (
<div> 100 1-{dataTableComponent.pagination.pageSize || 10}</div>
)}
<div className="flex items-center space-x-1">
{dataTableComponent.pagination.showFirstLast && (
<button className="rounded border px-2 py-1"></button>
)}
<button className="rounded border px-2 py-1"></button>
<span className="px-2">1</span>
<button className="rounded border px-2 py-1"></button>
{dataTableComponent.pagination.showFirstLast && (
<button className="rounded border px-2 py-1"></button>
)}
</div>
</div>
</div>
)}
</>
);
})()}
{type === "group" && (
<div className="relative h-full w-full">
{/* 그룹 내용 */}

View File

@@ -29,6 +29,7 @@ import {
alignGroupChildrenToGrid,
calculateOptimalGroupSize,
normalizeGroupChildPositions,
calculateWidthFromColumns,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar";
@@ -40,6 +41,7 @@ import { RealtimePreview } from "./RealtimePreview";
import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel";
@@ -60,6 +62,14 @@ const panelConfigs: PanelConfig[] = [
defaultHeight: 700, // 테이블 목록은 그대로 유지
shortcutKey: "t",
},
{
id: "templates",
title: "템플릿",
defaultPosition: "left",
defaultWidth: 380,
defaultHeight: 700,
shortcutKey: "m", // template의 m
},
{
id: "properties",
title: "속성 편집",
@@ -298,6 +308,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// gridColumns 변경 시 크기 자동 업데이트
console.log("🔍 gridColumns 변경 감지:", {
path,
value,
componentType: newComp.type,
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings,
currentGridColumns: (newComp as any).gridColumns,
});
if (path === "gridColumns" && gridInfo) {
const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = updatedSize;
@@ -306,6 +325,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
oldSize: comp.size,
newSize: updatedSize,
});
} else if (path === "gridColumns") {
console.log("❌ gridColumns 변경 실패:", {
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings,
gridInfo,
gridSettings: layout.gridSettings,
});
}
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
@@ -414,6 +440,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
setLayout(newLayout);
saveToHistory(newLayout);
// selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트
if (selectedComponent && selectedComponent.id === componentId) {
const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId);
if (updatedSelectedComponent) {
console.log("🔄 selectedComponent 동기화:", {
componentId,
path,
oldColumnsCount:
selectedComponent.type === "datatable" ? (selectedComponent as any).columns?.length : "N/A",
newColumnsCount:
updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).columns?.length : "N/A",
oldFiltersCount:
selectedComponent.type === "datatable" ? (selectedComponent as any).filters?.length : "N/A",
newFiltersCount:
updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).filters?.length : "N/A",
timestamp: new Date().toISOString(),
});
setSelectedComponent(updatedSelectedComponent);
}
}
// webTypeConfig 업데이트 후 레이아웃 상태 확인
if (path === "webTypeConfig") {
const updatedComponent = newLayout.components.find((c) => c.id === componentId);
@@ -574,6 +621,221 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}, [selectedScreen?.screenId, layout]);
// 템플릿 드래그 처리
const handleTemplateDrop = useCallback(
(e: React.DragEvent, template: TemplateComponent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropX = e.clientX - rect.left;
const dropY = e.clientY - rect.top;
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid({ x: dropX, y: dropY, z: 1 }, gridInfo, layout.gridSettings)
: { x: dropX, y: dropY, z: 1 };
console.log("🎨 템플릿 드롭:", {
templateName: template.name,
componentsCount: template.components.length,
dropPosition: { x: dropX, y: dropY },
snappedPosition,
});
// 템플릿의 모든 컴포넌트들을 생성
const newComponents: ComponentData[] = template.components.map((templateComp, index) => {
const componentId = generateComponentId();
// 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정
const absoluteX = snappedPosition.x + templateComp.position.x;
const absoluteY = snappedPosition.y + templateComp.position.y;
// 격자 스냅 적용
const finalPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, gridInfo, layout.gridSettings)
: { x: absoluteX, y: absoluteY, z: 1 };
if (templateComp.type === "container") {
return {
id: componentId,
type: "container",
label: templateComp.label,
tableName: selectedScreen?.tableName || "",
position: finalPosition,
size: templateComp.size,
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
};
} else if (templateComp.type === "datatable") {
// 데이터 테이블 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// gridColumns에 맞는 크기 계산
const calculatedSize =
gridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height, // 높이는 템플릿 값 유지
};
})()
: templateComp.size;
console.log("📊 데이터 테이블 생성 시 크기 계산:", {
gridColumns,
templateSize: templateComp.size,
calculatedSize,
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings?.snapToGrid,
});
return {
id: componentId,
type: "datatable",
label: templateComp.label,
tableName: selectedScreen?.tableName || "",
position: finalPosition,
size: calculatedSize,
title: templateComp.label,
columns: [], // 초기에는 빈 배열, 나중에 설정
filters: [], // 초기에는 빈 배열, 나중에 설정
pagination: {
enabled: true,
pageSize: 10,
pageSizeOptions: [5, 10, 20, 50],
showPageSizeSelector: true,
showPageInfo: true,
showFirstLast: true,
},
showSearchButton: true,
searchButtonText: "검색",
enableExport: true,
enableRefresh: true,
gridColumns,
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
} else {
// 위젯 컴포넌트
const widgetType = templateComp.widgetType || "text";
// 웹타입별 기본 설정 생성
const getDefaultWebTypeConfig = (wType: string) => {
switch (wType) {
case "date":
return {
format: "YYYY-MM-DD" as const,
showTime: false,
placeholder: templateComp.placeholder || "날짜를 선택하세요",
};
case "select":
case "dropdown":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: templateComp.placeholder || "옵션을 선택하세요",
};
case "text":
return {
format: "none" as const,
placeholder: templateComp.placeholder || "텍스트를 입력하세요",
multiline: false,
};
case "email":
return {
format: "email" as const,
placeholder: templateComp.placeholder || "이메일을 입력하세요",
multiline: false,
};
case "tel":
return {
format: "phone" as const,
placeholder: templateComp.placeholder || "전화번호를 입력하세요",
multiline: false,
};
case "textarea":
return {
rows: 3,
placeholder: templateComp.placeholder || "텍스트를 입력하세요",
resizable: true,
wordWrap: true,
};
default:
return {
placeholder: templateComp.placeholder || "입력하세요",
};
}
};
return {
id: componentId,
type: "widget",
widgetType: widgetType as any,
label: templateComp.label,
placeholder: templateComp.placeholder,
columnName: `field_${index + 1}`,
position: finalPosition,
size: templateComp.size,
required: templateComp.required || false,
readonly: templateComp.readonly || false,
gridColumns: 1,
webTypeConfig: getDefaultWebTypeConfig(widgetType),
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
}
});
// 레이아웃에 새 컴포넌트들 추가
const newLayout = {
...layout,
components: [...layout.components, ...newComponents],
};
setLayout(newLayout);
saveToHistory(newLayout);
// 첫 번째 컴포넌트 선택
if (newComponents.length > 0) {
setSelectedComponent(newComponents[0]);
openPanel("properties");
}
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
},
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -587,7 +849,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
if (!dragData) return;
try {
const { type, table, column } = JSON.parse(dragData);
const parsedData = JSON.parse(dragData);
// 템플릿 드래그인 경우
if (parsedData.type === "template") {
handleTemplateDrop(e, parsedData.template);
return;
}
// 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
@@ -2081,8 +2352,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<div className="text-center text-gray-400">
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
<p className="text-sm"> </p>
<p className="mt-2 text-xs">단축키: T(), P(), S(), R(), D()</p>
<p className="text-sm"> / 릿 </p>
<p className="mt-2 text-xs">단축키: T(), M(릿), P(), S(), R(), D()</p>
<p className="mt-1 text-xs">
편집: Ctrl+C(), Ctrl+V(), Ctrl+S(), Ctrl+Z(), Delete()
</p>
@@ -2121,6 +2392,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
/>
</FloatingPanel>
<FloatingPanel
id="templates"
title="템플릿"
isOpen={panelStates.templates?.isOpen || false}
onClose={() => closePanel("templates")}
position="left"
width={380}
height={700}
autoHeight={false}
>
<TemplatesPanel
onDragStart={(e, template) => {
const dragData = {
type: "template",
template,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
/>
</FloatingPanel>
<FloatingPanel
id="properties"
title="속성 편집"
@@ -2133,7 +2425,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
>
<PropertiesPanel
selectedComponent={selectedComponent || undefined}
tables={tables}
onUpdateProperty={(path: string, value: any) => {
console.log("🔧 속성 업데이트 요청:", {
componentId: selectedComponent?.id,
componentType: selectedComponent?.type,
path,
value: typeof value === "object" ? JSON.stringify(value).substring(0, 100) + "..." : value,
});
if (selectedComponent) {
updateComponentProperty(selectedComponent.id, path, value);
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,12 @@ import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
import { ComponentData, WebType, WidgetComponent, GroupComponent } from "@/types/screen";
import { ComponentData, WebType, WidgetComponent, GroupComponent, DataTableComponent, TableInfo } from "@/types/screen";
import DataTableConfigPanel from "./DataTableConfigPanel";
interface PropertiesPanelProps {
selectedComponent?: ComponentData;
tables?: TableInfo[];
onUpdateProperty: (path: string, value: unknown) => void;
onDeleteComponent: () => void;
onCopyComponent: () => void;
@@ -43,6 +45,7 @@ const webTypeOptions: { value: WebType; label: string }[] = [
export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
selectedComponent,
tables = [],
onUpdateProperty,
onDeleteComponent,
onCopyComponent,
@@ -71,6 +74,7 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
labelDisplay: selectedComponent?.style?.labelDisplay !== false,
});
useEffect(() => {
@@ -83,6 +87,18 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
if (selectedComponent) {
const widget = selectedComponent.type === "widget" ? (selectedComponent as WidgetComponent) : null;
const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null;
console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", {
componentId: selectedComponent.id,
componentType: selectedComponent.type,
currentValues: {
placeholder: widget?.placeholder,
title: group?.title,
positionX: selectedComponent.position.x,
labelText: selectedComponent.style?.labelText || selectedComponent.label,
},
});
setLocalInputs({
placeholder: widget?.placeholder || "",
title: group?.title || "",
@@ -98,9 +114,16 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
labelMarginBottom: selectedComponent.style?.labelMarginBottom || "4px",
required: widget?.required || false,
readonly: widget?.readonly || false,
labelDisplay: selectedComponent.style?.labelDisplay !== false,
});
}
}, [selectedComponent]);
}, [
selectedComponent,
selectedComponent?.position,
selectedComponent?.size,
selectedComponent?.style,
selectedComponent?.label,
]);
if (!selectedComponent) {
return (
@@ -112,6 +135,65 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
);
}
// 데이터 테이블 컴포넌트인 경우 전용 패널 사용
if (selectedComponent.type === "datatable") {
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Settings className="h-5 w-5 text-gray-600" />
<span className="text-lg font-semibold"> </span>
</div>
<Badge variant="secondary" className="text-xs">
{selectedComponent.type}
</Badge>
</div>
{/* 액션 버튼들 */}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onCopyComponent}>
<Copy className="mr-1 h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={onDeleteComponent}>
<Trash2 className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
{/* 데이터 테이블 설정 패널 */}
<div className="flex-1 overflow-y-auto">
<DataTableConfigPanel
key={`datatable-${selectedComponent.id}-${selectedComponent.columns.length}-${selectedComponent.filters.length}-${JSON.stringify(selectedComponent.columns.map((c) => c.id))}-${JSON.stringify(selectedComponent.filters.map((f) => f.columnName))}`}
component={selectedComponent as DataTableComponent}
tables={tables}
onUpdateComponent={(updates) => {
console.log("🔄 DataTable 컴포넌트 업데이트:", updates);
console.log("🔄 업데이트 항목들:", Object.keys(updates));
// 각 속성을 개별적으로 업데이트
Object.entries(updates).forEach(([key, value]) => {
console.log(` - ${key}:`, value);
if (key === "columns") {
console.log(` 컬럼 개수: ${Array.isArray(value) ? value.length : 0}`);
}
if (key === "filters") {
console.log(` 필터 개수: ${Array.isArray(value) ? value.length : 0}`);
}
onUpdateProperty(key, value);
});
console.log("✅ DataTable 컴포넌트 업데이트 완료");
}}
/>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
@@ -210,6 +292,7 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
value={localInputs.placeholder}
onChange={(e) => {
const newValue = e.target.value;
console.log("🔄 placeholder 변경:", newValue);
setLocalInputs((prev) => ({ ...prev, placeholder: newValue }));
onUpdateProperty("placeholder", newValue);
}}
@@ -394,8 +477,12 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
</Label>
<Checkbox
id="labelDisplay"
checked={selectedComponent.style?.labelDisplay !== false}
onCheckedChange={(checked) => onUpdateProperty("style.labelDisplay", checked)}
checked={localInputs.labelDisplay}
onCheckedChange={(checked) => {
console.log("🔄 라벨 표시 변경:", checked);
setLocalInputs((prev) => ({ ...prev, labelDisplay: checked as boolean }));
onUpdateProperty("style.labelDisplay", checked);
}}
/>
</div>
@@ -409,6 +496,7 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
value={localInputs.labelText}
onChange={(e) => {
const newValue = e.target.value;
console.log("🔄 라벨 텍스트 변경:", newValue);
setLocalInputs((prev) => ({ ...prev, labelText: newValue }));
// 기본 라벨과 스타일 라벨을 모두 업데이트
onUpdateProperty("label", newValue);

View File

@@ -0,0 +1,170 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Table, Search, FileText, Grid3x3, Info } from "lucide-react";
// 템플릿 컴포넌트 타입 정의
export interface TemplateComponent {
id: string;
name: string;
description: string;
category: "table" | "button" | "form" | "layout" | "chart" | "status";
icon: React.ReactNode;
defaultSize: { width: number; height: number };
components: Array<{
type: "widget" | "container";
widgetType?: string;
label: string;
placeholder?: string;
position: { x: number; y: number };
size: { width: number; height: number };
style?: any;
required?: boolean;
readonly?: boolean;
}>;
}
// 미리 정의된 템플릿 컴포넌트들
const templateComponents: TemplateComponent[] = [
// 고급 데이터 테이블 템플릿
{
id: "advanced-data-table",
name: "고급 데이터 테이블",
description: "컬럼 설정, 필터링, 페이지네이션이 포함된 완전한 데이터 테이블",
category: "table",
icon: <Table className="h-4 w-4" />,
defaultSize: { width: 1000, height: 680 },
components: [
// 데이터 테이블 컴포넌트 (특별한 타입)
{
type: "datatable",
label: "데이터 테이블",
position: { x: 0, y: 0 },
size: { width: 1000, height: 680 },
style: {
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "#ffffff",
padding: "16px",
},
},
],
},
];
interface TemplatesPanelProps {
onDragStart: (e: React.DragEvent, template: TemplateComponent) => void;
}
export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) => {
const [searchTerm, setSearchTerm] = React.useState("");
const [selectedCategory, setSelectedCategory] = React.useState<string>("all");
const categories = [
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
];
const filteredTemplates = templateComponents.filter((template) => {
const matchesSearch =
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === "all" || template.category === selectedCategory;
return matchesSearch && matchesCategory;
});
return (
<div className="flex h-full flex-col space-y-4 p-4">
{/* 검색 */}
<div className="space-y-3">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="템플릿 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* 카테고리 필터 */}
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<Button
key={category.id}
variant={selectedCategory === category.id ? "default" : "outline"}
size="sm"
onClick={() => setSelectedCategory(category.id)}
className="flex items-center space-x-1"
>
{category.icon}
<span>{category.name}</span>
</Button>
))}
</div>
</div>
<Separator />
{/* 템플릿 목록 */}
<div className="flex-1 space-y-2 overflow-y-auto">
{filteredTemplates.length === 0 ? (
<div className="flex h-32 items-center justify-center text-center text-gray-500">
<div>
<FileText className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm"> </p>
</div>
</div>
) : (
filteredTemplates.map((template) => (
<div
key={template.id}
draggable
onDragStart={(e) => onDragStart(e, template)}
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
>
<div className="flex items-start space-x-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600 group-hover:bg-blue-100">
{template.icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center space-x-2">
<h4 className="truncate font-medium text-gray-900">{template.name}</h4>
<Badge variant="secondary" className="text-xs">
{template.components.length}
</Badge>
</div>
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{template.description}</p>
<div className="mt-2 flex items-center space-x-2 text-xs text-gray-400">
<span>
{template.defaultSize.width}×{template.defaultSize.height}
</span>
<span></span>
<span className="capitalize">{template.category}</span>
</div>
</div>
</div>
</div>
))
)}
</div>
{/* 도움말 */}
<div className="rounded-lg bg-blue-50 p-3">
<div className="flex items-start space-x-2">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
<div className="text-xs text-blue-700">
<p className="mb-1 font-medium"> </p>
<p>릿 .</p>
</div>
</div>
</div>
</div>
);
};
export default TemplatesPanel;

View File

@@ -145,6 +145,35 @@ export const tableTypeApi = {
detailSettings,
});
},
// 테이블 데이터 조회 (페이지네이션 + 검색)
getTableData: async (
tableName: string,
params: {
page?: number;
size?: number;
search?: Record<string, any>; // 검색 조건
sortBy?: string;
sortOrder?: "asc" | "desc";
} = {},
): Promise<{
data: Record<string, any>[];
total: number;
page: number;
size: number;
totalPages: number;
}> => {
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params);
const raw = response.data?.data || response.data;
return {
data: raw.data || [],
total: raw.total || 0,
page: raw.page || params.page || 1,
size: raw.size || params.size || 10,
totalPages: raw.totalPages || Math.ceil((raw.total || 0) / (params.size || 10)),
};
},
};
// 메뉴-화면 할당 관련 API

View File

@@ -1,7 +1,7 @@
// 화면관리 시스템 타입 정의
// 기본 컴포넌트 타입
export type ComponentType = "container" | "row" | "column" | "widget" | "group";
export type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable";
// 웹 타입 정의
export type WebType =
@@ -199,8 +199,62 @@ export interface WidgetComponent extends BaseComponent {
webTypeConfig?: WebTypeConfig; // 웹타입별 상세 설정
}
// 데이터 테이블 컬럼 설정
export interface DataTableColumn {
id: string;
columnName: string; // 실제 DB 컬럼명
label: string; // 화면에 표시될 라벨
widgetType: WebType; // 컬럼의 데이터 타입
gridColumns: number; // 그리드에서 차지할 컬럼 수 (1-12)
visible: boolean; // 테이블에 표시할지 여부
filterable: boolean; // 필터링 가능 여부
sortable: boolean; // 정렬 가능 여부
searchable: boolean; // 검색 대상 여부
webTypeConfig?: WebTypeConfig; // 컬럼별 상세 설정
}
// 데이터 테이블 필터 설정
export interface DataTableFilter {
columnName: string;
widgetType: WebType;
label: string;
gridColumns: number; // 필터에서 차지할 컬럼 수
webTypeConfig?: WebTypeConfig;
}
// 데이터 테이블 페이지네이션 설정
export interface DataTablePagination {
enabled: boolean;
pageSize: number; // 페이지당 행 수
pageSizeOptions: number[]; // 선택 가능한 페이지 크기들
showPageSizeSelector: boolean; // 페이지 크기 선택기 표시 여부
showPageInfo: boolean; // 페이지 정보 표시 여부
showFirstLast: boolean; // 처음/마지막 버튼 표시 여부
}
// 데이터 테이블 컴포넌트
export interface DataTableComponent extends BaseComponent {
type: "datatable";
tableName: string; // 연결된 테이블명
title?: string; // 테이블 제목
columns: DataTableColumn[]; // 테이블 컬럼 설정
filters: DataTableFilter[]; // 검색 필터 설정
pagination: DataTablePagination; // 페이지네이션 설정
showSearchButton: boolean; // 검색 버튼 표시 여부
searchButtonText: string; // 검색 버튼 텍스트
enableExport: boolean; // 내보내기 기능 활성화
enableRefresh: boolean; // 새로고침 기능 활성화
gridColumns: number; // 테이블이 차지할 그리드 컬럼 수
}
// 컴포넌트 유니온 타입
export type ComponentData = ContainerComponent | GroupComponent | RowComponent | ColumnComponent | WidgetComponent;
export type ComponentData =
| ContainerComponent
| GroupComponent
| RowComponent
| ColumnComponent
| WidgetComponent
| DataTableComponent;
// 레이아웃 데이터
export interface LayoutData {