feat: 엑셀 다운로드 기능 개선
- 화면 편집기 컬럼 설정 기반 다운로드 (visible 컬럼만) - 필터 조건 적용 (필터링된 데이터만 다운로드) - 한글 라벨명 표시 (column_labels 테이블 조회) - Entity 조인 값 표시 (writer → writer_name 등) - 카테고리 타입 라벨 변환 (코드 → 라벨) - 멀티테넌시 보안 강화 (autoFilter: true) - 디버깅 로그 정리 변경된 파일: - frontend/lib/utils/buttonActions.ts - frontend/lib/registry/components/table-list/TableListComponent.tsx 관련 이슈: #엑셀다운로드개선
This commit is contained in:
656
docs/엑셀_다운로드_개선_계획.md
Normal file
656
docs/엑셀_다운로드_개선_계획.md
Normal file
@@ -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
|
||||
**다음 업데이트**: 구현 완료 후
|
||||
275
docs/엑셀_다운로드_개선_계획_v2.md
Normal file
275
docs/엑셀_다운로드_개선_계획_v2.md
Normal file
@@ -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
|
||||
**다음 업데이트**: 구현 완료 후
|
||||
|
||||
@@ -323,16 +323,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
return reordered;
|
||||
});
|
||||
|
||||
console.log("📊 초기 화면 표시 데이터 전달:", { count: initialData.length, firstRow: initialData[0] });
|
||||
|
||||
// 전역 저장소에 데이터 저장
|
||||
if (tableConfig.selectedTable) {
|
||||
// 컬럼 라벨 매핑 생성
|
||||
const labels: Record<string, string> = {};
|
||||
visibleColumns.forEach((col) => {
|
||||
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
||||
});
|
||||
|
||||
tableDisplayStore.setTableData(
|
||||
tableConfig.selectedTable,
|
||||
initialData,
|
||||
parsedOrder.filter((col) => col !== "__checkbox__"),
|
||||
sortColumn,
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -639,6 +652,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
setTotalPages(response.totalPages || 0);
|
||||
setTotalItems(response.total || 0);
|
||||
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) {
|
||||
console.error("데이터 가져오기 실패:", err);
|
||||
setData([]);
|
||||
@@ -776,12 +812,28 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
const cleanColumnOrder = (
|
||||
columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName)
|
||||
).filter((col) => col !== "__checkbox__");
|
||||
|
||||
// 컬럼 라벨 정보도 함께 저장
|
||||
const labels: Record<string, string> = {};
|
||||
visibleColumns.forEach((col) => {
|
||||
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
||||
});
|
||||
|
||||
tableDisplayStore.setTableData(
|
||||
tableConfig.selectedTable,
|
||||
reorderedData,
|
||||
cleanColumnOrder,
|
||||
newSortColumn,
|
||||
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 {
|
||||
|
||||
@@ -113,6 +113,16 @@ export interface ButtonActionContext {
|
||||
sortOrder?: "asc" | "desc"; // 정렬 방향
|
||||
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
|
||||
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> {
|
||||
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로 엑셀 유틸리티 로드
|
||||
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||
|
||||
let dataToExport: any[] = [];
|
||||
|
||||
// 1순위: 선택된 행 데이터
|
||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
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) {
|
||||
// ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기
|
||||
if (context.tableName) {
|
||||
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
||||
const storedData = tableDisplayStore.getTableData(context.tableName);
|
||||
|
||||
if (storedData && storedData.data.length > 0) {
|
||||
dataToExport = storedData.data;
|
||||
console.log("✅ 화면 표시 데이터 사용 (전역 저장소):", {
|
||||
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,
|
||||
});
|
||||
// 필터 조건은 저장소 또는 context에서 가져오기
|
||||
const filterConditions = storedData?.filterConditions || context.filterConditions;
|
||||
const searchTerm = storedData?.searchTerm || context.searchTerm;
|
||||
|
||||
try {
|
||||
const { dynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||
const response = await dynamicFormApi.getTableData(context.tableName, {
|
||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
|
||||
const apiParams = {
|
||||
page: 1,
|
||||
pageSize: 10000, // 최대 10,000개 행
|
||||
sortBy: context.sortBy || "id", // 화면 정렬 또는 기본 정렬
|
||||
sortOrder: context.sortOrder || "asc", // 화면 정렬 방향 또는 오름차순
|
||||
});
|
||||
size: 10000, // 최대 10,000개
|
||||
sortBy: context.sortBy || storedData?.sortBy || "id",
|
||||
sortOrder: (context.sortOrder || storedData?.sortOrder || "asc") as "asc" | "desc",
|
||||
search: filterConditions, // ✅ 필터 조건
|
||||
enableEntityJoin: true, // ✅ Entity 조인
|
||||
autoFilter: true, // ✅ company_code 자동 필터링 (멀티테넌시)
|
||||
};
|
||||
|
||||
console.log("📦 API 응답 구조:", {
|
||||
response,
|
||||
responseSuccess: response.success,
|
||||
responseData: response.data,
|
||||
responseDataType: typeof response.data,
|
||||
responseDataIsArray: Array.isArray(response.data),
|
||||
responseDataLength: Array.isArray(response.data) ? response.data.length : "N/A",
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
|
||||
const response = await entityJoinApi.getTableDataWithJoins(context.tableName, apiParams);
|
||||
|
||||
// 🔒 멀티테넌시 확인
|
||||
const allData = Array.isArray(response) ? response : response?.data || [];
|
||||
const companyCodesInData = [...new Set(allData.map((row: any) => row.company_code))];
|
||||
|
||||
if (companyCodesInData.length > 1) {
|
||||
console.error("❌ 멀티테넌시 위반! 여러 회사의 데이터가 섞여있습니다:", companyCodesInData);
|
||||
}
|
||||
|
||||
// entityJoinApi는 EntityJoinResponse 또는 data 배열을 반환
|
||||
if (Array.isArray(response)) {
|
||||
// 배열로 직접 반환된 경우
|
||||
dataToExport = response;
|
||||
} else if (response && 'data' in response) {
|
||||
// EntityJoinResponse 객체인 경우
|
||||
dataToExport = response.data;
|
||||
console.log("✅ 테이블 전체 데이터 조회 완료:", {
|
||||
count: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
});
|
||||
} else {
|
||||
console.error("❌ API 응답에 데이터가 없습니다:", response);
|
||||
console.error("❌ 예상치 못한 응답 형식:", response);
|
||||
toast.error("데이터를 가져오는데 실패했습니다.");
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||
}
|
||||
console.error("엑셀 다운로드: 데이터 조회 실패:", error);
|
||||
toast.error("데이터를 가져오는데 실패했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 4순위: 폼 데이터
|
||||
// 폴백: 폼 데이터
|
||||
else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
dataToExport = [context.formData];
|
||||
console.log("✅ 폼 데이터 사용:", dataToExport);
|
||||
}
|
||||
|
||||
console.log("📊 최종 다운로드 데이터:", {
|
||||
selectedRowsData: context.selectedRowsData,
|
||||
selectedRowsLength: context.selectedRowsData?.length,
|
||||
formData: context.formData,
|
||||
tableName: context.tableName,
|
||||
dataToExport,
|
||||
dataToExportType: typeof dataToExport,
|
||||
dataToExportIsArray: Array.isArray(dataToExport),
|
||||
dataToExportLength: Array.isArray(dataToExport) ? dataToExport.length : "N/A",
|
||||
});
|
||||
// 테이블명도 없고 폼 데이터도 없으면 에러
|
||||
else {
|
||||
toast.error("다운로드할 데이터 소스가 없습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 배열이 아니면 배열로 변환
|
||||
if (!Array.isArray(dataToExport)) {
|
||||
console.warn("⚠️ dataToExport가 배열이 아닙니다. 변환 시도:", dataToExport);
|
||||
|
||||
// 객체인 경우 배열로 감싸기
|
||||
if (typeof dataToExport === "object" && dataToExport !== null) {
|
||||
dataToExport = [dataToExport];
|
||||
} else {
|
||||
@@ -2110,66 +2032,196 @@ export class ButtonActionExecutor {
|
||||
const sheetName = config.excelSheetName || "Sheet1";
|
||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||
|
||||
// 🆕 컬럼 순서 재정렬 (화면에 표시된 순서대로)
|
||||
let columnOrder: string[] | undefined = context.columnOrder;
|
||||
|
||||
// columnOrder가 없으면 tableDisplayData에서 추출 시도
|
||||
if (!columnOrder && context.tableDisplayData && context.tableDisplayData.length > 0) {
|
||||
columnOrder = Object.keys(context.tableDisplayData[0]);
|
||||
console.log("📊 tableDisplayData에서 컬럼 순서 추출:", columnOrder);
|
||||
}
|
||||
|
||||
if (columnOrder && columnOrder.length > 0 && dataToExport.length > 0) {
|
||||
console.log("🔄 컬럼 순서 재정렬 시작:", {
|
||||
columnOrder,
|
||||
originalColumns: Object.keys(dataToExport[0] || {}),
|
||||
});
|
||||
|
||||
dataToExport = dataToExport.map((row: any) => {
|
||||
const reorderedRow: any = {};
|
||||
|
||||
// 1. columnOrder에 있는 컬럼들을 순서대로 추가
|
||||
columnOrder!.forEach((colName: string) => {
|
||||
if (colName in row) {
|
||||
reorderedRow[colName] = row[colName];
|
||||
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
||||
let visibleColumns: string[] | undefined = undefined;
|
||||
let columnLabels: Record<string, string> | undefined = undefined;
|
||||
|
||||
try {
|
||||
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`);
|
||||
|
||||
if (layoutResponse.data?.success && layoutResponse.data?.data) {
|
||||
let layoutData = layoutResponse.data.data;
|
||||
|
||||
// components가 문자열이면 파싱
|
||||
if (typeof layoutData.components === 'string') {
|
||||
layoutData.components = JSON.parse(layoutData.components);
|
||||
}
|
||||
|
||||
// 테이블 리스트 컴포넌트 찾기
|
||||
const findTableListComponent = (components: any[]): any => {
|
||||
if (!Array.isArray(components)) return null;
|
||||
|
||||
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에 없는 나머지 컬럼들 추가 (끝에 배치)
|
||||
Object.keys(row).forEach((key) => {
|
||||
if (!(key in reorderedRow)) {
|
||||
reorderedRow[key] = row[key];
|
||||
const categoryColumnsResponse = await getCategoryColumns(context.tableName);
|
||||
|
||||
if (categoryColumnsResponse.success && categoryColumnsResponse.data) {
|
||||
// 백엔드에서 정의된 카테고리 컬럼들
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return reorderedRow;
|
||||
});
|
||||
|
||||
console.log("✅ 컬럼 순서 재정렬 완료:", {
|
||||
reorderedColumns: Object.keys(dataToExport[0] || {}),
|
||||
});
|
||||
} else {
|
||||
console.log("⏭️ 컬럼 순서 재정렬 스킵:", {
|
||||
hasColumnOrder: !!columnOrder,
|
||||
columnOrderLength: columnOrder?.length,
|
||||
hasTableDisplayData: !!context.tableDisplayData,
|
||||
dataToExportLength: dataToExport.length,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 카테고리 정보 조회 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📥 엑셀 다운로드 실행:", {
|
||||
fileName,
|
||||
sheetName,
|
||||
includeHeaders,
|
||||
dataCount: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
columnOrder: context.columnOrder,
|
||||
});
|
||||
// 🎨 컬럼 필터링 및 라벨 적용 (항상 실행)
|
||||
if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
|
||||
dataToExport = dataToExport.map((row: any) => {
|
||||
const filteredRow: Record<string, any> = {};
|
||||
|
||||
visibleColumns.forEach((columnName: string) => {
|
||||
// __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);
|
||||
|
||||
toast.success(config.successMessage || "엑셀 파일이 다운로드되었습니다.");
|
||||
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 엑셀 다운로드 실패:", error);
|
||||
|
||||
@@ -9,6 +9,15 @@ interface TableDisplayState {
|
||||
sortBy: string | null;
|
||||
sortOrder: "asc" | "desc";
|
||||
tableName: string;
|
||||
|
||||
// 🆕 엑셀 다운로드 개선을 위한 추가 필드
|
||||
filterConditions?: Record<string, any>; // 필터 조건
|
||||
searchTerm?: string; // 검색어
|
||||
visibleColumns?: string[]; // 화면 표시 컬럼
|
||||
columnLabels?: Record<string, string>; // 컬럼 라벨
|
||||
currentPage?: number; // 현재 페이지
|
||||
pageSize?: number; // 페이지 크기
|
||||
totalItems?: number; // 전체 항목 수
|
||||
}
|
||||
|
||||
class TableDisplayStore {
|
||||
@@ -22,13 +31,23 @@ class TableDisplayStore {
|
||||
* @param columnOrder 컬럼 순서
|
||||
* @param sortBy 정렬 컬럼
|
||||
* @param sortOrder 정렬 방향
|
||||
* @param options 추가 옵션 (필터, 페이징 등)
|
||||
*/
|
||||
setTableData(
|
||||
tableName: string,
|
||||
data: any[],
|
||||
columnOrder: string[],
|
||||
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, {
|
||||
data,
|
||||
@@ -36,15 +55,7 @@ class TableDisplayStore {
|
||||
sortBy,
|
||||
sortOrder,
|
||||
tableName,
|
||||
});
|
||||
|
||||
console.log("📦 [TableDisplayStore] 데이터 저장:", {
|
||||
tableName,
|
||||
dataCount: data.length,
|
||||
columnOrderLength: columnOrder.length,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
firstRow: data[0],
|
||||
...options,
|
||||
});
|
||||
|
||||
this.notifyListeners();
|
||||
@@ -55,15 +66,7 @@ class TableDisplayStore {
|
||||
* @param tableName 테이블명
|
||||
*/
|
||||
getTableData(tableName: string): TableDisplayState | undefined {
|
||||
const state = this.state.get(tableName);
|
||||
|
||||
console.log("📤 [TableDisplayStore] 데이터 조회:", {
|
||||
tableName,
|
||||
found: !!state,
|
||||
dataCount: state?.data.length,
|
||||
});
|
||||
|
||||
return state;
|
||||
return this.state.get(tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user