276 lines
7.9 KiB
Markdown
276 lines
7.9 KiB
Markdown
|
|
# 엑셀 다운로드 개선 계획 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
|
||
|
|
**다음 업데이트**: 구현 완료 후
|
||
|
|
|