Compare commits
32 Commits
7dc420a1a2
...
8dee8ac314
| Author | SHA1 | Date |
|---|---|---|
|
|
8dee8ac314 | |
|
|
59fa54b812 | |
|
|
2722ebb218 | |
|
|
dad7e9edab | |
|
|
49f779e0e4 | |
|
|
605fbc4383 | |
|
|
2e0ccaac16 | |
|
|
ccbb6924c8 | |
|
|
0e95f8ed66 | |
|
|
8e74429a83 | |
|
|
2148e8e019 | |
|
|
5d374f902a | |
|
|
99468ca250 | |
|
|
99deab05d8 | |
|
|
5f11b5083f | |
|
|
cdf9c0e562 | |
|
|
2d832c56b6 | |
|
|
1d26b979ac | |
|
|
2a2bf86d12 | |
|
|
d7e598435c | |
|
|
0af0b53638 | |
|
|
ed351f7044 | |
|
|
d0ddc702ac | |
|
|
eb8e5da329 | |
|
|
e7cbbe39a6 | |
|
|
8f41cf7919 | |
|
|
4cd9629a1d | |
|
|
7f68a70b0f | |
|
|
0474937e57 | |
|
|
d8bba7cfc1 | |
|
|
554cdbdea5 | |
|
|
c4290f2d0e |
|
|
@ -49,6 +49,33 @@ export class EntityJoinService {
|
||||||
|
|
||||||
const joinConfigs: EntityJoinConfig[] = [];
|
const joinConfigs: EntityJoinConfig[] = [];
|
||||||
|
|
||||||
|
// 🎯 writer 컬럼 자동 감지 및 조인 설정 추가
|
||||||
|
const tableColumns = await query<{ column_name: string }>(
|
||||||
|
`SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND table_schema = 'public'
|
||||||
|
AND column_name = 'writer'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tableColumns.length > 0) {
|
||||||
|
const writerJoinConfig: EntityJoinConfig = {
|
||||||
|
sourceTable: tableName,
|
||||||
|
sourceColumn: "writer",
|
||||||
|
referenceTable: "user_info",
|
||||||
|
referenceColumn: "user_id",
|
||||||
|
displayColumns: ["user_name"],
|
||||||
|
displayColumn: "user_name",
|
||||||
|
aliasColumn: "writer_name",
|
||||||
|
separator: " - ",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await this.validateJoinConfig(writerJoinConfig)) {
|
||||||
|
joinConfigs.push(writerJoinConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const column of entityColumns) {
|
for (const column of entityColumns) {
|
||||||
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
||||||
column_name: column.column_name,
|
column_name: column.column_name,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,656 @@
|
||||||
|
# 엑셀 다운로드 기능 개선 계획서
|
||||||
|
|
||||||
|
## 📋 문서 정보
|
||||||
|
|
||||||
|
- **작성일**: 2025-01-10
|
||||||
|
- **작성자**: AI Developer
|
||||||
|
- **상태**: 계획 단계
|
||||||
|
- **우선순위**: 🔴 높음 (보안 취약점 포함)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 현재 문제점
|
||||||
|
|
||||||
|
### 1. 보안 취약점 (Critical)
|
||||||
|
|
||||||
|
- ❌ **멀티테넌시 규칙 위반**: 모든 회사의 데이터를 가져옴
|
||||||
|
- ❌ **회사 필터링 없음**: `dynamicFormApi.getTableData` 호출 시 `autoFilter` 미적용
|
||||||
|
- ❌ **데이터 유출 위험**: 회사 A 사용자가 회사 B, C, D의 데이터를 다운로드 가능
|
||||||
|
- ❌ **규정 위반**: GDPR, 개인정보보호법 등 법적 문제
|
||||||
|
|
||||||
|
**관련 코드**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/lib/utils/buttonActions.ts (2043-2048 라인)
|
||||||
|
const response = await dynamicFormApi.getTableData(context.tableName, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10000, // 최대 10,000개 행
|
||||||
|
sortBy: context.sortBy || "id",
|
||||||
|
sortOrder: context.sortOrder || "asc",
|
||||||
|
// ❌ autoFilter 없음 - company_code 필터링 안됨
|
||||||
|
// ❌ search 없음 - 사용자 필터 조건 무시
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 기능 문제
|
||||||
|
|
||||||
|
- ❌ **모든 컬럼 포함**: 화면에 표시되지 않는 컬럼도 다운로드됨
|
||||||
|
- ❌ **필터 조건 무시**: 사용자가 설정한 검색/필터가 적용되지 않음
|
||||||
|
- ❌ **DB 컬럼명 사용**: 사용자 친화적이지 않음 (예: `user_id` 대신 `사용자 ID`)
|
||||||
|
- ❌ **컬럼 순서 불일치**: 화면 표시 순서와 다름
|
||||||
|
|
||||||
|
### 3. 우선순위 문제
|
||||||
|
|
||||||
|
현재 다운로드 데이터 우선순위:
|
||||||
|
|
||||||
|
1. ✅ 선택된 행 데이터 (`context.selectedRowsData`)
|
||||||
|
2. ✅ 화면 표시 데이터 (`context.tableDisplayData`)
|
||||||
|
3. ✅ 전역 저장소 데이터 (`tableDisplayStore`)
|
||||||
|
4. ❌ **테이블 전체 데이터** (API 호출) ← **보안 위험!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 개선 목표
|
||||||
|
|
||||||
|
### 1. 보안 강화
|
||||||
|
|
||||||
|
- ✅ **멀티테넌시 준수**: 현재 사용자의 회사 데이터만 다운로드
|
||||||
|
- ✅ **필터 조건 적용**: 사용자가 설정한 검색/필터 조건 반영
|
||||||
|
- ✅ **권한 검증**: 데이터 접근 권한 확인
|
||||||
|
- ✅ **감사 로그**: 다운로드 이력 기록
|
||||||
|
|
||||||
|
### 2. 사용자 경험 개선
|
||||||
|
|
||||||
|
- ✅ **화면 표시 컬럼만**: 사용자가 선택한 컬럼만 다운로드
|
||||||
|
- ✅ **컬럼 순서 유지**: 화면 표시 순서와 동일
|
||||||
|
- ✅ **라벨명 사용**: 한글 컬럼명 (예: `사용자 ID`, `부서명`)
|
||||||
|
- ✅ **정렬 유지**: 화면 정렬 상태 반영
|
||||||
|
|
||||||
|
### 3. 데이터 정확성
|
||||||
|
|
||||||
|
- ✅ **필터링된 데이터**: 화면에 보이는 조건과 동일한 데이터
|
||||||
|
- ✅ **선택 우선**: 사용자가 행을 선택했으면 선택된 행만
|
||||||
|
- ✅ **데이터 일관성**: 화면 ↔ 엑셀 데이터 일치
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 개선 계획
|
||||||
|
|
||||||
|
### Phase 1: 데이터 소스 우선순위 재정의
|
||||||
|
|
||||||
|
#### 새로운 우선순위
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 선택된 행 데이터 (가장 높은 우선순위)
|
||||||
|
- 출처: context.selectedRowsData
|
||||||
|
- 설명: 사용자가 체크박스로 선택한 행
|
||||||
|
- 특징: 필터/정렬 이미 적용됨, 가장 명확한 의도
|
||||||
|
- 처리: 그대로 사용
|
||||||
|
|
||||||
|
2. 화면 표시 데이터 (두 번째 우선순위)
|
||||||
|
- 출처: tableDisplayStore.getTableData(tableName)
|
||||||
|
- 설명: 현재 화면에 표시 중인 데이터
|
||||||
|
- 특징: 필터/정렬/페이징 적용됨, 가장 안전
|
||||||
|
- 처리:
|
||||||
|
- 현재 페이지 데이터만 (기본)
|
||||||
|
- 또는 전체 페이지 데이터 (옵션)
|
||||||
|
|
||||||
|
3. API 호출 - 필터 조건 포함 (최후 수단)
|
||||||
|
- 출처: entityJoinApi.getTableDataWithJoins()
|
||||||
|
- 설명: 위의 데이터가 없을 때만
|
||||||
|
- 특징:
|
||||||
|
- ✅ company_code 자동 필터링 (autoFilter: true)
|
||||||
|
- ✅ 검색/필터 조건 전달
|
||||||
|
- ✅ 정렬 조건 전달
|
||||||
|
- 제한: 최대 10,000개 행
|
||||||
|
|
||||||
|
4. ❌ 테이블 전체 데이터 (제거)
|
||||||
|
- 보안상 위험하므로 완전 제거
|
||||||
|
- 대신 경고 메시지 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: ButtonActionContext 확장
|
||||||
|
|
||||||
|
#### 현재 구조
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ButtonActionContext {
|
||||||
|
tableName?: string;
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
selectedRowsData?: any[];
|
||||||
|
tableDisplayData?: any[];
|
||||||
|
columnOrder?: string[];
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 추가 필드
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ButtonActionContext {
|
||||||
|
// ... 기존 필드
|
||||||
|
|
||||||
|
// 🆕 필터 및 검색 조건
|
||||||
|
filterConditions?: Record<string, any>; // 필터 조건 (예: { status: "active", dept: "dev" })
|
||||||
|
searchTerm?: string; // 검색어
|
||||||
|
searchColumn?: string; // 검색 대상 컬럼
|
||||||
|
|
||||||
|
// 🆕 컬럼 정보
|
||||||
|
visibleColumns?: string[]; // 화면에 표시 중인 컬럼 목록 (순서 포함)
|
||||||
|
columnLabels?: Record<string, string>; // 컬럼명 → 라벨명 매핑
|
||||||
|
|
||||||
|
// 🆕 페이징 정보
|
||||||
|
currentPage?: number; // 현재 페이지
|
||||||
|
pageSize?: number; // 페이지 크기
|
||||||
|
totalItems?: number; // 전체 항목 수
|
||||||
|
|
||||||
|
// 🆕 엑셀 옵션
|
||||||
|
excelScope?: "selected" | "current-page" | "all-filtered"; // 다운로드 범위
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: TableListComponent 수정
|
||||||
|
|
||||||
|
#### 위치
|
||||||
|
|
||||||
|
`frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
||||||
|
|
||||||
|
#### 변경 사항
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 버튼 클릭 시 context 생성
|
||||||
|
const buttonContext: ButtonActionContext = {
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
|
||||||
|
// 기존
|
||||||
|
selectedRowsData: selectedRows,
|
||||||
|
tableDisplayData: data, // 현재 페이지 데이터
|
||||||
|
columnOrder: visibleColumns.map((col) => col.columnName),
|
||||||
|
sortBy: sortColumn,
|
||||||
|
sortOrder: sortDirection,
|
||||||
|
|
||||||
|
// 🆕 추가
|
||||||
|
filterConditions: searchValues, // 필터 조건
|
||||||
|
searchTerm: searchTerm, // 검색어
|
||||||
|
visibleColumns: visibleColumns.map((col) => col.columnName), // 표시 컬럼
|
||||||
|
columnLabels: columnLabels, // 컬럼 라벨 (한글)
|
||||||
|
currentPage: currentPage, // 현재 페이지
|
||||||
|
pageSize: localPageSize, // 페이지 크기
|
||||||
|
totalItems: totalItems, // 전체 항목 수
|
||||||
|
excelScope: selectedRows.length > 0 ? "selected" : "current-page", // 기본: 현재 페이지
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: handleExcelDownload 수정
|
||||||
|
|
||||||
|
#### 4-1. 데이터 소스 선택 로직
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private static async handleExcelDownload(
|
||||||
|
config: ButtonActionConfig,
|
||||||
|
context: ButtonActionContext
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let dataToExport: any[] = [];
|
||||||
|
let dataSource: string = "unknown";
|
||||||
|
|
||||||
|
// 1순위: 선택된 행 데이터
|
||||||
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
dataToExport = context.selectedRowsData;
|
||||||
|
dataSource = "selected";
|
||||||
|
console.log("✅ 선택된 행 사용:", dataToExport.length);
|
||||||
|
}
|
||||||
|
// 2순위: 화면 표시 데이터
|
||||||
|
else if (context.tableDisplayData && context.tableDisplayData.length > 0) {
|
||||||
|
dataToExport = context.tableDisplayData;
|
||||||
|
dataSource = "current-page";
|
||||||
|
console.log("✅ 현재 페이지 데이터 사용:", dataToExport.length);
|
||||||
|
}
|
||||||
|
// 3순위: 전역 저장소 데이터
|
||||||
|
else if (context.tableName) {
|
||||||
|
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
||||||
|
const storedData = tableDisplayStore.getTableData(context.tableName);
|
||||||
|
|
||||||
|
if (storedData && storedData.data.length > 0) {
|
||||||
|
dataToExport = storedData.data;
|
||||||
|
dataSource = "store";
|
||||||
|
console.log("✅ 저장소 데이터 사용:", dataToExport.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4순위: API 호출 (필터 조건 포함) - 최후 수단
|
||||||
|
if (dataToExport.length === 0 && context.tableName) {
|
||||||
|
console.log("⚠️ 화면 데이터 없음 - API 호출 필요");
|
||||||
|
|
||||||
|
// 사용자 확인 (선택사항)
|
||||||
|
const confirmed = await this.confirmLargeDownload(context.totalItems || 0);
|
||||||
|
if (!confirmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
dataToExport = await this.fetchFilteredData(context);
|
||||||
|
dataSource = "api";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 없음
|
||||||
|
if (dataToExport.length === 0) {
|
||||||
|
toast.error("다운로드할 데이터가 없습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... 계속
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-2. API 호출 메서드 (필터 조건 포함)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private static async fetchFilteredData(
|
||||||
|
context: ButtonActionContext
|
||||||
|
): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
console.log("🔄 필터된 데이터 조회 중...", {
|
||||||
|
tableName: context.tableName,
|
||||||
|
filterConditions: context.filterConditions,
|
||||||
|
searchTerm: context.searchTerm,
|
||||||
|
sortBy: context.sortBy,
|
||||||
|
sortOrder: context.sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
|
||||||
|
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
|
||||||
|
const response = await entityJoinApi.getTableDataWithJoins(
|
||||||
|
context.tableName!,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 10000, // 최대 10,000개
|
||||||
|
sortBy: context.sortBy || "id",
|
||||||
|
sortOrder: context.sortOrder || "asc",
|
||||||
|
search: context.filterConditions, // ✅ 필터 조건
|
||||||
|
enableEntityJoin: true, // ✅ Entity 조인
|
||||||
|
autoFilter: true, // ✅ company_code 자동 필터링
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
console.log("✅ API 데이터 조회 완료:", {
|
||||||
|
count: response.data.length,
|
||||||
|
total: response.total,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
console.error("❌ API 응답 실패:", response);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ API 호출 오류:", error);
|
||||||
|
toast.error("데이터를 가져오는데 실패했습니다.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-3. 컬럼 필터링 및 라벨 적용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private static applyColumnFiltering(
|
||||||
|
data: any[],
|
||||||
|
context: ButtonActionContext
|
||||||
|
): any[] {
|
||||||
|
// 표시 컬럼이 지정되지 않았으면 모든 컬럼 사용
|
||||||
|
const visibleColumns = context.visibleColumns || Object.keys(data[0] || {});
|
||||||
|
const columnLabels = context.columnLabels || {};
|
||||||
|
|
||||||
|
console.log("🔧 컬럼 필터링 및 라벨 적용:", {
|
||||||
|
totalColumns: Object.keys(data[0] || {}).length,
|
||||||
|
visibleColumns: visibleColumns.length,
|
||||||
|
hasLabels: Object.keys(columnLabels).length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.map(row => {
|
||||||
|
const filteredRow: Record<string, any> = {};
|
||||||
|
|
||||||
|
visibleColumns.forEach(columnName => {
|
||||||
|
// 라벨 우선 사용, 없으면 컬럼명 사용
|
||||||
|
const label = columnLabels[columnName] || columnName;
|
||||||
|
filteredRow[label] = row[columnName];
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-4. 대용량 다운로드 확인
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private static async confirmLargeDownload(totalItems: number): Promise<boolean> {
|
||||||
|
if (totalItems === 0) {
|
||||||
|
return true; // 데이터 없으면 확인 불필요
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalItems > 1000) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`총 ${totalItems.toLocaleString()}개의 데이터를 다운로드합니다.\n` +
|
||||||
|
`(최대 10,000개까지만 다운로드됩니다)\n\n` +
|
||||||
|
`계속하시겠습니까?`
|
||||||
|
);
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // 1000개 이하는 자동 진행
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-5. 전체 흐름
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private static async handleExcelDownload(
|
||||||
|
config: ButtonActionConfig,
|
||||||
|
context: ButtonActionContext
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// 1. 데이터 소스 선택
|
||||||
|
let dataToExport = await this.selectDataSource(context);
|
||||||
|
|
||||||
|
if (dataToExport.length === 0) {
|
||||||
|
toast.error("다운로드할 데이터가 없습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 최대 행 수 제한
|
||||||
|
const MAX_ROWS = 10000;
|
||||||
|
if (dataToExport.length > MAX_ROWS) {
|
||||||
|
toast.warning(`최대 ${MAX_ROWS.toLocaleString()}개 행까지만 다운로드됩니다.`);
|
||||||
|
dataToExport = dataToExport.slice(0, MAX_ROWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 컬럼 필터링 및 라벨 적용
|
||||||
|
dataToExport = this.applyColumnFiltering(dataToExport, context);
|
||||||
|
|
||||||
|
// 4. 정렬 적용 (필요 시)
|
||||||
|
if (context.sortBy) {
|
||||||
|
dataToExport = this.applySorting(dataToExport, context.sortBy, context.sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 엑셀 파일 생성
|
||||||
|
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||||
|
|
||||||
|
const fileName = config.excelFileName ||
|
||||||
|
`${context.tableName}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||||
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
|
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||||
|
|
||||||
|
await exportToExcel(dataToExport, fileName, sheetName, includeHeaders);
|
||||||
|
|
||||||
|
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
|
||||||
|
|
||||||
|
// 6. 감사 로그 (선택사항)
|
||||||
|
this.logExcelDownload(context, dataToExport.length);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 엑셀 다운로드 실패:", error);
|
||||||
|
toast.error("엑셀 다운로드에 실패했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 구현 단계
|
||||||
|
|
||||||
|
### Step 1: 타입 정의 업데이트
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/utils/buttonActions.ts`
|
||||||
|
|
||||||
|
- [ ] `ButtonActionContext` 인터페이스에 새 필드 추가
|
||||||
|
- [ ] `ExcelDownloadScope` 타입 정의 추가
|
||||||
|
- [ ] JSDoc 주석 추가
|
||||||
|
|
||||||
|
**예상 작업 시간**: 10분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: TableListComponent 수정
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
||||||
|
|
||||||
|
- [ ] 버튼 context 생성 시 필터/컬럼/라벨 정보 추가
|
||||||
|
- [ ] `columnLabels` 생성 로직 추가
|
||||||
|
- [ ] `visibleColumns` 목록 생성
|
||||||
|
|
||||||
|
**예상 작업 시간**: 20분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: handleExcelDownload 리팩토링
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/utils/buttonActions.ts`
|
||||||
|
|
||||||
|
- [ ] 데이터 소스 선택 로직 분리 (`selectDataSource`)
|
||||||
|
- [ ] API 호출 메서드 추가 (`fetchFilteredData`)
|
||||||
|
- [ ] 컬럼 필터링 메서드 추가 (`applyColumnFiltering`)
|
||||||
|
- [ ] 대용량 확인 메서드 추가 (`confirmLargeDownload`)
|
||||||
|
- [ ] 정렬 메서드 개선 (`applySorting`)
|
||||||
|
- [ ] 기존 코드 정리 (불필요한 로그 제거)
|
||||||
|
|
||||||
|
**예상 작업 시간**: 40분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: 테스트
|
||||||
|
|
||||||
|
**테스트 시나리오**:
|
||||||
|
|
||||||
|
1. **선택된 행 다운로드**
|
||||||
|
|
||||||
|
- 체크박스로 여러 행 선택
|
||||||
|
- 엑셀 다운로드 버튼 클릭
|
||||||
|
- 예상: 선택된 행만 다운로드
|
||||||
|
- 확인: 라벨명, 컬럼 순서, 데이터 정확성
|
||||||
|
|
||||||
|
2. **현재 페이지 다운로드**
|
||||||
|
|
||||||
|
- 행 선택 없이 엑셀 다운로드
|
||||||
|
- 예상: 현재 페이지 데이터만
|
||||||
|
- 확인: 페이지 크기만큼 다운로드
|
||||||
|
|
||||||
|
3. **필터 적용 다운로드**
|
||||||
|
|
||||||
|
- 검색어 입력 또는 필터 설정
|
||||||
|
- 엑셀 다운로드
|
||||||
|
- 예상: 필터된 결과만
|
||||||
|
- 확인: 화면 데이터와 일치
|
||||||
|
|
||||||
|
4. **멀티테넌시 테스트**
|
||||||
|
|
||||||
|
- 회사 A로 로그인
|
||||||
|
- 엑셀 다운로드
|
||||||
|
- 확인: 회사 A 데이터만
|
||||||
|
- 회사 B로 로그인
|
||||||
|
- 엑셀 다운로드
|
||||||
|
- 확인: 회사 B 데이터만
|
||||||
|
|
||||||
|
5. **대용량 데이터 테스트**
|
||||||
|
|
||||||
|
- 10,000개 이상 데이터 조회
|
||||||
|
- 엑셀 다운로드
|
||||||
|
- 예상: 10,000개까지만 + 경고 메시지
|
||||||
|
|
||||||
|
6. **컬럼 라벨 테스트**
|
||||||
|
- 엑셀 파일 열기
|
||||||
|
- 확인: DB 컬럼명이 아닌 한글 라벨명
|
||||||
|
|
||||||
|
**예상 작업 시간**: 30분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: 문서화 및 커밋
|
||||||
|
|
||||||
|
- [ ] 코드 주석 추가
|
||||||
|
- [ ] README 업데이트 (있다면)
|
||||||
|
- [ ] 커밋 메시지 작성
|
||||||
|
|
||||||
|
**예상 작업 시간**: 10분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏱️ 총 예상 시간
|
||||||
|
|
||||||
|
**약 2시간** (코딩 + 테스트)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
### 1. 하위 호환성
|
||||||
|
|
||||||
|
- 기존 `context.tableDisplayData`를 사용하는 코드가 있을 수 있음
|
||||||
|
- 새 필드는 모두 선택사항(`?`)으로 정의
|
||||||
|
- 기존 동작은 유지하면서 점진적으로 개선
|
||||||
|
|
||||||
|
### 2. 성능
|
||||||
|
|
||||||
|
- API 호출 시 최대 10,000개 제한
|
||||||
|
- 대용량 데이터는 페이징 권장
|
||||||
|
- 브라우저 메모리 제한 고려
|
||||||
|
|
||||||
|
### 3. 보안
|
||||||
|
|
||||||
|
- **절대 `autoFilter: false` 사용 금지**
|
||||||
|
- 모든 API 호출에 `autoFilter: true` 필수
|
||||||
|
- 감사 로그 기록 권장
|
||||||
|
|
||||||
|
### 4. 사용자 경험
|
||||||
|
|
||||||
|
- 다운로드 중 로딩 표시
|
||||||
|
- 완료/실패 토스트 메시지
|
||||||
|
- 대용량 다운로드 시 확인 창
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 예상 결과
|
||||||
|
|
||||||
|
### Before (현재)
|
||||||
|
|
||||||
|
```
|
||||||
|
엑셀 다운로드:
|
||||||
|
❌ 모든 회사의 데이터 (보안 위험!)
|
||||||
|
❌ 모든 컬럼 포함 (불필요한 정보)
|
||||||
|
❌ 필터 조건 무시
|
||||||
|
❌ DB 컬럼명 (user_id, dept_code)
|
||||||
|
❌ 정렬 상태 무시
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (개선)
|
||||||
|
|
||||||
|
```
|
||||||
|
엑셀 다운로드:
|
||||||
|
✅ 현재 회사 데이터만 (멀티테넌시 준수)
|
||||||
|
✅ 화면 표시 컬럼만 (사용자 선택)
|
||||||
|
✅ 필터 조건 적용 (검색/필터 반영)
|
||||||
|
✅ 한글 라벨명 (사용자 ID, 부서명)
|
||||||
|
✅ 정렬 상태 유지 (화면과 동일)
|
||||||
|
✅ 컬럼 순서 유지 (화면과 동일)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 관련 파일
|
||||||
|
|
||||||
|
### 수정 대상
|
||||||
|
|
||||||
|
1. `frontend/lib/utils/buttonActions.ts`
|
||||||
|
|
||||||
|
- `ButtonActionContext` 인터페이스
|
||||||
|
- `handleExcelDownload` 메서드
|
||||||
|
|
||||||
|
2. `frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
||||||
|
- 버튼 context 생성 로직
|
||||||
|
|
||||||
|
### 참고 파일
|
||||||
|
|
||||||
|
1. `frontend/lib/api/entityJoin.ts`
|
||||||
|
|
||||||
|
- `getTableDataWithJoins` API
|
||||||
|
|
||||||
|
2. `frontend/lib/utils/excelExport.ts`
|
||||||
|
|
||||||
|
- `exportToExcel` 함수
|
||||||
|
|
||||||
|
3. `.cursor/rules/multi-tenancy-guide.mdc`
|
||||||
|
- 멀티테넌시 규칙
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 후속 작업 (선택사항)
|
||||||
|
|
||||||
|
### 1. 엑셀 다운로드 옵션 UI
|
||||||
|
|
||||||
|
사용자가 다운로드 범위를 선택할 수 있는 모달:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] 선택된 행만 (N개)
|
||||||
|
[x] 현재 페이지 (20개)
|
||||||
|
[ ] 필터된 전체 데이터 (최대 10,000개)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 엑셀 스타일링
|
||||||
|
|
||||||
|
- 헤더 배경색
|
||||||
|
- 자동 너비 조정
|
||||||
|
- 필터 버튼 추가
|
||||||
|
|
||||||
|
### 3. CSV 내보내기
|
||||||
|
|
||||||
|
- 대용량 데이터에 적합
|
||||||
|
- 가벼운 파일 크기
|
||||||
|
|
||||||
|
### 4. 감사 로그
|
||||||
|
|
||||||
|
- 누가, 언제, 어떤 데이터를 다운로드했는지 기록
|
||||||
|
- 보안 감사 추적
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 체크리스트
|
||||||
|
|
||||||
|
### 계획 단계
|
||||||
|
|
||||||
|
- [x] 계획서 작성 완료
|
||||||
|
- [x] 사용자 검토 및 승인
|
||||||
|
- [x] 수정 사항 반영
|
||||||
|
|
||||||
|
### 구현 단계
|
||||||
|
|
||||||
|
- [x] Step 1: 타입 정의 업데이트
|
||||||
|
- [x] Step 2: TableListComponent 수정
|
||||||
|
- [x] Step 3: handleExcelDownload 리팩토링
|
||||||
|
- [ ] Step 4: 테스트 완료 (사용자 테스트 필요)
|
||||||
|
- [ ] Step 5: 문서화 및 커밋 (대기 중)
|
||||||
|
|
||||||
|
### 배포 단계
|
||||||
|
|
||||||
|
- [ ] 코드 리뷰
|
||||||
|
- [ ] QA 테스트
|
||||||
|
- [ ] 프로덕션 배포
|
||||||
|
- [ ] 모니터링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 승인
|
||||||
|
|
||||||
|
- [ ] 개발팀 리뷰
|
||||||
|
- [ ] 보안팀 검토
|
||||||
|
- [ ] 사용자 승인
|
||||||
|
- [ ] 최종 승인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성 완료**: 2025-01-10
|
||||||
|
**다음 업데이트**: 구현 완료 후
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
# 엑셀 다운로드 개선 계획 v2 (수정)
|
||||||
|
|
||||||
|
## 📋 문서 정보
|
||||||
|
|
||||||
|
- **작성일**: 2025-01-10
|
||||||
|
- **작성자**: AI Developer
|
||||||
|
- **버전**: 2.0 (사용자 피드백 반영)
|
||||||
|
- **상태**: 구현 대기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 변경된 요구사항 (사용자 피드백)
|
||||||
|
|
||||||
|
### 사용자가 원하는 동작
|
||||||
|
|
||||||
|
1. ❌ **선택된 행만 다운로드 기능 제거** (불필요)
|
||||||
|
2. ✅ **항상 필터링된 전체 데이터 다운로드** (현재 화면 기준)
|
||||||
|
3. ✅ **화면에 표시된 컬럼만** 다운로드
|
||||||
|
4. ✅ **컬럼 라벨(한글) 우선** 사용
|
||||||
|
5. ✅ **멀티테넌시 준수** (company_code 필터링)
|
||||||
|
|
||||||
|
### 현재 문제
|
||||||
|
|
||||||
|
1. 🐛 **행 선택 안 했을 때**: "다운로드할 데이터가 없습니다" 에러
|
||||||
|
2. ❌ **선택된 행만 다운로드**: 사용자가 원하지 않는 동작
|
||||||
|
3. ❌ **모든 컬럼 포함**: 화면에 표시되지 않는 컬럼도 다운로드됨
|
||||||
|
4. ❌ **필터 조건 무시**: 사용자가 설정한 검색/필터가 적용되지 않음
|
||||||
|
5. ❌ **멀티테넌시 위반**: 모든 회사의 데이터를 가져올 가능성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 수정된 다운로드 동작 흐름
|
||||||
|
|
||||||
|
### Before (현재 - 잘못된 동작)
|
||||||
|
|
||||||
|
```
|
||||||
|
엑셀 다운로드 버튼 클릭
|
||||||
|
↓
|
||||||
|
1. 선택된 행이 있는가?
|
||||||
|
├─ Yes → 선택된 행만 다운로드 ❌ (사용자가 원하지 않음)
|
||||||
|
└─ No → 현재 페이지 데이터만 (10개 등) ❌ (전체가 아님)
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (수정 - 올바른 동작)
|
||||||
|
|
||||||
|
```
|
||||||
|
엑셀 다운로드 버튼 클릭
|
||||||
|
↓
|
||||||
|
🔒 멀티테넌시: company_code 자동 필터링
|
||||||
|
↓
|
||||||
|
🔍 필터 조건: 사용자가 설정한 검색/필터 적용
|
||||||
|
↓
|
||||||
|
📊 데이터 조회: 전체 필터링된 데이터 (최대 10,000개)
|
||||||
|
↓
|
||||||
|
🎨 컬럼 필터링: 화면에 표시된 컬럼만
|
||||||
|
↓
|
||||||
|
🏷️ 라벨 적용: 컬럼명 → 한글 라벨명
|
||||||
|
↓
|
||||||
|
💾 엑셀 다운로드
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 수정된 데이터 우선순위
|
||||||
|
|
||||||
|
### ❌ 제거: 선택된 행 다운로드
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 삭제할 코드
|
||||||
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
dataToExport = context.selectedRowsData; // 불필요!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 새로운 우선순위
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 항상 API 호출로 전체 필터링된 데이터 가져오기
|
||||||
|
const response = await entityJoinApi.getTableDataWithJoins(context.tableName, {
|
||||||
|
page: 1,
|
||||||
|
size: 10000, // 최대 10,000개
|
||||||
|
sortBy: context.sortBy || "id",
|
||||||
|
sortOrder: (context.sortOrder || "asc") as "asc" | "desc",
|
||||||
|
search: context.filterConditions, // ✅ 필터 조건
|
||||||
|
searchTerm: context.searchTerm, // ✅ 검색어
|
||||||
|
autoFilter: true, // ✅ company_code 자동 필터링 (멀티테넌시)
|
||||||
|
enableEntityJoin: true, // ✅ Entity 조인 (writer_name 등)
|
||||||
|
});
|
||||||
|
|
||||||
|
dataToExport = response.data; // 필터링된 전체 데이터
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 수정 사항
|
||||||
|
|
||||||
|
### 1. `buttonActions.ts` - handleExcelDownload 리팩토링
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/utils/buttonActions.ts`
|
||||||
|
|
||||||
|
#### 변경 전
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 잘못된 우선순위
|
||||||
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
dataToExport = context.selectedRowsData; // 선택된 행만
|
||||||
|
}
|
||||||
|
else if (context.tableDisplayData && context.tableDisplayData.length > 0) {
|
||||||
|
dataToExport = context.tableDisplayData; // 현재 페이지만
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 변경 후
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private static async handleExcelDownload(
|
||||||
|
config: ButtonActionConfig,
|
||||||
|
context: ButtonActionContext
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let dataToExport: any[] = [];
|
||||||
|
|
||||||
|
// ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기
|
||||||
|
if (context.tableName) {
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
|
||||||
|
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
|
||||||
|
const response = await entityJoinApi.getTableDataWithJoins(context.tableName, {
|
||||||
|
page: 1,
|
||||||
|
size: 10000, // 최대 10,000개
|
||||||
|
sortBy: context.sortBy || "id",
|
||||||
|
sortOrder: (context.sortOrder || "asc") as "asc" | "desc",
|
||||||
|
search: context.filterConditions, // ✅ 필터 조건
|
||||||
|
searchTerm: context.searchTerm, // ✅ 검색어
|
||||||
|
autoFilter: true, // ✅ company_code 자동 필터링 (멀티테넌시)
|
||||||
|
enableEntityJoin: true, // ✅ Entity 조인
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
dataToExport = response.data;
|
||||||
|
} else {
|
||||||
|
toast.error("데이터를 가져오는데 실패했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error("테이블 정보가 없습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터가 없으면 종료
|
||||||
|
if (dataToExport.length === 0) {
|
||||||
|
toast.error("다운로드할 데이터가 없습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎨 컬럼 필터링 및 라벨 적용
|
||||||
|
if (context.visibleColumns && context.visibleColumns.length > 0) {
|
||||||
|
const visibleColumns = context.visibleColumns;
|
||||||
|
const columnLabels = context.columnLabels || {};
|
||||||
|
|
||||||
|
dataToExport = dataToExport.map((row) => {
|
||||||
|
const filteredRow: Record<string, any> = {};
|
||||||
|
|
||||||
|
visibleColumns.forEach((columnName) => {
|
||||||
|
// 라벨 우선 사용, 없으면 컬럼명 사용
|
||||||
|
const label = columnLabels[columnName] || columnName;
|
||||||
|
filteredRow[label] = row[columnName];
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💾 엑셀 파일 생성
|
||||||
|
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||||
|
|
||||||
|
const fileName =
|
||||||
|
config.excelFileName || `${context.tableName}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||||
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
|
|
||||||
|
await exportToExcel(dataToExport, fileName, {
|
||||||
|
sheetName,
|
||||||
|
includeHeaders: config.excelIncludeHeaders !== false,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("엑셀 다운로드 오류:", error);
|
||||||
|
toast.error("엑셀 다운로드 중 오류가 발생했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 보안 강화 (멀티테넌시)
|
||||||
|
|
||||||
|
### Before (위험)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 모든 회사 데이터 노출
|
||||||
|
await dynamicFormApi.getTableData(tableName, {
|
||||||
|
pageSize: 10000, // 필터 없음!
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (안전)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 멀티테넌시 준수
|
||||||
|
await entityJoinApi.getTableDataWithJoins(tableName, {
|
||||||
|
size: 10000,
|
||||||
|
search: filterConditions, // 필터 조건
|
||||||
|
searchTerm: searchTerm, // 검색어
|
||||||
|
autoFilter: true, // company_code 자동 필터링 ✅
|
||||||
|
enableEntityJoin: true, // Entity 조인 ✅
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 구현 체크리스트
|
||||||
|
|
||||||
|
### Step 1: handleExcelDownload 단순화
|
||||||
|
|
||||||
|
- [ ] 선택된 행 다운로드 로직 제거 (`context.selectedRowsData` 체크 삭제)
|
||||||
|
- [ ] 화면 표시 데이터 로직 제거 (`context.tableDisplayData` 체크 삭제)
|
||||||
|
- [ ] 항상 API 호출로 변경 (entityJoinApi.getTableDataWithJoins)
|
||||||
|
- [ ] 멀티테넌시 필수 적용 (`autoFilter: true`)
|
||||||
|
- [ ] 필터 조건 전달 (`search`, `searchTerm`)
|
||||||
|
|
||||||
|
### Step 2: 컬럼 필터링 및 라벨 적용
|
||||||
|
|
||||||
|
- [ ] `context.visibleColumns`로 필터링
|
||||||
|
- [ ] `context.columnLabels`로 라벨 변환
|
||||||
|
- [ ] 라벨 우선, 없으면 컬럼명 사용
|
||||||
|
|
||||||
|
### Step 3: 테스트
|
||||||
|
|
||||||
|
- [ ] 필터 없이 다운로드 → 전체 데이터 (company_code 필터링)
|
||||||
|
- [ ] 검색어 입력 후 다운로드 → 검색된 데이터만
|
||||||
|
- [ ] 필터 설정 후 다운로드 → 필터링된 데이터만
|
||||||
|
- [ ] 컬럼 숨기기 후 다운로드 → 표시된 컬럼만
|
||||||
|
- [ ] 멀티테넌시 테스트 → 다른 회사 데이터 안 보임
|
||||||
|
- [ ] 10,000개 제한 확인
|
||||||
|
|
||||||
|
### Step 4: 문서화
|
||||||
|
|
||||||
|
- [ ] 주석 추가
|
||||||
|
- [ ] 계획서 업데이트
|
||||||
|
- [ ] 커밋 메시지 작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 예상 효과
|
||||||
|
|
||||||
|
1. **보안 강화**: 멀티테넌시 100% 준수
|
||||||
|
2. **사용자 경험 개선**: 필터링된 전체 데이터 다운로드
|
||||||
|
3. **직관적인 동작**: 화면에 보이는 대로 다운로드
|
||||||
|
4. **한글 지원**: 컬럼 라벨명으로 엑셀 생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 승인
|
||||||
|
|
||||||
|
**사용자 승인**: ⬜ 대기 중
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성 완료**: 2025-01-10
|
||||||
|
**다음 업데이트**: 구현 완료 후
|
||||||
|
|
||||||
|
|
@ -563,7 +563,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
|
|
||||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||||
{type === "widget" && !isFileComponent(component) && (
|
{type === "widget" && !isFileComponent(component) && (
|
||||||
<div className="pointer-events-none h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WidgetRenderer
|
<WidgetRenderer
|
||||||
component={component}
|
component={component}
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
|
|
|
||||||
|
|
@ -257,13 +257,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
}
|
}
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
|
// componentStyle에서 width, height 제거 (size.width, size.height만 사용)
|
||||||
|
const { width: _styleWidth, height: _styleHeight, ...restComponentStyle } = componentStyle || {};
|
||||||
|
|
||||||
const baseStyle = {
|
const baseStyle = {
|
||||||
left: `${position.x}px`,
|
left: `${position.x}px`,
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
width: getWidth(), // getWidth()가 모든 우선순위를 처리
|
...restComponentStyle, // width/height 제외한 스타일 먼저 적용
|
||||||
height: getHeight(),
|
width: getWidth(), // size.width로 덮어쓰기
|
||||||
|
height: getHeight(), // size.height로 덮어쓰기
|
||||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||||
...componentStyle,
|
|
||||||
right: undefined,
|
right: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,18 +25,44 @@ import {
|
||||||
restoreAbsolutePositions,
|
restoreAbsolutePositions,
|
||||||
} from "@/lib/utils/groupingUtils";
|
} from "@/lib/utils/groupingUtils";
|
||||||
import {
|
import {
|
||||||
calculateGridInfo,
|
|
||||||
snapToGrid,
|
|
||||||
snapSizeToGrid,
|
|
||||||
generateGridLines,
|
|
||||||
updateSizeFromGridColumns,
|
|
||||||
adjustGridColumnsFromSize,
|
adjustGridColumnsFromSize,
|
||||||
alignGroupChildrenToGrid,
|
updateSizeFromGridColumns,
|
||||||
calculateOptimalGroupSize,
|
|
||||||
normalizeGroupChildPositions,
|
|
||||||
calculateWidthFromColumns,
|
calculateWidthFromColumns,
|
||||||
GridSettings as GridUtilSettings,
|
snapSizeToGrid,
|
||||||
|
snapToGrid,
|
||||||
} from "@/lib/utils/gridUtils";
|
} from "@/lib/utils/gridUtils";
|
||||||
|
|
||||||
|
// 10px 단위 스냅 함수
|
||||||
|
const snapTo10px = (value: number): number => {
|
||||||
|
return Math.round(value / 10) * 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapPositionTo10px = (position: Position): Position => {
|
||||||
|
return {
|
||||||
|
x: snapTo10px(position.x),
|
||||||
|
y: snapTo10px(position.y),
|
||||||
|
z: position.z,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapSizeTo10px = (size: { width: number; height: number }): { width: number; height: number } => {
|
||||||
|
return {
|
||||||
|
width: snapTo10px(size.width),
|
||||||
|
height: snapTo10px(size.height),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// calculateGridInfo 더미 함수 (하위 호환성을 위해 유지)
|
||||||
|
const calculateGridInfo = (width: number, height: number, settings: any) => {
|
||||||
|
return {
|
||||||
|
columnWidth: 10,
|
||||||
|
totalWidth: width,
|
||||||
|
totalHeight: height,
|
||||||
|
columns: settings.columns || 12,
|
||||||
|
gap: settings.gap || 0,
|
||||||
|
padding: settings.padding || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
import { GroupingToolbar } from "./GroupingToolbar";
|
import { GroupingToolbar } from "./GroupingToolbar";
|
||||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
@ -57,7 +83,6 @@ import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
||||||
import { ComponentsPanel } from "./panels/ComponentsPanel";
|
import { ComponentsPanel } from "./panels/ComponentsPanel";
|
||||||
import PropertiesPanel from "./panels/PropertiesPanel";
|
import PropertiesPanel from "./panels/PropertiesPanel";
|
||||||
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
||||||
import GridPanel from "./panels/GridPanel";
|
|
||||||
import ResolutionPanel from "./panels/ResolutionPanel";
|
import ResolutionPanel from "./panels/ResolutionPanel";
|
||||||
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||||
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
||||||
|
|
@ -281,55 +306,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 격자 정보 계산
|
// 10px 격자 라인 생성 (시각적 가이드용)
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
|
||||||
|
|
||||||
const gridInfo = useMemo(() => {
|
|
||||||
if (!layout.gridSettings) return null;
|
|
||||||
|
|
||||||
// 캔버스 크기 계산 (해상도 설정 우선)
|
|
||||||
let width = screenResolution.width;
|
|
||||||
let height = screenResolution.height;
|
|
||||||
|
|
||||||
// 해상도가 설정되지 않은 경우 기본값 사용
|
|
||||||
if (!width || !height) {
|
|
||||||
width = canvasSize.width || window.innerWidth - 100;
|
|
||||||
height = canvasSize.height || window.innerHeight - 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newGridInfo = calculateGridInfo(width, height, {
|
|
||||||
columns: layout.gridSettings.columns,
|
|
||||||
gap: layout.gridSettings.gap,
|
|
||||||
padding: layout.gridSettings.padding,
|
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return newGridInfo;
|
|
||||||
}, [layout.gridSettings, screenResolution]);
|
|
||||||
|
|
||||||
// 격자 라인 생성
|
|
||||||
const gridLines = useMemo(() => {
|
const gridLines = useMemo(() => {
|
||||||
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
|
if (!layout.gridSettings?.showGrid) return [];
|
||||||
|
|
||||||
// 캔버스 크기는 해상도 크기 사용
|
|
||||||
const width = screenResolution.width;
|
const width = screenResolution.width;
|
||||||
const height = screenResolution.height;
|
const height = screenResolution.height;
|
||||||
|
const lines: Array<{ type: "vertical" | "horizontal"; position: number }> = [];
|
||||||
|
|
||||||
const lines = generateGridLines(width, height, {
|
// 10px 단위로 격자 라인 생성
|
||||||
columns: layout.gridSettings.columns,
|
for (let x = 0; x <= width; x += 10) {
|
||||||
gap: layout.gridSettings.gap,
|
lines.push({ type: "vertical", position: x });
|
||||||
padding: layout.gridSettings.padding,
|
}
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
for (let y = 0; y <= height; y += 10) {
|
||||||
});
|
lines.push({ type: "horizontal", position: y });
|
||||||
|
}
|
||||||
|
|
||||||
// 수직선과 수평선을 하나의 배열로 합치기
|
return lines;
|
||||||
const allLines = [
|
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
|
||||||
...lines.verticalLines.map((pos) => ({ type: "vertical" as const, position: pos })),
|
|
||||||
...lines.horizontalLines.map((pos) => ({ type: "horizontal" as const, position: pos })),
|
|
||||||
];
|
|
||||||
|
|
||||||
return allLines;
|
|
||||||
}, [gridInfo, layout.gridSettings, screenResolution]);
|
|
||||||
|
|
||||||
// 필터된 테이블 목록
|
// 필터된 테이블 목록
|
||||||
const filteredTables = useMemo(() => {
|
const filteredTables = useMemo(() => {
|
||||||
|
|
@ -527,64 +521,61 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const finalKey = pathParts[pathParts.length - 1];
|
const finalKey = pathParts[pathParts.length - 1];
|
||||||
current[finalKey] = value;
|
current[finalKey] = value;
|
||||||
|
|
||||||
// gridColumns 변경 시 크기 자동 업데이트
|
// gridColumns 변경 시 크기 자동 업데이트 제거 (격자 시스템 제거됨)
|
||||||
if (path === "gridColumns" && gridInfo) {
|
// if (path === "gridColumns" && prevLayout.gridSettings) {
|
||||||
const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
|
// const updatedSize = updateSizeFromGridColumns(newComp, prevLayout.gridSettings as GridUtilSettings);
|
||||||
newComp.size = updatedSize;
|
// newComp.size = updatedSize;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
|
// 크기 변경 시 격자 스냅 적용 제거 (직접 입력 시 불필요)
|
||||||
if (
|
// 드래그/리사이즈 시에는 별도 로직에서 처리됨
|
||||||
(path === "size.width" || path === "size.height") &&
|
// if (
|
||||||
prevLayout.gridSettings?.snapToGrid &&
|
// (path === "size.width" || path === "size.height") &&
|
||||||
gridInfo &&
|
// prevLayout.gridSettings?.snapToGrid &&
|
||||||
newComp.type !== "group"
|
// newComp.type !== "group"
|
||||||
) {
|
// ) {
|
||||||
// 현재 해상도에 맞는 격자 정보로 스냅 적용
|
// const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
// columns: prevLayout.gridSettings.columns,
|
||||||
columns: prevLayout.gridSettings.columns,
|
// gap: prevLayout.gridSettings.gap,
|
||||||
gap: prevLayout.gridSettings.gap,
|
// padding: prevLayout.gridSettings.padding,
|
||||||
padding: prevLayout.gridSettings.padding,
|
// snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
||||||
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
// });
|
||||||
});
|
// const snappedSize = snapSizeToGrid(
|
||||||
const snappedSize = snapSizeToGrid(
|
// newComp.size,
|
||||||
newComp.size,
|
// currentGridInfo,
|
||||||
currentGridInfo,
|
// prevLayout.gridSettings as GridUtilSettings,
|
||||||
prevLayout.gridSettings as GridUtilSettings,
|
// );
|
||||||
);
|
// newComp.size = snappedSize;
|
||||||
newComp.size = snappedSize;
|
//
|
||||||
|
// const adjustedColumns = adjustGridColumnsFromSize(
|
||||||
|
// newComp,
|
||||||
|
// currentGridInfo,
|
||||||
|
// prevLayout.gridSettings as GridUtilSettings,
|
||||||
|
// );
|
||||||
|
// if (newComp.gridColumns !== adjustedColumns) {
|
||||||
|
// newComp.gridColumns = adjustedColumns;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
// 크기 변경 시 gridColumns도 자동 조정
|
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정 제거 (격자 시스템 제거됨)
|
||||||
const adjustedColumns = adjustGridColumnsFromSize(
|
// if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
|
||||||
newComp,
|
// const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||||
currentGridInfo,
|
// columns: prevLayout.gridSettings.columns,
|
||||||
prevLayout.gridSettings as GridUtilSettings,
|
// gap: prevLayout.gridSettings.gap,
|
||||||
);
|
// padding: prevLayout.gridSettings.padding,
|
||||||
if (newComp.gridColumns !== adjustedColumns) {
|
// snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
||||||
newComp.gridColumns = adjustedColumns;
|
// });
|
||||||
}
|
//
|
||||||
}
|
// const newWidth = calculateWidthFromColumns(
|
||||||
|
// newComp.gridColumns,
|
||||||
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정
|
// currentGridInfo,
|
||||||
if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
|
// prevLayout.gridSettings as GridUtilSettings,
|
||||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
// );
|
||||||
columns: prevLayout.gridSettings.columns,
|
// newComp.size = {
|
||||||
gap: prevLayout.gridSettings.gap,
|
// ...newComp.size,
|
||||||
padding: prevLayout.gridSettings.padding,
|
// width: newWidth,
|
||||||
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
// };
|
||||||
});
|
// }
|
||||||
|
|
||||||
// gridColumns에 맞는 정확한 너비 계산
|
|
||||||
const newWidth = calculateWidthFromColumns(
|
|
||||||
newComp.gridColumns,
|
|
||||||
currentGridInfo,
|
|
||||||
prevLayout.gridSettings as GridUtilSettings,
|
|
||||||
);
|
|
||||||
newComp.size = {
|
|
||||||
...newComp.size,
|
|
||||||
width: newWidth,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
|
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
|
||||||
if (
|
if (
|
||||||
|
|
@ -634,7 +625,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
};
|
};
|
||||||
} else if (newComp.type !== "group") {
|
} else if (newComp.type !== "group") {
|
||||||
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
|
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
|
||||||
const snappedPosition = snapToGrid(
|
const snappedPosition = snapPositionTo10px(
|
||||||
newComp.position,
|
newComp.position,
|
||||||
currentGridInfo,
|
currentGridInfo,
|
||||||
layout.gridSettings as GridUtilSettings,
|
layout.gridSettings as GridUtilSettings,
|
||||||
|
|
@ -684,7 +675,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
return newLayout;
|
return newLayout;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[gridInfo, saveToHistory], // 🔧 layout, selectedComponent 제거!
|
[saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 컴포넌트 시스템 초기화
|
// 컴포넌트 시스템 초기화
|
||||||
|
|
@ -1093,7 +1084,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
columns: newGridSettings.columns,
|
columns: newGridSettings.columns,
|
||||||
gap: newGridSettings.gap,
|
gap: newGridSettings.gap,
|
||||||
padding: newGridSettings.padding,
|
padding: newGridSettings.padding,
|
||||||
snapToGrid: newGridSettings.snapToGrid,
|
snapToGrid: true, // 항상 10px 스냅 활성화
|
||||||
};
|
};
|
||||||
|
|
||||||
const adjustedComponents = layout.components.map((comp) => {
|
const adjustedComponents = layout.components.map((comp) => {
|
||||||
|
|
@ -1208,7 +1199,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
columns: layout.gridSettings.columns,
|
columns: layout.gridSettings.columns,
|
||||||
gap: layout.gridSettings.gap,
|
gap: layout.gridSettings.gap,
|
||||||
padding: layout.gridSettings.padding,
|
padding: layout.gridSettings.padding,
|
||||||
snapToGrid: layout.gridSettings.snapToGrid,
|
snapToGrid: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
finalComponents = scaledComponents.map((comp) => {
|
finalComponents = scaledComponents.map((comp) => {
|
||||||
|
|
@ -1278,7 +1269,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
columns: layout.gridSettings.columns,
|
columns: layout.gridSettings.columns,
|
||||||
gap: layout.gridSettings.gap,
|
gap: layout.gridSettings.gap,
|
||||||
padding: layout.gridSettings.padding,
|
padding: layout.gridSettings.padding,
|
||||||
snapToGrid: layout.gridSettings.snapToGrid,
|
snapToGrid: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const adjustedComponents = layout.components.map((comp) => {
|
const adjustedComponents = layout.components.map((comp) => {
|
||||||
|
|
@ -1450,7 +1441,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 격자 스냅 적용
|
// 격자 스냅 적용
|
||||||
const finalPosition =
|
const finalPosition =
|
||||||
layout.gridSettings?.snapToGrid && currentGridInfo
|
layout.gridSettings?.snapToGrid && currentGridInfo
|
||||||
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
? snapPositionTo10px(
|
||||||
|
{ x: absoluteX, y: absoluteY, z: 1 },
|
||||||
|
currentGridInfo,
|
||||||
|
layout.gridSettings as GridUtilSettings,
|
||||||
|
)
|
||||||
: { x: absoluteX, y: absoluteY, z: 1 };
|
: { x: absoluteX, y: absoluteY, z: 1 };
|
||||||
|
|
||||||
if (templateComp.type === "container") {
|
if (templateComp.type === "container") {
|
||||||
|
|
@ -1516,7 +1511,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
templateSize: templateComp.size,
|
templateSize: templateComp.size,
|
||||||
calculatedSize,
|
calculatedSize,
|
||||||
hasGridInfo: !!currentGridInfo,
|
hasGridInfo: !!currentGridInfo,
|
||||||
hasGridSettings: !!layout.gridSettings?.snapToGrid,
|
hasGridSettings: !!layout.gridSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -1807,7 +1802,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
||||||
},
|
},
|
||||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
|
[layout, selectedScreen, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 레이아웃 드래그 처리
|
// 레이아웃 드래그 처리
|
||||||
|
|
@ -1877,7 +1872,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||||
},
|
},
|
||||||
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory, zoomLevel],
|
[layout, screenResolution, saveToHistory, zoomLevel],
|
||||||
);
|
);
|
||||||
|
|
||||||
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
||||||
|
|
@ -2022,7 +2017,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 격자 스냅 적용
|
// 격자 스냅 적용
|
||||||
const snappedPosition =
|
const snappedPosition =
|
||||||
layout.gridSettings?.snapToGrid && currentGridInfo
|
layout.gridSettings?.snapToGrid && currentGridInfo
|
||||||
? snapToGrid({ x: boundedX, y: boundedY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
? snapPositionTo10px(
|
||||||
|
{ x: boundedX, y: boundedY, z: 1 },
|
||||||
|
currentGridInfo,
|
||||||
|
layout.gridSettings as GridUtilSettings,
|
||||||
|
)
|
||||||
: { x: boundedX, y: boundedY, z: 1 };
|
: { x: boundedX, y: boundedY, z: 1 };
|
||||||
|
|
||||||
console.log("🧩 컴포넌트 드롭:", {
|
console.log("🧩 컴포넌트 드롭:", {
|
||||||
|
|
@ -2131,21 +2130,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
|
// 10px 단위로 너비 스냅
|
||||||
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
if (layout.gridSettings?.snapToGrid) {
|
||||||
// gridColumns에 맞는 정확한 너비 계산
|
|
||||||
const calculatedWidth = calculateWidthFromColumns(
|
|
||||||
gridColumns,
|
|
||||||
gridInfo,
|
|
||||||
layout.gridSettings as GridUtilSettings,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 컴포넌트별 최소 크기 보장
|
|
||||||
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : component.defaultSize.width;
|
|
||||||
|
|
||||||
componentSize = {
|
componentSize = {
|
||||||
...component.defaultSize,
|
...component.defaultSize,
|
||||||
width: Math.max(calculatedWidth, minWidth),
|
width: snapTo10px(component.defaultSize.width),
|
||||||
|
height: snapTo10px(component.defaultSize.height),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2234,7 +2224,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
||||||
},
|
},
|
||||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
|
[layout, selectedScreen, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 드래그 앤 드롭 처리
|
// 드래그 앤 드롭 처리
|
||||||
|
|
@ -2309,74 +2299,44 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
};
|
};
|
||||||
} else if (type === "column") {
|
} else if (type === "column") {
|
||||||
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
|
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
|
||||||
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
|
|
||||||
const currentGridInfo = layout.gridSettings
|
|
||||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
|
||||||
columns: layout.gridSettings.columns,
|
|
||||||
gap: layout.gridSettings.gap,
|
|
||||||
padding: layout.gridSettings.padding,
|
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// 격자 스냅이 활성화된 경우 정확한 격자 크기로 생성, 아니면 기본값
|
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||||
const defaultWidth =
|
const getDefaultWidth = (widgetType: string): number => {
|
||||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
|
||||||
? calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
|
||||||
: 200;
|
|
||||||
|
|
||||||
console.log("🎯 컴포넌트 생성 시 크기 계산:", {
|
|
||||||
screenResolution: `${screenResolution.width}x${screenResolution.height}`,
|
|
||||||
gridSettings: layout.gridSettings,
|
|
||||||
currentGridInfo: currentGridInfo
|
|
||||||
? {
|
|
||||||
columnWidth: currentGridInfo.columnWidth.toFixed(2),
|
|
||||||
totalWidth: currentGridInfo.totalWidth,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
defaultWidth: defaultWidth.toFixed(2),
|
|
||||||
snapToGrid: layout.gridSettings?.snapToGrid,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 웹타입별 기본 그리드 컬럼 수 계산
|
|
||||||
const getDefaultGridColumns = (widgetType: string): number => {
|
|
||||||
const widthMap: Record<string, number> = {
|
const widthMap: Record<string, number> = {
|
||||||
// 텍스트 입력 계열 (넓게)
|
// 텍스트 입력 계열
|
||||||
text: 4, // 1/3 (33%)
|
text: 200,
|
||||||
email: 4, // 1/3 (33%)
|
email: 200,
|
||||||
tel: 3, // 1/4 (25%)
|
tel: 150,
|
||||||
url: 4, // 1/3 (33%)
|
url: 250,
|
||||||
textarea: 6, // 절반 (50%)
|
textarea: 300,
|
||||||
|
|
||||||
// 숫자/날짜 입력 (중간)
|
// 숫자/날짜 입력
|
||||||
number: 2, // 2/12 (16.67%)
|
number: 120,
|
||||||
decimal: 2, // 2/12 (16.67%)
|
decimal: 120,
|
||||||
date: 3, // 1/4 (25%)
|
date: 150,
|
||||||
datetime: 3, // 1/4 (25%)
|
datetime: 180,
|
||||||
time: 2, // 2/12 (16.67%)
|
time: 120,
|
||||||
|
|
||||||
// 선택 입력 (중간)
|
// 선택 입력
|
||||||
select: 3, // 1/4 (25%)
|
select: 180,
|
||||||
radio: 3, // 1/4 (25%)
|
radio: 180,
|
||||||
checkbox: 2, // 2/12 (16.67%)
|
checkbox: 120,
|
||||||
boolean: 2, // 2/12 (16.67%)
|
boolean: 120,
|
||||||
|
|
||||||
// 코드/참조 (넓게)
|
// 코드/참조
|
||||||
code: 3, // 1/4 (25%)
|
code: 180,
|
||||||
entity: 4, // 1/3 (33%)
|
entity: 200,
|
||||||
|
|
||||||
// 파일/이미지 (넓게)
|
// 파일/이미지
|
||||||
file: 4, // 1/3 (33%)
|
file: 250,
|
||||||
image: 3, // 1/4 (25%)
|
image: 200,
|
||||||
|
|
||||||
// 기타
|
// 기타
|
||||||
button: 2, // 2/12 (16.67%)
|
button: 100,
|
||||||
label: 2, // 2/12 (16.67%)
|
label: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultColumns = widthMap[widgetType] || 3; // 기본값 3 (1/4, 25%)
|
return widthMap[widgetType] || 200; // 기본값 200px
|
||||||
console.log("🎯 [ScreenDesigner] getDefaultGridColumns:", { widgetType, defaultColumns });
|
|
||||||
return defaultColumns;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 웹타입별 기본 높이 계산
|
// 웹타입별 기본 높이 계산
|
||||||
|
|
@ -2544,24 +2504,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||||
// console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
|
// console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
|
||||||
|
|
||||||
// 웹타입별 적절한 gridColumns 계산
|
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||||
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
|
const componentWidth = getDefaultWidth(column.widgetType);
|
||||||
|
|
||||||
// gridColumns에 맞는 실제 너비 계산
|
|
||||||
const componentWidth =
|
|
||||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
|
||||||
? calculateWidthFromColumns(
|
|
||||||
calculatedGridColumns,
|
|
||||||
currentGridInfo,
|
|
||||||
layout.gridSettings as GridUtilSettings,
|
|
||||||
)
|
|
||||||
: defaultWidth;
|
|
||||||
|
|
||||||
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
|
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
|
||||||
widgetType: column.widgetType,
|
widgetType: column.widgetType,
|
||||||
calculatedGridColumns,
|
|
||||||
componentWidth,
|
componentWidth,
|
||||||
defaultWidth,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
|
|
@ -2576,7 +2524,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
gridColumns: calculatedGridColumns,
|
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
column.codeCategory && {
|
column.codeCategory && {
|
||||||
|
|
@ -2588,7 +2535,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
labelColor: "#212121",
|
labelColor: "#212121",
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
labelMarginBottom: "6px",
|
labelMarginBottom: "6px",
|
||||||
width: `${(calculatedGridColumns / (layout.gridSettings?.columns || 12)) * 100}%`, // 퍼센트 너비
|
|
||||||
},
|
},
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: componentId, // text-input, number-input 등
|
type: componentId, // text-input, number-input 등
|
||||||
|
|
@ -2611,36 +2557,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||||
// console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
|
// console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
|
||||||
|
|
||||||
// 웹타입별 적절한 gridColumns 계산
|
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||||
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
|
const componentWidth = getDefaultWidth(column.widgetType);
|
||||||
|
|
||||||
// gridColumns에 맞는 실제 너비 계산
|
|
||||||
const componentWidth =
|
|
||||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
|
||||||
? calculateWidthFromColumns(
|
|
||||||
calculatedGridColumns,
|
|
||||||
currentGridInfo,
|
|
||||||
layout.gridSettings as GridUtilSettings,
|
|
||||||
)
|
|
||||||
: defaultWidth;
|
|
||||||
|
|
||||||
console.log("🎯 캔버스 컴포넌트 생성:", {
|
console.log("🎯 캔버스 컴포넌트 생성:", {
|
||||||
widgetType: column.widgetType,
|
widgetType: column.widgetType,
|
||||||
calculatedGridColumns,
|
|
||||||
componentWidth,
|
componentWidth,
|
||||||
defaultWidth,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔍 이미지 타입 드래그앤드롭 디버깅
|
|
||||||
// if (column.widgetType === "image") {
|
|
||||||
// console.log("🖼️ 이미지 컬럼 드래그앤드롭:", {
|
|
||||||
// columnName: column.columnName,
|
|
||||||
// widgetType: column.widgetType,
|
|
||||||
// componentId,
|
|
||||||
// column,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
||||||
|
|
@ -2652,7 +2576,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||||
position: { x, y, z: 1 } as Position,
|
position: { x, y, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
gridColumns: calculatedGridColumns,
|
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
column.codeCategory && {
|
column.codeCategory && {
|
||||||
|
|
@ -2664,7 +2587,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
labelColor: "#000000", // 순수한 검정
|
labelColor: "#000000", // 순수한 검정
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
labelMarginBottom: "8px",
|
labelMarginBottom: "8px",
|
||||||
width: `${(calculatedGridColumns / (layout.gridSettings?.columns || 12)) * 100}%`, // 퍼센트 너비
|
|
||||||
},
|
},
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: componentId, // text-input, number-input 등
|
type: componentId, // text-input, number-input 등
|
||||||
|
|
@ -2684,31 +2606,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 격자 스냅 적용 (그룹 컴포넌트 제외)
|
// 10px 단위 스냅 적용 (그룹 컴포넌트 제외)
|
||||||
if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") {
|
if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") {
|
||||||
// 현재 해상도에 맞는 격자 정보 계산
|
newComponent.position = snapPositionTo10px(newComponent.position);
|
||||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
newComponent.size = snapSizeTo10px(newComponent.size);
|
||||||
columns: layout.gridSettings.columns,
|
|
||||||
gap: layout.gridSettings.gap,
|
|
||||||
padding: layout.gridSettings.padding,
|
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const gridUtilSettings = {
|
console.log("🧲 새 컴포넌트 10px 스냅 적용:", {
|
||||||
columns: layout.gridSettings.columns,
|
|
||||||
gap: layout.gridSettings.gap,
|
|
||||||
padding: layout.gridSettings.padding,
|
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
|
||||||
};
|
|
||||||
newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings);
|
|
||||||
newComponent.size = snapSizeToGrid(newComponent.size, currentGridInfo, gridUtilSettings);
|
|
||||||
|
|
||||||
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
|
|
||||||
type: newComponent.type,
|
type: newComponent.type,
|
||||||
resolution: `${screenResolution.width}x${screenResolution.height}`,
|
|
||||||
snappedPosition: newComponent.position,
|
snappedPosition: newComponent.position,
|
||||||
snappedSize: newComponent.size,
|
snappedSize: newComponent.size,
|
||||||
columnWidth: currentGridInfo.columnWidth,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2735,7 +2641,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// console.error("드롭 처리 실패:", error);
|
// console.error("드롭 처리 실패:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[layout, gridInfo, saveToHistory],
|
[layout, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 파일 컴포넌트 업데이트 처리
|
// 파일 컴포넌트 업데이트 처리
|
||||||
|
|
@ -3014,7 +2920,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
|
// 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
|
||||||
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
|
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
|
||||||
finalPosition = snapToGrid(
|
finalPosition = snapPositionTo10px(
|
||||||
{
|
{
|
||||||
x: dragState.currentPosition.x,
|
x: dragState.currentPosition.x,
|
||||||
y: dragState.currentPosition.y,
|
y: dragState.currentPosition.y,
|
||||||
|
|
@ -3174,7 +3080,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
justFinishedDrag: false,
|
justFinishedDrag: false,
|
||||||
}));
|
}));
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [dragState, layout, gridInfo, saveToHistory]);
|
}, [dragState, layout, saveToHistory]);
|
||||||
|
|
||||||
// 드래그 선택 시작
|
// 드래그 선택 시작
|
||||||
const startSelectionDrag = useCallback(
|
const startSelectionDrag = useCallback(
|
||||||
|
|
@ -3669,8 +3575,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
console.log("🔧 그룹 생성 시작:", {
|
console.log("🔧 그룹 생성 시작:", {
|
||||||
selectedCount: selectedComponents.length,
|
selectedCount: selectedComponents.length,
|
||||||
snapToGrid: layout.gridSettings?.snapToGrid,
|
snapToGrid: true,
|
||||||
gridInfo: currentGridInfo,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 컴포넌트 크기 조정 기반 그룹 크기 계산
|
// 컴포넌트 크기 조정 기반 그룹 크기 계산
|
||||||
|
|
@ -3834,12 +3739,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
size: optimizedGroupSize,
|
size: optimizedGroupSize,
|
||||||
gridColumns: groupComponent.gridColumns,
|
gridColumns: groupComponent.gridColumns,
|
||||||
componentsScaled: !!scaledComponents.length,
|
componentsScaled: !!scaledComponents.length,
|
||||||
gridAligned: layout.gridSettings?.snapToGrid,
|
gridAligned: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`);
|
toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`);
|
||||||
},
|
},
|
||||||
[layout, saveToHistory, gridInfo],
|
[layout, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 그룹 생성 함수 (다이얼로그 표시)
|
// 그룹 생성 함수 (다이얼로그 표시)
|
||||||
|
|
@ -3935,36 +3840,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
endSelectionDrag,
|
endSelectionDrag,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 캔버스 크기 초기화 및 리사이즈 이벤트 처리
|
|
||||||
useEffect(() => {
|
|
||||||
const updateCanvasSize = () => {
|
|
||||||
if (canvasRef.current) {
|
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
|
||||||
setCanvasSize({ width: rect.width, height: rect.height });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 초기 크기 설정
|
|
||||||
updateCanvasSize();
|
|
||||||
|
|
||||||
// 리사이즈 이벤트 리스너
|
|
||||||
window.addEventListener("resize", updateCanvasSize);
|
|
||||||
|
|
||||||
return () => window.removeEventListener("resize", updateCanvasSize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 컴포넌트 마운트 후 캔버스 크기 업데이트
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (canvasRef.current) {
|
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
|
||||||
setCanvasSize({ width: rect.width, height: rect.height });
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [selectedScreen]);
|
|
||||||
|
|
||||||
// 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단)
|
// 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||||
|
|
@ -4253,7 +4128,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenPreviewProvider isPreviewMode={true}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
<div className="bg-background flex h-full w-full flex-col">
|
<div className="bg-background flex h-full w-full flex-col">
|
||||||
{/* 상단 슬림 툴바 */}
|
{/* 상단 슬림 툴바 */}
|
||||||
<SlimToolbar
|
<SlimToolbar
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,20 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap, RefreshCw } from "lucide-react";
|
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
|
||||||
import { GridSettings, ScreenResolution } from "@/types/screen";
|
import { GridSettings, ScreenResolution } from "@/types/screen";
|
||||||
import { calculateGridInfo } from "@/lib/utils/gridUtils";
|
|
||||||
|
|
||||||
interface GridPanelProps {
|
interface GridPanelProps {
|
||||||
gridSettings: GridSettings;
|
gridSettings: GridSettings;
|
||||||
onGridSettingsChange: (settings: GridSettings) => void;
|
onGridSettingsChange: (settings: GridSettings) => void;
|
||||||
onResetGrid: () => void;
|
onResetGrid: () => void;
|
||||||
onForceGridUpdate?: () => void; // 강제 격자 재조정 추가
|
screenResolution?: ScreenResolution;
|
||||||
screenResolution?: ScreenResolution; // 해상도 정보 추가
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GridPanel: React.FC<GridPanelProps> = ({
|
export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
gridSettings,
|
gridSettings,
|
||||||
onGridSettingsChange,
|
onGridSettingsChange,
|
||||||
onResetGrid,
|
onResetGrid,
|
||||||
onForceGridUpdate,
|
|
||||||
screenResolution,
|
screenResolution,
|
||||||
}) => {
|
}) => {
|
||||||
const updateSetting = (key: keyof GridSettings, value: any) => {
|
const updateSetting = (key: keyof GridSettings, value: any) => {
|
||||||
|
|
@ -33,32 +30,6 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 최대 컬럼 수 계산 (최소 컬럼 너비 30px 기준)
|
|
||||||
const MIN_COLUMN_WIDTH = 30;
|
|
||||||
const maxColumns = screenResolution
|
|
||||||
? Math.floor((screenResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
|
|
||||||
: 24;
|
|
||||||
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
|
|
||||||
|
|
||||||
// 실제 격자 정보 계산
|
|
||||||
const actualGridInfo = screenResolution
|
|
||||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
|
||||||
columns: gridSettings.columns,
|
|
||||||
gap: gridSettings.gap,
|
|
||||||
padding: gridSettings.padding,
|
|
||||||
snapToGrid: gridSettings.snapToGrid || false,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// 실제 표시되는 컬럼 수 계산 (항상 설정된 개수를 표시하되, 너비가 너무 작으면 경고)
|
|
||||||
const actualColumns = gridSettings.columns;
|
|
||||||
|
|
||||||
// 컬럼이 너무 작은지 확인
|
|
||||||
const isColumnsTooSmall =
|
|
||||||
screenResolution && actualGridInfo
|
|
||||||
? actualGridInfo.columnWidth < MIN_COLUMN_WIDTH
|
|
||||||
: false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
|
|
@ -69,25 +40,10 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
<h3 className="text-sm font-semibold">격자 설정</h3>
|
<h3 className="text-sm font-semibold">격자 설정</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<Button size="sm" variant="outline" onClick={onResetGrid} className="h-7 px-2 text-xs">
|
||||||
{onForceGridUpdate && (
|
<RotateCcw className="mr-1 h-3 w-3" />
|
||||||
<Button
|
초기화
|
||||||
size="sm"
|
</Button>
|
||||||
variant="outline"
|
|
||||||
onClick={onForceGridUpdate}
|
|
||||||
className="h-7 px-2 text-xs" style={{ fontSize: "12px" }}
|
|
||||||
title="현재 해상도에 맞게 모든 컴포넌트를 격자에 재정렬합니다"
|
|
||||||
>
|
|
||||||
<RefreshCw className="mr-1 h-3 w-3" />
|
|
||||||
재정렬
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button size="sm" variant="outline" onClick={onResetGrid} className="h-7 px-2 text-xs">
|
|
||||||
<RotateCcw className="mr-1 h-3 w-3" />
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 주요 토글들 */}
|
{/* 주요 토글들 */}
|
||||||
|
|
@ -128,87 +84,14 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
|
|
||||||
{/* 설정 영역 */}
|
{/* 설정 영역 */}
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
{/* 격자 구조 */}
|
{/* 10px 단위 스냅 안내 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-xs font-semibold">격자 구조</h4>
|
<h4 className="text-xs font-semibold">격자 시스템</h4>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="bg-muted/50 rounded-md p-3">
|
||||||
<Label htmlFor="columns" className="text-xs font-medium">
|
<p className="text-xs text-muted-foreground">
|
||||||
컬럼 수
|
모든 컴포넌트는 10px 단위로 자동 배치됩니다.
|
||||||
</Label>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
id="columns"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={safeMaxColumns}
|
|
||||||
value={gridSettings.columns}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseInt(e.target.value, 10);
|
|
||||||
if (!isNaN(value) && value >= 1 && value <= safeMaxColumns) {
|
|
||||||
updateSetting("columns", value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground text-xs">/ {safeMaxColumns}</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
id="columns-slider"
|
|
||||||
min={1}
|
|
||||||
max={safeMaxColumns}
|
|
||||||
step={1}
|
|
||||||
value={[gridSettings.columns]}
|
|
||||||
onValueChange={([value]) => updateSetting("columns", value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="text-muted-foreground flex justify-between text-xs">
|
|
||||||
<span>1열</span>
|
|
||||||
<span>{safeMaxColumns}열</span>
|
|
||||||
</div>
|
|
||||||
{isColumnsTooSmall && (
|
|
||||||
<p className="text-xs text-amber-600">
|
|
||||||
⚠️ 컬럼 너비가 너무 작습니다 (최소 {MIN_COLUMN_WIDTH}px 권장)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="gap" className="text-xs font-medium">
|
|
||||||
간격: <span className="text-primary">{gridSettings.gap}px</span>
|
|
||||||
</Label>
|
|
||||||
<Slider
|
|
||||||
id="gap"
|
|
||||||
min={0}
|
|
||||||
max={40}
|
|
||||||
step={2}
|
|
||||||
value={[gridSettings.gap]}
|
|
||||||
onValueChange={([value]) => updateSetting("gap", value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="text-muted-foreground flex justify-between text-xs">
|
|
||||||
<span>0px</span>
|
|
||||||
<span>40px</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="padding" className="text-xs font-medium">
|
|
||||||
여백: <span className="text-primary">{gridSettings.padding}px</span>
|
|
||||||
</Label>
|
|
||||||
<Slider
|
|
||||||
id="padding"
|
|
||||||
min={0}
|
|
||||||
max={60}
|
|
||||||
step={4}
|
|
||||||
value={[gridSettings.padding]}
|
|
||||||
onValueChange={([value]) => updateSetting("padding", value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="text-muted-foreground flex justify-between text-xs">
|
|
||||||
<span>0px</span>
|
|
||||||
<span>60px</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -216,10 +99,10 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
|
|
||||||
{/* 격자 스타일 */}
|
{/* 격자 스타일 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium text-gray-900">격자 스타일</h4>
|
<h4 className="text-xs font-semibold">격자 스타일</h4>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="gridColor" className="text-sm font-medium">
|
<Label htmlFor="gridColor" className="text-xs font-medium">
|
||||||
격자 색상
|
격자 색상
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-1 flex items-center space-x-2">
|
<div className="mt-1 flex items-center space-x-2">
|
||||||
|
|
@ -235,13 +118,13 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
value={gridSettings.gridColor || "#d1d5db"}
|
value={gridSettings.gridColor || "#d1d5db"}
|
||||||
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
||||||
placeholder="#d1d5db"
|
placeholder="#d1d5db"
|
||||||
className="flex-1"
|
className="flex-1 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="gridOpacity" className="mb-2 block text-sm font-medium">
|
<Label htmlFor="gridOpacity" className="mb-2 block text-xs font-medium">
|
||||||
격자 투명도: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
|
격자 투명도: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
|
||||||
</Label>
|
</Label>
|
||||||
<Slider
|
<Slider
|
||||||
|
|
@ -253,7 +136,7 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
onValueChange={([value]) => updateSetting("gridOpacity", value)}
|
onValueChange={([value]) => updateSetting("gridOpacity", value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||||
<span>10%</span>
|
<span>10%</span>
|
||||||
<span>100%</span>
|
<span>100%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -264,68 +147,46 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
|
|
||||||
{/* 미리보기 */}
|
{/* 미리보기 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="font-medium text-gray-900">미리보기</h4>
|
<h4 className="text-xs font-semibold">미리보기</h4>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="rounded-md border border-gray-200 bg-white p-4"
|
className="rounded-md border bg-white p-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: gridSettings.showGrid
|
backgroundImage: gridSettings.showGrid
|
||||||
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
|
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
|
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
|
||||||
: "none",
|
: "none",
|
||||||
backgroundSize: gridSettings.showGrid ? `${100 / gridSettings.columns}% 20px` : "none",
|
backgroundSize: gridSettings.showGrid ? "10px 10px" : "none",
|
||||||
opacity: gridSettings.gridOpacity || 0.5,
|
opacity: gridSettings.gridOpacity || 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-primary/20 flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300">
|
<div className="bg-primary/20 flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300">
|
||||||
<span className="text-primary text-xs">컴포넌트 예시</span>
|
<span className="text-primary text-xs">10px 격자</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 푸터 */}
|
{/* 푸터 */}
|
||||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
<div className="border-t bg-muted/30 p-3">
|
||||||
<div className="text-muted-foreground text-xs">💡 격자 설정은 실시간으로 캔버스에 반영됩니다 </div>
|
{screenResolution && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-semibold">화면 정보</h4>
|
||||||
|
|
||||||
{/* 해상도 및 격자 정보 */}
|
<div className="space-y-2 text-xs">
|
||||||
{screenResolution && actualGridInfo && (
|
<div className="flex justify-between">
|
||||||
<>
|
<span className="text-muted-foreground">해상도:</span>
|
||||||
<Separator />
|
<span className="font-mono">
|
||||||
<div className="space-y-3">
|
{screenResolution.width} × {screenResolution.height}
|
||||||
<h4 className="font-medium text-gray-900">격자 정보</h4>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 text-xs" style={{ fontSize: "12px" }}>
|
<div className="flex justify-between">
|
||||||
<div className="flex justify-between">
|
<span className="text-muted-foreground">격자 단위:</span>
|
||||||
<span className="text-muted-foreground">해상도:</span>
|
<span className="font-mono text-primary">10px</span>
|
||||||
<span className="font-mono">
|
|
||||||
{screenResolution.width} × {screenResolution.height}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">컬럼 너비:</span>
|
|
||||||
<span className={`font-mono ${isColumnsTooSmall ? "text-destructive" : "text-gray-900"}`}>
|
|
||||||
{actualGridInfo.columnWidth.toFixed(1)}px
|
|
||||||
{isColumnsTooSmall && " (너무 작음)"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">사용 가능 너비:</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{(screenResolution.width - gridSettings.padding * 2).toLocaleString()}px
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isColumnsTooSmall && (
|
|
||||||
<div className="rounded-md bg-yellow-50 p-2 text-xs text-yellow-800">
|
|
||||||
💡 컬럼이 너무 작습니다. 컬럼 수를 줄이거나 간격을 줄여보세요.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
const { webTypes } = useWebTypes({ active: "Y" });
|
const { webTypes } = useWebTypes({ active: "Y" });
|
||||||
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
||||||
|
|
||||||
// 높이 입력 로컬 상태 (격자 스냅 방지)
|
// 높이/너비 입력 로컬 상태 (자유 입력 허용)
|
||||||
const [localHeight, setLocalHeight] = useState<string>("");
|
const [localHeight, setLocalHeight] = useState<string>("");
|
||||||
|
const [localWidth, setLocalWidth] = useState<string>("");
|
||||||
|
|
||||||
// 새로운 컴포넌트 시스템의 webType 동기화
|
// 새로운 컴포넌트 시스템의 webType 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -125,6 +126,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
}
|
}
|
||||||
}, [selectedComponent?.size?.height, selectedComponent?.id]);
|
}, [selectedComponent?.size?.height, selectedComponent?.id]);
|
||||||
|
|
||||||
|
// 너비 값 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedComponent?.size?.width !== undefined) {
|
||||||
|
setLocalWidth(String(selectedComponent.size.width));
|
||||||
|
}
|
||||||
|
}, [selectedComponent?.size?.width, selectedComponent?.id]);
|
||||||
|
|
||||||
// 격자 설정 업데이트 함수 (early return 이전에 정의)
|
// 격자 설정 업데이트 함수 (early return 이전에 정의)
|
||||||
const updateGridSetting = (key: string, value: any) => {
|
const updateGridSetting = (key: string, value: any) => {
|
||||||
if (onGridSettingsChange && gridSettings) {
|
if (onGridSettingsChange && gridSettings) {
|
||||||
|
|
@ -187,66 +195,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 수 */}
|
{/* 10px 단위 스냅 안내 */}
|
||||||
<div className="space-y-1">
|
<div className="bg-muted/50 rounded-md p-2">
|
||||||
<Label htmlFor="columns" className="text-xs font-medium">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
컬럼 수
|
모든 컴포넌트는 10px 단위로 자동 배치됩니다.
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
id="columns"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={safeMaxColumns}
|
|
||||||
step="1"
|
|
||||||
value={gridSettings.columns}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseInt(e.target.value, 10);
|
|
||||||
if (!isNaN(value) && value >= 1 && value <= safeMaxColumns) {
|
|
||||||
updateGridSetting("columns", value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-6 px-2 py-0 text-xs"
|
|
||||||
style={{ fontSize: "12px" }}
|
|
||||||
placeholder={`1~${safeMaxColumns}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-[10px]">
|
|
||||||
최대 {safeMaxColumns}개까지 설정 가능 (최소 컬럼 너비 {MIN_COLUMN_WIDTH}px)
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 간격 */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="gap" className="text-xs font-medium">
|
|
||||||
간격: <span className="text-primary">{gridSettings.gap}px</span>
|
|
||||||
</Label>
|
|
||||||
<Slider
|
|
||||||
id="gap"
|
|
||||||
min={0}
|
|
||||||
max={40}
|
|
||||||
step={2}
|
|
||||||
value={[gridSettings.gap]}
|
|
||||||
onValueChange={([value]) => updateGridSetting("gap", value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 여백 */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="padding" className="text-xs font-medium">
|
|
||||||
여백: <span className="text-primary">{gridSettings.padding}px</span>
|
|
||||||
</Label>
|
|
||||||
<Slider
|
|
||||||
id="padding"
|
|
||||||
min={0}
|
|
||||||
max={60}
|
|
||||||
step={4}
|
|
||||||
value={[gridSettings.padding]}
|
|
||||||
onValueChange={([value]) => updateGridSetting("padding", value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -382,22 +336,26 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
type="number"
|
type="number"
|
||||||
value={localHeight}
|
value={localHeight}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
// 입력 중에는 로컬 상태만 업데이트 (격자 스냅 방지)
|
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
|
||||||
setLocalHeight(e.target.value);
|
setLocalHeight(e.target.value);
|
||||||
}}
|
}}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
// 포커스를 잃을 때만 실제로 업데이트
|
// 포커스를 잃을 때 10px 단위로 스냅
|
||||||
const value = parseInt(e.target.value) || 0;
|
const value = parseInt(e.target.value) || 0;
|
||||||
if (value >= 1) {
|
if (value >= 10) {
|
||||||
handleUpdate("size.height", value);
|
const snappedValue = Math.round(value / 10) * 10;
|
||||||
|
handleUpdate("size.height", snappedValue);
|
||||||
|
setLocalHeight(String(snappedValue));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Enter 키를 누르면 즉시 적용
|
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
const value = parseInt(e.currentTarget.value) || 0;
|
const value = parseInt(e.currentTarget.value) || 0;
|
||||||
if (value >= 1) {
|
if (value >= 10) {
|
||||||
handleUpdate("size.height", value);
|
const snappedValue = Math.round(value / 10) * 10;
|
||||||
|
handleUpdate("size.height", snappedValue);
|
||||||
|
setLocalHeight(String(snappedValue));
|
||||||
}
|
}
|
||||||
e.currentTarget.blur(); // 포커스 제거
|
e.currentTarget.blur(); // 포커스 제거
|
||||||
}
|
}
|
||||||
|
|
@ -455,38 +413,47 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Grid Columns + Z-Index (같은 행) */}
|
{/* Width + Z-Index (같은 행) */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{(selectedComponent as any).gridColumns !== undefined && (
|
<div className="space-y-1">
|
||||||
<div className="space-y-1">
|
<Label className="text-xs">너비 (px)</Label>
|
||||||
<Label className="text-xs">차지 컬럼 수</Label>
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<Input
|
||||||
<Input
|
type="number"
|
||||||
type="number"
|
min={10}
|
||||||
min={1}
|
max={3840}
|
||||||
max={gridSettings?.columns || 12}
|
step="1"
|
||||||
step="1"
|
value={localWidth}
|
||||||
value={(selectedComponent as any).gridColumns || 1}
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
|
||||||
const value = parseInt(e.target.value, 10);
|
setLocalWidth(e.target.value);
|
||||||
const maxColumns = gridSettings?.columns || 12;
|
}}
|
||||||
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
|
onBlur={(e) => {
|
||||||
handleUpdate("gridColumns", value);
|
// 포커스를 잃을 때 10px 단위로 스냅
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
// width를 퍼센트로 계산하여 업데이트
|
if (!isNaN(value) && value >= 10) {
|
||||||
const widthPercent = (value / maxColumns) * 100;
|
const snappedValue = Math.round(value / 10) * 10;
|
||||||
handleUpdate("style.width", `${widthPercent}%`);
|
handleUpdate("size.width", snappedValue);
|
||||||
|
setLocalWidth(String(snappedValue));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const value = parseInt(e.currentTarget.value, 10);
|
||||||
|
if (!isNaN(value) && value >= 10) {
|
||||||
|
const snappedValue = Math.round(value / 10) * 10;
|
||||||
|
handleUpdate("size.width", snappedValue);
|
||||||
|
setLocalWidth(String(snappedValue));
|
||||||
}
|
}
|
||||||
}}
|
e.currentTarget.blur(); // 포커스 제거
|
||||||
className="h-6 w-full px-2 py-0 text-xs"
|
}
|
||||||
style={{ fontSize: "12px" }}
|
}}
|
||||||
/>
|
className="h-6 w-full px-2 py-0 text-xs"
|
||||||
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
|
style={{ fontSize: "12px" }}
|
||||||
/{gridSettings?.columns || 12}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Z-Index</Label>
|
<Label className="text-xs">Z-Index</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,17 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||||
required,
|
required,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
isDesignMode = false, // 디자인 모드 플래그
|
||||||
|
...restProps
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = () => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
// 디자인 모드에서는 아무것도 하지 않고 그냥 이벤트 전파
|
||||||
|
if (isDesignMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
|
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
|
||||||
// console.log("Button clicked:", config);
|
console.log("Button clicked:", config);
|
||||||
|
|
||||||
// onChange를 통해 클릭 이벤트 전달
|
// onChange를 통해 클릭 이벤트 전달
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
|
|
@ -25,6 +32,25 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단
|
||||||
|
if (isDesignMode) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파
|
||||||
|
className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white ${className || ""} `}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
cursor: "pointer", // 선택 가능하도록 포인터 표시
|
||||||
|
}}
|
||||||
|
title={config?.tooltip || placeholder}
|
||||||
|
>
|
||||||
|
{config?.label || config?.text || value || placeholder || "버튼"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -528,48 +528,64 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 공통 버튼 스타일
|
||||||
|
const buttonElementStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: "40px",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
background: componentConfig.disabled ? "#e5e7eb" : buttonColor,
|
||||||
|
color: componentConfig.disabled ? "#9ca3af" : "white",
|
||||||
|
// 🔧 크기 설정 적용 (sm/md/lg)
|
||||||
|
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
||||||
|
fontWeight: "600",
|
||||||
|
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
// 🔧 크기에 따른 패딩 조정
|
||||||
|
padding:
|
||||||
|
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||||
|
margin: "0",
|
||||||
|
lineHeight: "1.25",
|
||||||
|
boxShadow: componentConfig.disabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||||
|
// isInteractive 모드에서는 사용자 스타일 우선 적용 (width/height 제외)
|
||||||
|
...(isInteractive && component.style ? Object.fromEntries(
|
||||||
|
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
||||||
|
) : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||||
<button
|
{isDesignMode ? (
|
||||||
type={componentConfig.actionType || "button"}
|
// 디자인 모드: div로 렌더링하여 선택 가능하게 함
|
||||||
disabled={componentConfig.disabled || false}
|
<div
|
||||||
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
className="transition-colors duration-150 hover:opacity-90"
|
||||||
style={{
|
style={buttonElementStyle}
|
||||||
width: "100%",
|
onClick={handleClick}
|
||||||
height: "100%",
|
>
|
||||||
minHeight: "40px",
|
{buttonContent}
|
||||||
border: "none",
|
</div>
|
||||||
borderRadius: "0.5rem",
|
) : (
|
||||||
background: componentConfig.disabled ? "#e5e7eb" : buttonColor,
|
// 일반 모드: button으로 렌더링
|
||||||
color: componentConfig.disabled ? "#9ca3af" : "white",
|
<button
|
||||||
// 🔧 크기 설정 적용 (sm/md/lg)
|
type={componentConfig.actionType || "button"}
|
||||||
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
disabled={componentConfig.disabled || false}
|
||||||
fontWeight: "600",
|
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
||||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
style={buttonElementStyle}
|
||||||
outline: "none",
|
onClick={handleClick}
|
||||||
boxSizing: "border-box",
|
onDragStart={onDragStart}
|
||||||
display: "flex",
|
onDragEnd={onDragEnd}
|
||||||
alignItems: "center",
|
>
|
||||||
justifyContent: "center",
|
{buttonContent}
|
||||||
// 🔧 크기에 따른 패딩 조정
|
</button>
|
||||||
padding:
|
)}
|
||||||
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
|
||||||
margin: "0",
|
|
||||||
lineHeight: "1.25",
|
|
||||||
boxShadow: componentConfig.disabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용 (width/height 제외)
|
|
||||||
...(isInteractive && component.style ? Object.fromEntries(
|
|
||||||
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
|
||||||
) : {}),
|
|
||||||
}}
|
|
||||||
onClick={handleClick}
|
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
>
|
|
||||||
{/* 🔧 빈 문자열도 허용 (undefined일 때만 기본값 적용) */}
|
|
||||||
{processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
||||||
|
|
|
||||||
|
|
@ -323,16 +323,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return reordered;
|
return reordered;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📊 초기 화면 표시 데이터 전달:", { count: initialData.length, firstRow: initialData[0] });
|
|
||||||
|
|
||||||
// 전역 저장소에 데이터 저장
|
// 전역 저장소에 데이터 저장
|
||||||
if (tableConfig.selectedTable) {
|
if (tableConfig.selectedTable) {
|
||||||
|
// 컬럼 라벨 매핑 생성
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
visibleColumns.forEach((col) => {
|
||||||
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
||||||
|
});
|
||||||
|
|
||||||
tableDisplayStore.setTableData(
|
tableDisplayStore.setTableData(
|
||||||
tableConfig.selectedTable,
|
tableConfig.selectedTable,
|
||||||
initialData,
|
initialData,
|
||||||
parsedOrder.filter((col) => col !== "__checkbox__"),
|
parsedOrder.filter((col) => col !== "__checkbox__"),
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
{
|
||||||
|
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
|
||||||
|
searchTerm: searchTerm || undefined,
|
||||||
|
visibleColumns: visibleColumns.map((col) => col.columnName),
|
||||||
|
columnLabels: labels,
|
||||||
|
currentPage: currentPage,
|
||||||
|
pageSize: localPageSize,
|
||||||
|
totalItems: totalItems,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -624,33 +637,44 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const hasEntityJoins = entityJoinColumns.length > 0;
|
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||||
|
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||||
let response;
|
page,
|
||||||
if (hasEntityJoins) {
|
size: pageSize,
|
||||||
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
sortBy,
|
||||||
page,
|
sortOrder,
|
||||||
size: pageSize,
|
search: filters,
|
||||||
sortBy,
|
enableEntityJoin: true,
|
||||||
sortOrder,
|
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||||
search: filters,
|
});
|
||||||
enableEntityJoin: true,
|
|
||||||
additionalJoinColumns: entityJoinColumns,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
response = await tableTypeApi.getTableData(tableConfig.selectedTable, {
|
|
||||||
page,
|
|
||||||
size: pageSize,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
search: filters,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(response.data || []);
|
setData(response.data || []);
|
||||||
setTotalPages(response.totalPages || 0);
|
setTotalPages(response.totalPages || 0);
|
||||||
setTotalItems(response.total || 0);
|
setTotalItems(response.total || 0);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// 🎯 Store에 필터 조건 저장 (엑셀 다운로드용)
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
visibleColumns.forEach((col) => {
|
||||||
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
||||||
|
});
|
||||||
|
|
||||||
|
tableDisplayStore.setTableData(
|
||||||
|
tableConfig.selectedTable,
|
||||||
|
response.data || [],
|
||||||
|
visibleColumns.map((col) => col.columnName),
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
{
|
||||||
|
filterConditions: filters,
|
||||||
|
searchTerm: search,
|
||||||
|
visibleColumns: visibleColumns.map((col) => col.columnName),
|
||||||
|
columnLabels: labels,
|
||||||
|
currentPage: page,
|
||||||
|
pageSize: pageSize,
|
||||||
|
totalItems: response.total || 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("데이터 가져오기 실패:", err);
|
console.error("데이터 가져오기 실패:", err);
|
||||||
setData([]);
|
setData([]);
|
||||||
|
|
@ -788,12 +812,28 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const cleanColumnOrder = (
|
const cleanColumnOrder = (
|
||||||
columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName)
|
columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName)
|
||||||
).filter((col) => col !== "__checkbox__");
|
).filter((col) => col !== "__checkbox__");
|
||||||
|
|
||||||
|
// 컬럼 라벨 정보도 함께 저장
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
visibleColumns.forEach((col) => {
|
||||||
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
||||||
|
});
|
||||||
|
|
||||||
tableDisplayStore.setTableData(
|
tableDisplayStore.setTableData(
|
||||||
tableConfig.selectedTable,
|
tableConfig.selectedTable,
|
||||||
reorderedData,
|
reorderedData,
|
||||||
cleanColumnOrder,
|
cleanColumnOrder,
|
||||||
newSortColumn,
|
newSortColumn,
|
||||||
newSortDirection,
|
newSortDirection,
|
||||||
|
{
|
||||||
|
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
|
||||||
|
searchTerm: searchTerm || undefined,
|
||||||
|
visibleColumns: visibleColumns.map((col) => col.columnName),
|
||||||
|
columnLabels: labels,
|
||||||
|
currentPage: currentPage,
|
||||||
|
pageSize: localPageSize,
|
||||||
|
totalItems: totalItems,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1062,6 +1102,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
|
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
|
||||||
if (value === null || value === undefined) return "-";
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
|
||||||
|
if (column.columnName === "writer" && rowData && rowData.writer_name) {
|
||||||
|
return rowData.writer_name;
|
||||||
|
}
|
||||||
|
|
||||||
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
|
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
|
||||||
if (column.entityDisplayConfig && rowData) {
|
if (column.entityDisplayConfig && rowData) {
|
||||||
// displayColumns 또는 selectedColumns 둘 다 체크
|
// displayColumns 또는 selectedColumns 둘 다 체크
|
||||||
|
|
@ -1155,6 +1200,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 날짜 타입 포맷팅 (yyyy-mm-dd)
|
||||||
|
if (inputType === "date" || inputType === "datetime") {
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
const date = new Date(value);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
// 숫자 타입 포맷팅
|
// 숫자 타입 포맷팅
|
||||||
if (inputType === "number" || inputType === "decimal") {
|
if (inputType === "number" || inputType === "decimal") {
|
||||||
if (value !== null && value !== undefined && value !== "") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
|
@ -1179,7 +1240,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (value) {
|
if (value) {
|
||||||
try {
|
try {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return date.toLocaleDateString("ko-KR");
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
@ -2144,7 +2208,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12",
|
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleRowClick(row, index, e)}
|
onClick={(e) => handleRowClick(row, index, e)}
|
||||||
>
|
>
|
||||||
|
|
@ -2173,8 +2237,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<td
|
<td
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
"text-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap font-normal sm:text-sm",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
||||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -2210,7 +2274,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12",
|
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleRowClick(row, index, e)}
|
onClick={(e) => handleRowClick(row, index, e)}
|
||||||
>
|
>
|
||||||
|
|
@ -2239,8 +2303,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<td
|
<td
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
"text-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap font-normal sm:text-sm",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
||||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,16 @@ export interface ButtonActionContext {
|
||||||
sortOrder?: "asc" | "desc"; // 정렬 방향
|
sortOrder?: "asc" | "desc"; // 정렬 방향
|
||||||
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
|
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
|
||||||
tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨)
|
tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨)
|
||||||
|
|
||||||
|
// 🆕 엑셀 다운로드 개선을 위한 추가 필드
|
||||||
|
filterConditions?: Record<string, any>; // 필터 조건 (예: { status: "active", dept: "dev" })
|
||||||
|
searchTerm?: string; // 검색어
|
||||||
|
searchColumn?: string; // 검색 대상 컬럼
|
||||||
|
visibleColumns?: string[]; // 화면에 표시 중인 컬럼 목록 (순서 포함)
|
||||||
|
columnLabels?: Record<string, string>; // 컬럼명 → 라벨명 매핑 (한글)
|
||||||
|
currentPage?: number; // 현재 페이지
|
||||||
|
pageSize?: number; // 페이지 크기
|
||||||
|
totalItems?: number; // 전체 항목 수
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1936,162 +1946,74 @@ export class ButtonActionExecutor {
|
||||||
*/
|
*/
|
||||||
private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
console.log("📥 엑셀 다운로드 시작:", { config, context });
|
|
||||||
console.log("🔍 context.columnOrder 확인:", {
|
|
||||||
hasColumnOrder: !!context.columnOrder,
|
|
||||||
columnOrderLength: context.columnOrder?.length,
|
|
||||||
columnOrder: context.columnOrder,
|
|
||||||
});
|
|
||||||
console.log("🔍 context.tableDisplayData 확인:", {
|
|
||||||
hasTableDisplayData: !!context.tableDisplayData,
|
|
||||||
tableDisplayDataLength: context.tableDisplayData?.length,
|
|
||||||
tableDisplayDataFirstRow: context.tableDisplayData?.[0],
|
|
||||||
tableDisplayDataColumns: context.tableDisplayData?.[0] ? Object.keys(context.tableDisplayData[0]) : [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 동적 import로 엑셀 유틸리티 로드
|
// 동적 import로 엑셀 유틸리티 로드
|
||||||
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||||
|
|
||||||
let dataToExport: any[] = [];
|
let dataToExport: any[] = [];
|
||||||
|
|
||||||
// 1순위: 선택된 행 데이터
|
// ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기
|
||||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
if (context.tableName) {
|
||||||
dataToExport = context.selectedRowsData;
|
|
||||||
console.log("✅ 선택된 행 데이터 사용:", dataToExport.length);
|
|
||||||
|
|
||||||
// 선택된 행도 정렬 적용
|
|
||||||
if (context.sortBy) {
|
|
||||||
console.log("🔄 선택된 행 데이터 정렬 적용:", {
|
|
||||||
sortBy: context.sortBy,
|
|
||||||
sortOrder: context.sortOrder,
|
|
||||||
});
|
|
||||||
|
|
||||||
dataToExport = [...dataToExport].sort((a, b) => {
|
|
||||||
const aVal = a[context.sortBy!];
|
|
||||||
const bVal = b[context.sortBy!];
|
|
||||||
|
|
||||||
// null/undefined 처리
|
|
||||||
if (aVal == null && bVal == null) return 0;
|
|
||||||
if (aVal == null) return 1;
|
|
||||||
if (bVal == null) return -1;
|
|
||||||
|
|
||||||
// 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교)
|
|
||||||
const aNum = Number(aVal);
|
|
||||||
const bNum = Number(bVal);
|
|
||||||
|
|
||||||
// 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우
|
|
||||||
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") {
|
|
||||||
return context.sortOrder === "desc" ? bNum - aNum : aNum - bNum;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬)
|
|
||||||
const aStr = String(aVal).toLowerCase();
|
|
||||||
const bStr = String(bVal).toLowerCase();
|
|
||||||
|
|
||||||
// 자연스러운 정렬 (숫자 포함 문자열)
|
|
||||||
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: 'base' });
|
|
||||||
return context.sortOrder === "desc" ? -comparison : comparison;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("✅ 정렬 완료:", {
|
|
||||||
firstRow: dataToExport[0],
|
|
||||||
lastRow: dataToExport[dataToExport.length - 1],
|
|
||||||
firstSortValue: dataToExport[0]?.[context.sortBy],
|
|
||||||
lastSortValue: dataToExport[dataToExport.length - 1]?.[context.sortBy],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2순위: 화면 표시 데이터 (컬럼 순서 포함, 정렬 적용됨)
|
|
||||||
else if (context.tableDisplayData && context.tableDisplayData.length > 0) {
|
|
||||||
dataToExport = context.tableDisplayData;
|
|
||||||
console.log("✅ 화면 표시 데이터 사용 (context):", {
|
|
||||||
count: dataToExport.length,
|
|
||||||
firstRow: dataToExport[0],
|
|
||||||
columns: Object.keys(dataToExport[0] || {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 2.5순위: 전역 저장소에서 화면 표시 데이터 조회
|
|
||||||
else if (context.tableName) {
|
|
||||||
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
||||||
const storedData = tableDisplayStore.getTableData(context.tableName);
|
const storedData = tableDisplayStore.getTableData(context.tableName);
|
||||||
|
|
||||||
if (storedData && storedData.data.length > 0) {
|
// 필터 조건은 저장소 또는 context에서 가져오기
|
||||||
dataToExport = storedData.data;
|
const filterConditions = storedData?.filterConditions || context.filterConditions;
|
||||||
console.log("✅ 화면 표시 데이터 사용 (전역 저장소):", {
|
const searchTerm = storedData?.searchTerm || context.searchTerm;
|
||||||
tableName: context.tableName,
|
|
||||||
count: dataToExport.length,
|
|
||||||
firstRow: dataToExport[0],
|
|
||||||
lastRow: dataToExport[dataToExport.length - 1],
|
|
||||||
columns: Object.keys(dataToExport[0] || {}),
|
|
||||||
columnOrder: storedData.columnOrder,
|
|
||||||
sortBy: storedData.sortBy,
|
|
||||||
sortOrder: storedData.sortOrder,
|
|
||||||
// 정렬 컬럼의 첫/마지막 값 확인
|
|
||||||
firstSortValue: storedData.sortBy ? dataToExport[0]?.[storedData.sortBy] : undefined,
|
|
||||||
lastSortValue: storedData.sortBy ? dataToExport[dataToExport.length - 1]?.[storedData.sortBy] : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 3순위: 테이블 전체 데이터 (API 호출)
|
|
||||||
else {
|
|
||||||
console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName);
|
|
||||||
console.log("📊 정렬 정보:", {
|
|
||||||
sortBy: context.sortBy,
|
|
||||||
sortOrder: context.sortOrder,
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const { dynamicFormApi } = await import("@/lib/api/dynamicForm");
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
const response = await dynamicFormApi.getTableData(context.tableName, {
|
|
||||||
|
const apiParams = {
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10000, // 최대 10,000개 행
|
size: 10000, // 최대 10,000개
|
||||||
sortBy: context.sortBy || "id", // 화면 정렬 또는 기본 정렬
|
sortBy: context.sortBy || storedData?.sortBy || "id",
|
||||||
sortOrder: context.sortOrder || "asc", // 화면 정렬 방향 또는 오름차순
|
sortOrder: (context.sortOrder || storedData?.sortOrder || "asc") as "asc" | "desc",
|
||||||
});
|
search: filterConditions, // ✅ 필터 조건
|
||||||
|
enableEntityJoin: true, // ✅ Entity 조인
|
||||||
|
autoFilter: true, // ✅ company_code 자동 필터링 (멀티테넌시)
|
||||||
|
};
|
||||||
|
|
||||||
console.log("📦 API 응답 구조:", {
|
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
|
||||||
response,
|
const response = await entityJoinApi.getTableDataWithJoins(context.tableName, apiParams);
|
||||||
responseSuccess: response.success,
|
|
||||||
responseData: response.data,
|
// 🔒 멀티테넌시 확인
|
||||||
responseDataType: typeof response.data,
|
const allData = Array.isArray(response) ? response : response?.data || [];
|
||||||
responseDataIsArray: Array.isArray(response.data),
|
const companyCodesInData = [...new Set(allData.map((row: any) => row.company_code))];
|
||||||
responseDataLength: Array.isArray(response.data) ? response.data.length : "N/A",
|
|
||||||
});
|
if (companyCodesInData.length > 1) {
|
||||||
|
console.error("❌ 멀티테넌시 위반! 여러 회사의 데이터가 섞여있습니다:", companyCodesInData);
|
||||||
if (response.success && response.data) {
|
}
|
||||||
|
|
||||||
|
// entityJoinApi는 EntityJoinResponse 또는 data 배열을 반환
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
// 배열로 직접 반환된 경우
|
||||||
|
dataToExport = response;
|
||||||
|
} else if (response && 'data' in response) {
|
||||||
|
// EntityJoinResponse 객체인 경우
|
||||||
dataToExport = response.data;
|
dataToExport = response.data;
|
||||||
console.log("✅ 테이블 전체 데이터 조회 완료:", {
|
|
||||||
count: dataToExport.length,
|
|
||||||
firstRow: dataToExport[0],
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ API 응답에 데이터가 없습니다:", response);
|
console.error("❌ 예상치 못한 응답 형식:", response);
|
||||||
|
toast.error("데이터를 가져오는데 실패했습니다.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
console.error("엑셀 다운로드: 데이터 조회 실패:", error);
|
||||||
}
|
toast.error("데이터를 가져오는데 실패했습니다.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 4순위: 폼 데이터
|
// 폴백: 폼 데이터
|
||||||
else if (context.formData && Object.keys(context.formData).length > 0) {
|
else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||||
dataToExport = [context.formData];
|
dataToExport = [context.formData];
|
||||||
console.log("✅ 폼 데이터 사용:", dataToExport);
|
|
||||||
}
|
}
|
||||||
|
// 테이블명도 없고 폼 데이터도 없으면 에러
|
||||||
console.log("📊 최종 다운로드 데이터:", {
|
else {
|
||||||
selectedRowsData: context.selectedRowsData,
|
toast.error("다운로드할 데이터 소스가 없습니다.");
|
||||||
selectedRowsLength: context.selectedRowsData?.length,
|
return false;
|
||||||
formData: context.formData,
|
}
|
||||||
tableName: context.tableName,
|
|
||||||
dataToExport,
|
|
||||||
dataToExportType: typeof dataToExport,
|
|
||||||
dataToExportIsArray: Array.isArray(dataToExport),
|
|
||||||
dataToExportLength: Array.isArray(dataToExport) ? dataToExport.length : "N/A",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 배열이 아니면 배열로 변환
|
// 배열이 아니면 배열로 변환
|
||||||
if (!Array.isArray(dataToExport)) {
|
if (!Array.isArray(dataToExport)) {
|
||||||
console.warn("⚠️ dataToExport가 배열이 아닙니다. 변환 시도:", dataToExport);
|
|
||||||
|
|
||||||
// 객체인 경우 배열로 감싸기
|
|
||||||
if (typeof dataToExport === "object" && dataToExport !== null) {
|
if (typeof dataToExport === "object" && dataToExport !== null) {
|
||||||
dataToExport = [dataToExport];
|
dataToExport = [dataToExport];
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2110,66 +2032,196 @@ export class ButtonActionExecutor {
|
||||||
const sheetName = config.excelSheetName || "Sheet1";
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||||
|
|
||||||
// 🆕 컬럼 순서 재정렬 (화면에 표시된 순서대로)
|
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
||||||
let columnOrder: string[] | undefined = context.columnOrder;
|
let visibleColumns: string[] | undefined = undefined;
|
||||||
|
let columnLabels: Record<string, string> | undefined = undefined;
|
||||||
// columnOrder가 없으면 tableDisplayData에서 추출 시도
|
|
||||||
if (!columnOrder && context.tableDisplayData && context.tableDisplayData.length > 0) {
|
try {
|
||||||
columnOrder = Object.keys(context.tableDisplayData[0]);
|
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
||||||
console.log("📊 tableDisplayData에서 컬럼 순서 추출:", columnOrder);
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
}
|
const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`);
|
||||||
|
|
||||||
if (columnOrder && columnOrder.length > 0 && dataToExport.length > 0) {
|
if (layoutResponse.data?.success && layoutResponse.data?.data) {
|
||||||
console.log("🔄 컬럼 순서 재정렬 시작:", {
|
let layoutData = layoutResponse.data.data;
|
||||||
columnOrder,
|
|
||||||
originalColumns: Object.keys(dataToExport[0] || {}),
|
// components가 문자열이면 파싱
|
||||||
});
|
if (typeof layoutData.components === 'string') {
|
||||||
|
layoutData.components = JSON.parse(layoutData.components);
|
||||||
dataToExport = dataToExport.map((row: any) => {
|
}
|
||||||
const reorderedRow: any = {};
|
|
||||||
|
// 테이블 리스트 컴포넌트 찾기
|
||||||
// 1. columnOrder에 있는 컬럼들을 순서대로 추가
|
const findTableListComponent = (components: any[]): any => {
|
||||||
columnOrder!.forEach((colName: string) => {
|
if (!Array.isArray(components)) return null;
|
||||||
if (colName in row) {
|
|
||||||
reorderedRow[colName] = row[colName];
|
for (const comp of components) {
|
||||||
|
// componentType이 'table-list'인지 확인
|
||||||
|
const isTableList = comp.componentType === 'table-list';
|
||||||
|
|
||||||
|
// componentConfig 안에서 테이블명 확인
|
||||||
|
const matchesTable =
|
||||||
|
comp.componentConfig?.selectedTable === context.tableName ||
|
||||||
|
comp.componentConfig?.tableName === context.tableName;
|
||||||
|
|
||||||
|
if (isTableList && matchesTable) {
|
||||||
|
return comp;
|
||||||
|
}
|
||||||
|
if (comp.children && comp.children.length > 0) {
|
||||||
|
const found = findTableListComponent(comp.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableListComponent = findTableListComponent(layoutData.components || []);
|
||||||
|
|
||||||
|
if (tableListComponent && tableListComponent.componentConfig?.columns) {
|
||||||
|
const columns = tableListComponent.componentConfig.columns;
|
||||||
|
|
||||||
|
// visible이 true인 컬럼만 추출
|
||||||
|
visibleColumns = columns
|
||||||
|
.filter((col: any) => col.visible !== false)
|
||||||
|
.map((col: any) => col.columnName);
|
||||||
|
|
||||||
|
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
|
||||||
|
try {
|
||||||
|
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
|
||||||
|
params: { page: 1, size: 9999 }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||||
|
let columnData = columnsResponse.data.data;
|
||||||
|
|
||||||
|
// data가 객체이고 columns 필드가 있으면 추출
|
||||||
|
if (columnData.columns && Array.isArray(columnData.columns)) {
|
||||||
|
columnData = columnData.columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(columnData)) {
|
||||||
|
columnLabels = {};
|
||||||
|
|
||||||
|
// API에서 가져온 라벨로 매핑
|
||||||
|
columnData.forEach((colData: any) => {
|
||||||
|
const colName = colData.column_name || colData.columnName;
|
||||||
|
// 우선순위: column_label > label > displayName > columnName
|
||||||
|
const labelValue = colData.column_label || colData.label || colData.displayName || colName;
|
||||||
|
if (colName && labelValue) {
|
||||||
|
columnLabels![colName] = labelValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 실패 시 컴포넌트 설정의 displayName 사용
|
||||||
|
columnLabels = {};
|
||||||
|
columns.forEach((col: any) => {
|
||||||
|
if (col.columnName) {
|
||||||
|
columnLabels![col.columnName] = col.displayName || col.label || col.columnName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 화면 레이아웃 조회 실패:", error);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
|
// 🎨 카테고리 값들 조회 (한 번만)
|
||||||
|
const categoryMap: Record<string, Record<string, string>> = {};
|
||||||
|
let categoryColumns: string[] = [];
|
||||||
|
|
||||||
|
// 백엔드에서 카테고리 컬럼 정보 가져오기
|
||||||
|
if (context.tableName) {
|
||||||
|
try {
|
||||||
|
const { getCategoryColumns, getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||||
|
|
||||||
// 2. columnOrder에 없는 나머지 컬럼들 추가 (끝에 배치)
|
const categoryColumnsResponse = await getCategoryColumns(context.tableName);
|
||||||
Object.keys(row).forEach((key) => {
|
|
||||||
if (!(key in reorderedRow)) {
|
if (categoryColumnsResponse.success && categoryColumnsResponse.data) {
|
||||||
reorderedRow[key] = row[key];
|
// 백엔드에서 정의된 카테고리 컬럼들
|
||||||
|
categoryColumns = categoryColumnsResponse.data.map((col: any) =>
|
||||||
|
col.column_name || col.columnName || col.name
|
||||||
|
).filter(Boolean); // undefined 제거
|
||||||
|
|
||||||
|
// 각 카테고리 컬럼의 값들 조회
|
||||||
|
for (const columnName of categoryColumns) {
|
||||||
|
try {
|
||||||
|
const valuesResponse = await getCategoryValues(context.tableName, columnName, false);
|
||||||
|
|
||||||
|
if (valuesResponse.success && valuesResponse.data) {
|
||||||
|
// valueCode → valueLabel 매핑
|
||||||
|
categoryMap[columnName] = {};
|
||||||
|
valuesResponse.data.forEach((catValue: any) => {
|
||||||
|
const code = catValue.valueCode || catValue.category_value_id;
|
||||||
|
const label = catValue.valueLabel || catValue.label || code;
|
||||||
|
if (code) {
|
||||||
|
categoryMap[columnName][code] = label;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 카테고리 "${columnName}" 조회 실패:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
} catch (error) {
|
||||||
return reorderedRow;
|
console.error("❌ 카테고리 정보 조회 실패:", error);
|
||||||
});
|
}
|
||||||
|
|
||||||
console.log("✅ 컬럼 순서 재정렬 완료:", {
|
|
||||||
reorderedColumns: Object.keys(dataToExport[0] || {}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log("⏭️ 컬럼 순서 재정렬 스킵:", {
|
|
||||||
hasColumnOrder: !!columnOrder,
|
|
||||||
columnOrderLength: columnOrder?.length,
|
|
||||||
hasTableDisplayData: !!context.tableDisplayData,
|
|
||||||
dataToExportLength: dataToExport.length,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📥 엑셀 다운로드 실행:", {
|
// 🎨 컬럼 필터링 및 라벨 적용 (항상 실행)
|
||||||
fileName,
|
if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
|
||||||
sheetName,
|
dataToExport = dataToExport.map((row: any) => {
|
||||||
includeHeaders,
|
const filteredRow: Record<string, any> = {};
|
||||||
dataCount: dataToExport.length,
|
|
||||||
firstRow: dataToExport[0],
|
visibleColumns.forEach((columnName: string) => {
|
||||||
columnOrder: context.columnOrder,
|
// __checkbox__ 컬럼은 제외
|
||||||
});
|
if (columnName === "__checkbox__") return;
|
||||||
|
|
||||||
|
if (columnName in row) {
|
||||||
|
// 라벨 우선 사용, 없으면 컬럼명 사용
|
||||||
|
const label = columnLabels?.[columnName] || columnName;
|
||||||
|
|
||||||
|
// 🎯 Entity 조인된 값 우선 사용
|
||||||
|
let value = row[columnName];
|
||||||
|
|
||||||
|
// writer → writer_name 사용
|
||||||
|
if (columnName === 'writer' && row['writer_name']) {
|
||||||
|
value = row['writer_name'];
|
||||||
|
}
|
||||||
|
// 다른 엔티티 필드들도 _name 우선 사용
|
||||||
|
else if (row[`${columnName}_name`]) {
|
||||||
|
value = row[`${columnName}_name`];
|
||||||
|
}
|
||||||
|
// 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만)
|
||||||
|
else if (categoryMap[columnName] && typeof value === 'string' && categoryMap[columnName][value]) {
|
||||||
|
value = categoryMap[columnName][value];
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredRow[label] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최대 행 수 제한
|
||||||
|
const MAX_ROWS = 10000;
|
||||||
|
if (dataToExport.length > MAX_ROWS) {
|
||||||
|
toast.warning(`최대 ${MAX_ROWS.toLocaleString()}개 행까지만 다운로드됩니다.`);
|
||||||
|
dataToExport = dataToExport.slice(0, MAX_ROWS);
|
||||||
|
}
|
||||||
|
|
||||||
// 엑셀 다운로드 실행
|
// 엑셀 다운로드 실행
|
||||||
await exportToExcel(dataToExport, fileName, sheetName, includeHeaders);
|
await exportToExcel(dataToExport, fileName, sheetName, includeHeaders);
|
||||||
|
|
||||||
toast.success(config.successMessage || "엑셀 파일이 다운로드되었습니다.");
|
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 엑셀 다운로드 실패:", error);
|
console.error("❌ 엑셀 다운로드 실패:", error);
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,9 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri
|
||||||
const rowHeight = 10;
|
const rowHeight = 10;
|
||||||
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
|
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
|
||||||
|
|
||||||
console.log(
|
// console.log(
|
||||||
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
// `📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||||
);
|
// );
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: Math.max(columnWidth, snappedWidth),
|
width: Math.max(columnWidth, snappedWidth),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,15 @@ interface TableDisplayState {
|
||||||
sortBy: string | null;
|
sortBy: string | null;
|
||||||
sortOrder: "asc" | "desc";
|
sortOrder: "asc" | "desc";
|
||||||
tableName: string;
|
tableName: string;
|
||||||
|
|
||||||
|
// 🆕 엑셀 다운로드 개선을 위한 추가 필드
|
||||||
|
filterConditions?: Record<string, any>; // 필터 조건
|
||||||
|
searchTerm?: string; // 검색어
|
||||||
|
visibleColumns?: string[]; // 화면 표시 컬럼
|
||||||
|
columnLabels?: Record<string, string>; // 컬럼 라벨
|
||||||
|
currentPage?: number; // 현재 페이지
|
||||||
|
pageSize?: number; // 페이지 크기
|
||||||
|
totalItems?: number; // 전체 항목 수
|
||||||
}
|
}
|
||||||
|
|
||||||
class TableDisplayStore {
|
class TableDisplayStore {
|
||||||
|
|
@ -22,13 +31,23 @@ class TableDisplayStore {
|
||||||
* @param columnOrder 컬럼 순서
|
* @param columnOrder 컬럼 순서
|
||||||
* @param sortBy 정렬 컬럼
|
* @param sortBy 정렬 컬럼
|
||||||
* @param sortOrder 정렬 방향
|
* @param sortOrder 정렬 방향
|
||||||
|
* @param options 추가 옵션 (필터, 페이징 등)
|
||||||
*/
|
*/
|
||||||
setTableData(
|
setTableData(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
data: any[],
|
data: any[],
|
||||||
columnOrder: string[],
|
columnOrder: string[],
|
||||||
sortBy: string | null,
|
sortBy: string | null,
|
||||||
sortOrder: "asc" | "desc"
|
sortOrder: "asc" | "desc",
|
||||||
|
options?: {
|
||||||
|
filterConditions?: Record<string, any>;
|
||||||
|
searchTerm?: string;
|
||||||
|
visibleColumns?: string[];
|
||||||
|
columnLabels?: Record<string, string>;
|
||||||
|
currentPage?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
totalItems?: number;
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
this.state.set(tableName, {
|
this.state.set(tableName, {
|
||||||
data,
|
data,
|
||||||
|
|
@ -36,15 +55,7 @@ class TableDisplayStore {
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
tableName,
|
tableName,
|
||||||
});
|
...options,
|
||||||
|
|
||||||
console.log("📦 [TableDisplayStore] 데이터 저장:", {
|
|
||||||
tableName,
|
|
||||||
dataCount: data.length,
|
|
||||||
columnOrderLength: columnOrder.length,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
firstRow: data[0],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
|
|
@ -55,15 +66,7 @@ class TableDisplayStore {
|
||||||
* @param tableName 테이블명
|
* @param tableName 테이블명
|
||||||
*/
|
*/
|
||||||
getTableData(tableName: string): TableDisplayState | undefined {
|
getTableData(tableName: string): TableDisplayState | undefined {
|
||||||
const state = this.state.get(tableName);
|
return this.state.get(tableName);
|
||||||
|
|
||||||
console.log("📤 [TableDisplayStore] 데이터 조회:", {
|
|
||||||
tableName,
|
|
||||||
found: !!state,
|
|
||||||
dataCount: state?.data.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue