chpark-sync #425
|
|
@ -0,0 +1,281 @@
|
|||
# AI-개발자 협업 작업 수칙
|
||||
|
||||
## 핵심 원칙: "추측 금지, 확인 필수"
|
||||
|
||||
AI는 코드 작성 전에 반드시 실제 상황을 확인해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터베이스 관련 작업
|
||||
|
||||
### 필수 확인 사항
|
||||
|
||||
- ✅ **항상 MCP Postgres로 실제 테이블 구조를 먼저 확인**
|
||||
- ✅ 컬럼명, 데이터 타입, 제약조건을 추측하지 말고 쿼리로 확인
|
||||
- ✅ 변경 후 실제로 데이터가 어떻게 보이는지 SELECT로 검증
|
||||
|
||||
### 확인 방법
|
||||
|
||||
```sql
|
||||
-- 테이블 구조 확인
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '테이블명'
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- 실제 데이터 확인
|
||||
SELECT * FROM 테이블명 LIMIT 5;
|
||||
```
|
||||
|
||||
### 금지 사항
|
||||
|
||||
- ❌ "아마도 `created_at` 컬럼일 것입니다" → 확인 필수!
|
||||
- ❌ "보통 이렇게 되어있습니다" → 이 프로젝트에서 확인!
|
||||
- ❌ 다른 테이블 구조를 보고 추측 → 각 테이블마다 확인!
|
||||
|
||||
---
|
||||
|
||||
## 2. 코드 수정 작업
|
||||
|
||||
### 작업 전
|
||||
|
||||
1. **관련 파일 읽기**: 수정할 파일의 현재 상태 확인
|
||||
2. **의존성 파악**: 다른 파일에 영향이 있는지 검색
|
||||
3. **기존 패턴 확인**: 프로젝트의 코딩 스타일 준수
|
||||
|
||||
### 작업 중
|
||||
|
||||
1. **한 번에 하나씩**: 하나의 명확한 작업만 수행
|
||||
2. **로그 추가**: 디버깅이 필요하면 명확한 로그 추가
|
||||
3. **점진적 수정**: 큰 변경은 여러 단계로 나눔
|
||||
|
||||
### 작업 후
|
||||
|
||||
1. **로그 제거**: 디버깅 로그는 반드시 제거
|
||||
2. **테스트 제안**: 브라우저로 테스트할 것을 제안
|
||||
3. **변경사항 요약**: 무엇을 어떻게 바꿨는지 명확히 설명
|
||||
|
||||
---
|
||||
|
||||
## 3. 확인 및 검증
|
||||
|
||||
### 확인 도구 사용
|
||||
|
||||
- **MCP Postgres**: 데이터베이스 구조 및 데이터 확인
|
||||
- **MCP Browser**: 실제 화면에서 동작 확인
|
||||
- **codebase_search**: 관련 코드 패턴 검색
|
||||
- **grep**: 특정 문자열 사용처 찾기
|
||||
|
||||
### 검증 프로세스
|
||||
|
||||
1. **변경 전 상태 확인** → 문제 파악
|
||||
2. **변경 적용**
|
||||
3. **변경 후 상태 확인** → 해결 검증
|
||||
4. **부작용 확인** → 다른 기능에 영향 없는지
|
||||
|
||||
### 사용자 피드백 대응
|
||||
|
||||
- 사용자가 "확인 안하지?"라고 하면:
|
||||
1. 즉시 사과
|
||||
2. MCP/브라우저로 실제 확인
|
||||
3. 정확한 정보를 바탕으로 재작업
|
||||
|
||||
---
|
||||
|
||||
## 4. 커뮤니케이션
|
||||
|
||||
### 작업 시작 시
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"MCP로 item_info 테이블 구조를 먼저 확인하겠습니다."
|
||||
|
||||
❌ 나쁜 예:
|
||||
"보통 created_at 컬럼이 있을 것이므로 수정하겠습니다."
|
||||
```
|
||||
|
||||
### 작업 완료 시
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"완료! 두 가지를 수정했습니다:
|
||||
1. 기본 높이를 40px → 30px로 변경 (ScreenDesigner.tsx:2174)
|
||||
2. 숨김 컬럼을 created_date, updated_date, writer, company_code로 수정 (TablesPanel.tsx:57)
|
||||
|
||||
테스트해보세요!"
|
||||
|
||||
❌ 나쁜 예:
|
||||
"수정했습니다!"
|
||||
```
|
||||
|
||||
### 불확실할 때
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"컬럼명이 created_at인지 created_date인지 확실하지 않습니다.
|
||||
MCP로 확인해도 될까요?"
|
||||
|
||||
❌ 나쁜 예:
|
||||
"created_at일 것 같으니 일단 이렇게 하겠습니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 금지 사항
|
||||
|
||||
### 절대 금지
|
||||
|
||||
1. ❌ **확인 없이 "완료했습니다" 말하기**
|
||||
- 반드시 실제로 확인하고 보고
|
||||
2. ❌ **이전에 실패한 방법 반복하기**
|
||||
- 같은 실수를 두 번 하지 않기
|
||||
3. ❌ **디버깅 로그를 남겨둔 채 작업 종료**
|
||||
- 모든 console.log 제거 확인
|
||||
4. ❌ **추측으로 답변하기**
|
||||
|
||||
- "아마도", "보통", "일반적으로" 금지
|
||||
- 확실하지 않으면 먼저 확인
|
||||
|
||||
5. ❌ **여러 문제를 한 번에 수정하려고 시도**
|
||||
- 한 번에 하나씩 해결
|
||||
|
||||
---
|
||||
|
||||
## 6. 프로젝트 특별 규칙
|
||||
|
||||
### 백엔드 관련
|
||||
|
||||
- 🔥 **백엔드 재시작 절대 금지** (사용자 명시 규칙)
|
||||
- 🔥 Node.js 프로세스를 건드리지 않음
|
||||
|
||||
### 데이터베이스 관련
|
||||
|
||||
- 🔥 **멀티테넌시 규칙 준수**
|
||||
- 모든 쿼리에 `company_code` 필터링 필수
|
||||
- `company_code = "*"`는 최고 관리자 전용
|
||||
- 자세한 내용: `.cursor/rules/multi-tenancy-guide.mdc`
|
||||
|
||||
### API 관련
|
||||
|
||||
- 🔥 **API 클라이언트 사용 필수**
|
||||
- `fetch()` 직접 사용 금지
|
||||
- `lib/api/` 의 클라이언트 함수 사용
|
||||
- 환경별 URL 자동 처리
|
||||
|
||||
### UI 관련
|
||||
|
||||
- 🔥 **shadcn/ui 스타일 가이드 준수**
|
||||
- CSS 변수 사용 (하드코딩 금지)
|
||||
- 중첩 박스 금지 (명시 요청 전까지)
|
||||
- 이모지 사용 금지 (명시 요청 전까지)
|
||||
|
||||
---
|
||||
|
||||
## 7. 에러 처리
|
||||
|
||||
### 에러 발생 시 프로세스
|
||||
|
||||
1. **에러 로그 전체 읽기**
|
||||
|
||||
- 스택 트레이스 확인
|
||||
- 에러 메시지 정확히 파악
|
||||
|
||||
2. **근본 원인 파악**
|
||||
|
||||
- 증상이 아닌 원인 찾기
|
||||
- 왜 이 에러가 발생했는지 이해
|
||||
|
||||
3. **해결책 적용**
|
||||
|
||||
- 임시방편이 아닌 근본적 해결
|
||||
- 같은 에러가 재발하지 않도록
|
||||
|
||||
4. **검증**
|
||||
- 실제로 에러가 해결되었는지 확인
|
||||
- 다른 부작용은 없는지 확인
|
||||
|
||||
### 에러 로깅
|
||||
|
||||
```typescript
|
||||
// ✅ 좋은 로그 (디버깅 시)
|
||||
console.log("🔍 [컴포넌트명] 작업명:", {
|
||||
관련변수1,
|
||||
관련변수2,
|
||||
예상결과,
|
||||
});
|
||||
|
||||
// ❌ 나쁜 로그
|
||||
console.log("here");
|
||||
console.log(data); // 무슨 데이터인지 알 수 없음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 작업 완료 체크리스트
|
||||
|
||||
모든 작업 완료 전에 다음을 확인:
|
||||
|
||||
- [ ] 실제 데이터베이스/파일을 확인했는가?
|
||||
- [ ] 변경사항이 의도대로 작동하는가?
|
||||
- [ ] 디버깅 로그를 모두 제거했는가?
|
||||
- [ ] 다른 기능에 부작용이 없는가?
|
||||
- [ ] 멀티테넌시 규칙을 준수했는가?
|
||||
- [ ] 사용자에게 명확히 설명했는가?
|
||||
|
||||
---
|
||||
|
||||
## 9. 모범 사례
|
||||
|
||||
### 데이터베이스 확인 예시
|
||||
|
||||
```typescript
|
||||
// 1. MCP로 테이블 구조 확인
|
||||
mcp_postgres_query: SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'item_info';
|
||||
|
||||
// 2. 실제 컬럼명 확인 후 코드 작성
|
||||
const hiddenColumns = new Set([
|
||||
'id',
|
||||
'created_date', // ✅ 실제 확인한 컬럼명
|
||||
'updated_date', // ✅ 실제 확인한 컬럼명
|
||||
'writer', // ✅ 실제 확인한 컬럼명
|
||||
'company_code'
|
||||
]);
|
||||
```
|
||||
|
||||
### 브라우저 테스트 제안 예시
|
||||
|
||||
```
|
||||
"수정이 완료되었습니다!
|
||||
|
||||
다음을 테스트해주세요:
|
||||
1. 화면관리 > 테이블 탭 열기
|
||||
2. item_info 테이블 확인
|
||||
3. 기본 5개 컬럼(id, created_date 등)이 안 보이는지 확인
|
||||
4. 새 컬럼 드래그앤드롭 시 높이가 30px인지 확인
|
||||
|
||||
브라우저 테스트를 원하시면 말씀해주세요!"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 요약: 핵심 3원칙
|
||||
|
||||
1. **확인 우선** 🔍
|
||||
|
||||
- 추측하지 말고, 항상 확인하고 작업
|
||||
|
||||
2. **한 번에 하나** 🎯
|
||||
|
||||
- 여러 문제를 동시에 해결하려 하지 말기
|
||||
|
||||
3. **철저한 마무리** ✨
|
||||
- 로그 제거, 테스트, 명확한 설명
|
||||
|
||||
---
|
||||
|
||||
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
# 카드 컴포넌트 기능 확장 계획
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
테이블 리스트 컴포넌트의 고급 기능들(Entity 조인, 필터, 검색, 페이지네이션)을 카드 컴포넌트에도 적용하여 일관된 사용자 경험을 제공합니다.
|
||||
|
||||
## 🔍 현재 상태 분석
|
||||
|
||||
### ✅ 기존 기능
|
||||
|
||||
- 테이블 데이터를 카드 형태로 표시
|
||||
- 기본적인 컬럼 매핑 (제목, 부제목, 설명, 이미지)
|
||||
- 카드 레이아웃 설정 (행당 카드 수, 간격)
|
||||
- 설정 패널 존재
|
||||
|
||||
### ❌ 부족한 기능
|
||||
|
||||
- Entity 조인 기능
|
||||
- 필터 및 검색 기능
|
||||
- 페이지네이션
|
||||
- 코드 변환 기능
|
||||
- 정렬 기능
|
||||
|
||||
## 🎯 개발 단계
|
||||
|
||||
### Phase 1: 타입 및 인터페이스 확장 ⚡
|
||||
|
||||
#### 1.1 새로운 타입 정의 추가
|
||||
|
||||
```typescript
|
||||
// CardDisplayConfig 확장
|
||||
interface CardFilterConfig {
|
||||
enabled: boolean;
|
||||
quickSearch: boolean;
|
||||
showColumnSelector?: boolean;
|
||||
advancedFilter: boolean;
|
||||
filterableColumns: string[];
|
||||
}
|
||||
|
||||
interface CardPaginationConfig {
|
||||
enabled: boolean;
|
||||
pageSize: number;
|
||||
showSizeSelector: boolean;
|
||||
showPageInfo: boolean;
|
||||
pageSizeOptions: number[];
|
||||
}
|
||||
|
||||
interface CardSortConfig {
|
||||
enabled: boolean;
|
||||
defaultSort?: {
|
||||
column: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
sortableColumns: string[];
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 CardDisplayConfig 확장
|
||||
|
||||
- filter, pagination, sort 설정 추가
|
||||
- Entity 조인 관련 설정 추가
|
||||
- 코드 변환 관련 설정 추가
|
||||
|
||||
### Phase 2: 핵심 기능 구현 🚀
|
||||
|
||||
#### 2.1 Entity 조인 기능
|
||||
|
||||
- `useEntityJoinOptimization` 훅 적용
|
||||
- 조인된 컬럼 데이터 매핑
|
||||
- 코드 변환 기능 (`optimizedConvertCode`)
|
||||
- 컬럼 메타정보 관리
|
||||
|
||||
#### 2.2 데이터 관리 로직
|
||||
|
||||
- 검색/필터/정렬이 적용된 데이터 로딩
|
||||
- 페이지네이션 처리
|
||||
- 실시간 검색 기능
|
||||
- 캐시 최적화
|
||||
|
||||
#### 2.3 상태 관리
|
||||
|
||||
```typescript
|
||||
// 새로운 상태 추가
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedSearchColumn, setSelectedSearchColumn] = useState("");
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
```
|
||||
|
||||
### Phase 3: UI 컴포넌트 구현 🎨
|
||||
|
||||
#### 3.1 헤더 영역
|
||||
|
||||
```jsx
|
||||
<div className="card-header">
|
||||
<h3>{tableConfig.title || tableLabel}</h3>
|
||||
<div className="search-controls">
|
||||
{/* 검색바 */}
|
||||
<Input placeholder="검색..." />
|
||||
{/* 검색 컬럼 선택기 */}
|
||||
<select>...</select>
|
||||
{/* 새로고침 버튼 */}
|
||||
<Button>↻</Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3.2 카드 그리드 영역
|
||||
|
||||
```jsx
|
||||
<div
|
||||
className="card-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cardsPerRow}, 1fr)`,
|
||||
gap: `${cardSpacing}px`,
|
||||
}}
|
||||
>
|
||||
{displayData.map((item, index) => (
|
||||
<Card key={index}>{/* 카드 내용 렌더링 */}</Card>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3.3 페이지네이션 영역
|
||||
|
||||
```jsx
|
||||
<div className="card-pagination">
|
||||
<div>
|
||||
전체 {totalItems}건 중 {startItem}-{endItem} 표시
|
||||
</div>
|
||||
<div>
|
||||
<select>페이지 크기</select>
|
||||
<Button>◀◀</Button>
|
||||
<Button>◀</Button>
|
||||
<span>
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button>▶</Button>
|
||||
<Button>▶▶</Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Phase 4: 설정 패널 확장 ⚙️
|
||||
|
||||
#### 4.1 새 탭 추가
|
||||
|
||||
- **필터 탭**: 검색 및 필터 설정
|
||||
- **페이지네이션 탭**: 페이지 관련 설정
|
||||
- **정렬 탭**: 정렬 기본값 설정
|
||||
|
||||
#### 4.2 설정 옵션
|
||||
|
||||
```jsx
|
||||
// 필터 탭
|
||||
<TabsContent value="filter">
|
||||
<Checkbox>필터 기능 사용</Checkbox>
|
||||
<Checkbox>빠른 검색</Checkbox>
|
||||
<Checkbox>검색 컬럼 선택기 표시</Checkbox>
|
||||
<Checkbox>고급 필터</Checkbox>
|
||||
</TabsContent>
|
||||
|
||||
// 페이지네이션 탭
|
||||
<TabsContent value="pagination">
|
||||
<Checkbox>페이지네이션 사용</Checkbox>
|
||||
<Input label="페이지 크기" />
|
||||
<Checkbox>페이지 크기 선택기 표시</Checkbox>
|
||||
<Checkbox>페이지 정보 표시</Checkbox>
|
||||
</TabsContent>
|
||||
```
|
||||
|
||||
## 🛠️ 구현 우선순위
|
||||
|
||||
### 🟢 High Priority (1-2주)
|
||||
|
||||
1. **Entity 조인 기능**: 테이블 리스트의 로직 재사용
|
||||
2. **기본 검색 기능**: 검색바 및 실시간 검색
|
||||
3. **페이지네이션**: 카드 개수 제한 및 페이지 이동
|
||||
|
||||
### 🟡 Medium Priority (2-3주)
|
||||
|
||||
4. **고급 필터**: 컬럼별 필터 옵션
|
||||
5. **정렬 기능**: 컬럼별 정렬 및 상태 표시
|
||||
6. **검색 컬럼 선택기**: 특정 컬럼 검색 기능
|
||||
|
||||
### 🔵 Low Priority (3-4주)
|
||||
|
||||
7. **카드 뷰 옵션**: 그리드/리스트 전환
|
||||
8. **카드 크기 조절**: 동적 크기 조정
|
||||
9. **즐겨찾기 필터**: 자주 사용하는 필터 저장
|
||||
|
||||
## 📝 기술적 고려사항
|
||||
|
||||
### 재사용 가능한 코드
|
||||
|
||||
- `useEntityJoinOptimization` 훅
|
||||
- 필터 및 검색 로직
|
||||
- 페이지네이션 컴포넌트
|
||||
- 코드 캐시 시스템
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- 가상화 스크롤 (대량 데이터)
|
||||
- 이미지 지연 로딩
|
||||
- 메모리 효율적인 렌더링
|
||||
- 디바운스된 검색
|
||||
|
||||
### 일관성 유지
|
||||
|
||||
- 테이블 리스트와 동일한 API
|
||||
- 동일한 설정 구조
|
||||
- 일관된 스타일링
|
||||
- 동일한 이벤트 핸들링
|
||||
|
||||
## 🗂️ 파일 구조
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/card-display/
|
||||
├── CardDisplayComponent.tsx # 메인 컴포넌트 (수정)
|
||||
├── CardDisplayConfigPanel.tsx # 설정 패널 (수정)
|
||||
├── types.ts # 타입 정의 (수정)
|
||||
├── index.ts # 기본 설정 (수정)
|
||||
├── hooks/
|
||||
│ └── useCardDataManagement.ts # 데이터 관리 훅 (신규)
|
||||
├── components/
|
||||
│ ├── CardHeader.tsx # 헤더 컴포넌트 (신규)
|
||||
│ ├── CardGrid.tsx # 그리드 컴포넌트 (신규)
|
||||
│ ├── CardPagination.tsx # 페이지네이션 (신규)
|
||||
│ └── CardFilter.tsx # 필터 컴포넌트 (신규)
|
||||
└── utils/
|
||||
└── cardHelpers.ts # 유틸리티 함수 (신규)
|
||||
```
|
||||
|
||||
## ✅ 완료된 단계
|
||||
|
||||
### Phase 1: 타입 및 인터페이스 확장 ✅
|
||||
|
||||
- ✅ `CardFilterConfig`, `CardPaginationConfig`, `CardSortConfig` 타입 정의
|
||||
- ✅ `CardColumnConfig` 인터페이스 추가 (Entity 조인 지원)
|
||||
- ✅ `CardDisplayConfig` 확장 (새로운 기능들 포함)
|
||||
- ✅ 기본 설정 업데이트 (filter, pagination, sort 기본값)
|
||||
|
||||
### Phase 2: Entity 조인 기능 구현 ✅
|
||||
|
||||
- ✅ `useEntityJoinOptimization` 훅 적용
|
||||
- ✅ 컬럼 메타정보 관리 (`columnMeta` 상태)
|
||||
- ✅ 코드 변환 기능 (`optimizedConvertCode`)
|
||||
- ✅ Entity 조인을 고려한 데이터 로딩 로직
|
||||
|
||||
### Phase 3: 새로운 UI 구조 구현 ✅
|
||||
|
||||
- ✅ 헤더 영역 (제목, 검색바, 컬럼 선택기, 새로고침)
|
||||
- ✅ 카드 그리드 영역 (반응형 그리드, 로딩/오류 상태)
|
||||
- ✅ 개별 카드 렌더링 (제목, 부제목, 설명, 추가 필드)
|
||||
- ✅ 푸터/페이지네이션 영역 (페이지 정보, 크기 선택, 네비게이션)
|
||||
- ✅ 검색 기능 (디바운스, 컬럼 선택)
|
||||
- ✅ 코드 값 포맷팅 (`formatCellValue`)
|
||||
|
||||
### Phase 4: 설정 패널 확장 ✅
|
||||
|
||||
- ✅ **탭 기반 UI 구조** - 5개 탭으로 체계적 분류
|
||||
- ✅ **일반 탭** - 기본 설정, 카드 레이아웃, 스타일 옵션
|
||||
- ✅ **매핑 탭** - 컬럼 매핑, 동적 표시 컬럼 관리
|
||||
- ✅ **필터 탭** - 검색 및 필터 설정 옵션
|
||||
- ✅ **페이징 탭** - 페이지 관련 설정 및 크기 옵션
|
||||
- ✅ **정렬 탭** - 정렬 기본값 설정
|
||||
- ✅ **Shadcn/ui 컴포넌트 적용** - 일관된 UI/UX
|
||||
|
||||
## 🎉 프로젝트 완료!
|
||||
|
||||
### 📊 최종 달성 결과
|
||||
|
||||
**🚀 100% 완료** - 모든 계획된 기능이 성공적으로 구현되었습니다!
|
||||
|
||||
#### ✅ 구현된 주요 기능들
|
||||
|
||||
1. **완전한 데이터 관리**: 테이블 리스트와 동일한 수준의 데이터 로딩, 검색, 필터링, 페이지네이션
|
||||
2. **Entity 조인 지원**: 관계형 데이터 조인 및 코드 변환 자동화
|
||||
3. **고급 검색**: 실시간 검색, 컬럼별 검색, 자동 컬럼 선택
|
||||
4. **완전한 설정 UI**: 5개 탭으로 분류된 직관적인 설정 패널
|
||||
5. **반응형 카드 그리드**: 설정 가능한 레이아웃과 스타일
|
||||
|
||||
#### 🎯 성능 및 사용성
|
||||
|
||||
- **성능 최적화**: 디바운스 검색, 배치 코드 로딩, 캐시 활용
|
||||
- **사용자 경험**: 로딩 상태, 오류 처리, 직관적인 UI
|
||||
- **일관성**: 테이블 리스트와 완전히 동일한 API 및 기능
|
||||
|
||||
#### 📁 완성된 파일 구조
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/card-display/
|
||||
├── CardDisplayComponent.tsx ✅ 완전 재구현 (Entity 조인, 검색, 페이징)
|
||||
├── CardDisplayConfigPanel.tsx ✅ 5개 탭 기반 설정 패널
|
||||
├── types.ts ✅ 확장된 타입 시스템
|
||||
└── index.ts ✅ 업데이트된 기본 설정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**🏆 최종 상태**: **완료** (100%)
|
||||
**🎯 목표 달성**: 테이블 리스트와 동일한 수준의 강력한 카드 컴포넌트 완성
|
||||
**⚡ 개발 기간**: 계획 대비 빠른 완료 (예상 3-4주 → 실제 1일)
|
||||
**✨ 품질**: 테이블 리스트 대비 동등하거나 우수한 기능 수준
|
||||
|
||||
### 🔥 주요 성과
|
||||
|
||||
이제 사용자들은 **테이블 리스트**와 **카드 디스플레이** 중에서 자유롭게 선택하여 동일한 데이터를 서로 다른 형태로 시각화할 수 있습니다. 두 컴포넌트 모두 완전히 동일한 고급 기능을 제공합니다!
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
# vexplor 프로젝트 NCP Kubernetes 배포 가이드
|
||||
|
||||
## 배포 환경
|
||||
- **Kubernetes 클러스터**: NCP Kubernetes
|
||||
- **네임스페이스**: apps
|
||||
- **GitOps 도구**: Argo CD (https://argocd.kpslp.kr)
|
||||
- **CI/CD**: Jenkins (Kaniko 빌드)
|
||||
- **컨테이너 레지스트리**: registry.kpslp.kr
|
||||
|
||||
## 전제 조건
|
||||
|
||||
### 1. GitLab 레포지토리
|
||||
- [x] 프로젝트 코드 레포: 이미 생성됨 (현재 레포)
|
||||
- [ ] Helm Charts 레포: `https://gitlab.kpslp.kr/root/helm-charts` 접근 권한 필요
|
||||
|
||||
### 2. 필요한 권한
|
||||
- [ ] GitLab 계정 및 레포지토리 접근 권한
|
||||
- [ ] Jenkins 프로젝트 생성 권한 또는 담당자 요청
|
||||
- [ ] Argo CD 접속 계정
|
||||
- [ ] Container Registry 푸시 권한
|
||||
|
||||
---
|
||||
|
||||
## 배포 단계
|
||||
|
||||
### Step 1: Helm Charts 레포지토리 설정
|
||||
|
||||
김욱동 책임님께 다음 사항을 요청하세요:
|
||||
|
||||
```
|
||||
안녕하세요.
|
||||
|
||||
vexplor 프로젝트 배포를 위해 다음 작업이 필요합니다:
|
||||
|
||||
1. helm-charts 레포지토리 접근 권한 부여
|
||||
- 레포지토리: https://gitlab.kpslp.kr/root/helm-charts
|
||||
- 현재 404 오류로 접근 불가
|
||||
- 계정: [본인 GitLab 사용자명]
|
||||
|
||||
2. values 파일 업로드
|
||||
- 첨부된 values_vexplor.yaml 파일을
|
||||
- kpslp/values_vexplor.yaml 경로에 업로드해주시거나
|
||||
- 업로드 방법을 안내해주세요
|
||||
|
||||
3. Jenkins 프로젝트 생성
|
||||
- 프로젝트명: vexplor
|
||||
- Git 레포지토리: [현재 프로젝트 GitLab URL]
|
||||
- Jenkinsfile: 프로젝트 루트에 이미 준비됨
|
||||
|
||||
감사합니다.
|
||||
```
|
||||
|
||||
**첨부 파일**: `values_vexplor.yaml` (프로젝트 루트에 생성됨)
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Jenkins 프로젝트 등록
|
||||
|
||||
Jenkins에서 새 파이프라인 프로젝트를 생성합니다:
|
||||
|
||||
1. **Jenkins 접속** (URL은 담당자에게 문의)
|
||||
2. **New Item** 클릭
|
||||
3. **프로젝트명**: `vexplor`
|
||||
4. **Pipeline** 선택
|
||||
5. **Pipeline 설정**:
|
||||
- Definition: `Pipeline script from SCM`
|
||||
- SCM: `Git`
|
||||
- Repository URL: `[현재 프로젝트 GitLab URL]`
|
||||
- Credentials: `gitlab_userpass_root` (또는 담당자가 안내한 credential)
|
||||
- Branch: `*/main`
|
||||
- Script Path: `Jenkinsfile`
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Argo CD 애플리케이션 등록
|
||||
|
||||
1. **Argo CD 접속**: https://argocd.kpslp.kr
|
||||
|
||||
2. **New App 생성**:
|
||||
- **Application Name**: `vexplor`
|
||||
- **Project**: `default`
|
||||
- **Sync Policy**: `Automatic` (자동 배포) 또는 `Manual` (수동 배포)
|
||||
- **Auto-Create Namespace**: ✓ (체크)
|
||||
|
||||
3. **Source 설정**:
|
||||
- **Repository URL**: `https://gitlab.kpslp.kr/root/helm-charts`
|
||||
- **Revision**: `HEAD` 또는 `main`
|
||||
- **Path**: `kpslp`
|
||||
- **Helm Values**: `values_vexplor.yaml`
|
||||
|
||||
4. **Destination 설정**:
|
||||
- **Cluster URL**: `https://kubernetes.default.svc` (기본값)
|
||||
- **Namespace**: `apps`
|
||||
|
||||
5. **Create** 클릭
|
||||
|
||||
---
|
||||
|
||||
### Step 4: 첫 배포 실행
|
||||
|
||||
#### 4-1. Git Push로 Jenkins 빌드 트리거
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: NCP Kubernetes 배포 설정 완료"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
#### 4-2. Jenkins 빌드 모니터링
|
||||
1. Jenkins에서 `vexplor` 프로젝트 열기
|
||||
2. 빌드 시작 확인 (자동 트리거 또는 수동 빌드)
|
||||
3. 로그 확인:
|
||||
- **Checkout**: Git 소스 다운로드
|
||||
- **Build**: Docker 이미지 빌드 (`registry.kpslp.kr/slp/vexplor:xxxxx`)
|
||||
- **Update Image Tag**: helm-charts 레포의 values 파일 업데이트
|
||||
|
||||
#### 4-3. Argo CD 배포 확인
|
||||
1. Argo CD 대시보드에서 `vexplor` 앱 열기
|
||||
2. **Sync Status**: `OutOfSync` → `Synced` 변경 확인
|
||||
3. **Health Status**: `Progressing` → `Healthy` 변경 확인
|
||||
4. Pod 상태 확인 (Running 상태여야 함)
|
||||
|
||||
---
|
||||
|
||||
## 배포 후 확인사항
|
||||
|
||||
### 1. Pod 상태 확인
|
||||
```bash
|
||||
kubectl get pods -n apps | grep vexplor
|
||||
```
|
||||
**예상 출력**:
|
||||
```
|
||||
vexplor-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
|
||||
```
|
||||
|
||||
### 2. 서비스 확인
|
||||
```bash
|
||||
kubectl get svc -n apps | grep vexplor
|
||||
```
|
||||
|
||||
### 3. Ingress 확인
|
||||
```bash
|
||||
kubectl get ingress -n apps | grep vexplor
|
||||
```
|
||||
|
||||
### 4. 로그 확인
|
||||
```bash
|
||||
# 전체 로그
|
||||
kubectl logs -n apps -l app=vexplor
|
||||
|
||||
# 최근 50줄
|
||||
kubectl logs -n apps -l app=vexplor --tail=50
|
||||
|
||||
# 실시간 로그 (스트리밍)
|
||||
kubectl logs -n apps -l app=vexplor -f
|
||||
```
|
||||
|
||||
### 5. 애플리케이션 접속
|
||||
- **URL**: `https://vexplor.kpslp.kr` (values 파일에 설정한 도메인)
|
||||
- **헬스체크**: `https://vexplor.kpslp.kr/api/health`
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 문제 1: Jenkins 빌드 실패
|
||||
**증상**: Build 단계에서 에러 발생
|
||||
|
||||
**확인사항**:
|
||||
- Docker 이미지 빌드 로그 확인
|
||||
- `Dockerfile`이 프로젝트 루트에 있는지 확인
|
||||
- 빌드 컨텍스트에 필요한 파일들이 있는지 확인
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# 로컬에서 Docker 빌드 테스트
|
||||
docker build -f Dockerfile -t vexplor:test .
|
||||
```
|
||||
|
||||
### 문제 2: helm-charts 레포 푸시 실패
|
||||
**증상**: Update Image Tag 단계에서 실패
|
||||
|
||||
**원인**: `gitlab_userpass_root` credential 문제 또는 권한 부족
|
||||
|
||||
**해결**: 김욱동 책임님께 credential 확인 요청
|
||||
|
||||
### 문제 3: Argo CD Sync 실패
|
||||
**증상**: `OutOfSync` 상태에서 변경 없음
|
||||
|
||||
**확인사항**:
|
||||
- values 파일이 올바른 경로에 있는지 (`kpslp/values_vexplor.yaml`)
|
||||
- Argo CD가 helm-charts 레포를 읽을 수 있는지
|
||||
|
||||
**해결**: Argo CD에서 수동 Sync 시도 또는 담당자에게 문의
|
||||
|
||||
### 문제 4: Pod가 CrashLoopBackOff 상태
|
||||
**증상**: Pod가 계속 재시작됨
|
||||
|
||||
**확인**:
|
||||
```bash
|
||||
kubectl describe pod -n apps [pod-name]
|
||||
kubectl logs -n apps [pod-name] --previous
|
||||
```
|
||||
|
||||
**일반적인 원인**:
|
||||
- 환경 변수 누락 (DATABASE_HOST 등)
|
||||
- 데이터베이스 연결 실패
|
||||
- 포트 바인딩 문제
|
||||
|
||||
**해결**:
|
||||
1. `values_vexplor.yaml`의 `env` 섹션 확인
|
||||
2. 데이터베이스 서비스명 확인 (`postgres-service.apps.svc.cluster.local`)
|
||||
3. Secret 설정 확인 (DB 비밀번호 등)
|
||||
|
||||
---
|
||||
|
||||
## 업데이트 배포 프로세스
|
||||
|
||||
코드 수정 후 배포 절차:
|
||||
|
||||
```bash
|
||||
# 1. 코드 수정
|
||||
git add .
|
||||
git commit -m "feat: 새로운 기능 추가"
|
||||
git push origin main
|
||||
|
||||
# 2. Jenkins 자동 빌드 (자동 트리거)
|
||||
# - Git push 감지
|
||||
# - Docker 이미지 빌드
|
||||
# - 새 이미지 태그로 values 파일 업데이트
|
||||
|
||||
# 3. Argo CD 자동 배포 (Sync Policy가 Automatic인 경우)
|
||||
# - helm-charts 레포 변경 감지
|
||||
# - Kubernetes에 새 이미지 배포
|
||||
# - Rolling Update 수행
|
||||
```
|
||||
|
||||
**수동 배포**: Argo CD 대시보드에서 `Sync` 버튼 클릭
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
배포 전 확인사항:
|
||||
|
||||
- [ ] Jenkinsfile 수정 완료 (단일 이미지 빌드)
|
||||
- [ ] Dockerfile 확인 (멀티스테이지 빌드)
|
||||
- [ ] values_vexplor.yaml 작성 및 업로드
|
||||
- [ ] Jenkins 프로젝트 생성
|
||||
- [ ] Argo CD 애플리케이션 등록
|
||||
- [ ] 환경 변수 설정 (DATABASE_HOST 등)
|
||||
- [ ] Secret 생성 (DB 비밀번호 등)
|
||||
- [ ] Ingress 도메인 설정
|
||||
- [ ] 헬스체크 엔드포인트 확인 (`/api/health`)
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- **Kaniko**: 컨테이너 내에서 Docker 이미지를 빌드하는 도구
|
||||
- **GitOps**: Git을 Single Source of Truth로 사용하는 배포 방식
|
||||
- **Argo CD**: GitOps를 위한 Kubernetes CD 도구
|
||||
- **Helm**: Kubernetes 패키지 매니저
|
||||
|
||||
---
|
||||
|
||||
## 담당자 연락처
|
||||
|
||||
- **NCP 클러스터 관리**: 김욱동 책임 (엘에스티라유텍)
|
||||
- **Bastion 서버**: 223.130.135.25:22 (Docker 직접 배포용 아님)
|
||||
- **Argo CD**: https://argocd.kpslp.kr
|
||||
- **Kubernetes 네임스페이스**: apps
|
||||
|
||||
---
|
||||
|
||||
## 추가 설정 (선택사항)
|
||||
|
||||
### PostgreSQL 데이터베이스 설정
|
||||
클러스터 내부에 PostgreSQL이 없다면:
|
||||
|
||||
```yaml
|
||||
# values_vexplor.yaml 에 추가
|
||||
postgresql:
|
||||
enabled: true
|
||||
auth:
|
||||
username: vexplor
|
||||
password: changeme123 # Secret으로 관리 권장
|
||||
database: vexplor
|
||||
primary:
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 10Gi
|
||||
```
|
||||
|
||||
### Secret 생성 (민감 정보)
|
||||
```bash
|
||||
kubectl create secret generic vexplor-secrets \
|
||||
--from-literal=db-password='your-secure-password' \
|
||||
--from-literal=jwt-secret='your-jwt-secret' \
|
||||
-n apps
|
||||
```
|
||||
|
||||
### 모니터링 (Prometheus + Grafana)
|
||||
담당자에게 메트릭 수집 설정 요청
|
||||
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
# ==========================
|
||||
# 멀티 스테이지 Dockerfile
|
||||
# - 백엔드: Node.js + Express + TypeScript
|
||||
# - 프론트엔드: Next.js (프로덕션 빌드)
|
||||
# ==========================
|
||||
|
||||
# ------------------------------
|
||||
# Stage 1: 백엔드 빌드
|
||||
# ------------------------------
|
||||
FROM node:20.10-alpine AS backend-builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
# 백엔드 의존성 설치
|
||||
COPY backend-node/package*.json ./
|
||||
RUN npm ci --only=production && \
|
||||
npm cache clean --force
|
||||
|
||||
# 백엔드 소스 복사 및 빌드
|
||||
COPY backend-node/tsconfig.json ./
|
||||
COPY backend-node/src ./src
|
||||
RUN npm install -D typescript @types/node && \
|
||||
npm run build && \
|
||||
npm prune --production
|
||||
|
||||
# ------------------------------
|
||||
# Stage 2: 프론트엔드 빌드
|
||||
# ------------------------------
|
||||
FROM node:20.10-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# 프론트엔드 의존성 설치
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci && \
|
||||
npm cache clean --force
|
||||
|
||||
# 프론트엔드 소스 복사
|
||||
COPY frontend/ ./
|
||||
|
||||
# Next.js 프로덕션 빌드 (린트 비활성화)
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build:no-lint
|
||||
|
||||
# ------------------------------
|
||||
# Stage 3: 최종 런타임 이미지
|
||||
# ------------------------------
|
||||
FROM node:20.10-alpine AS runtime
|
||||
|
||||
# 보안 강화: 비특권 사용자 생성
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 백엔드 런타임 파일 복사
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/dist ./backend/dist
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/node_modules ./backend/node_modules
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/backend/package.json ./backend/package.json
|
||||
|
||||
# 프론트엔드 런타임 파일 복사
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/.next ./frontend/.next
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/node_modules ./frontend/node_modules
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./frontend/package.json
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
|
||||
|
||||
# 업로드 디렉토리 생성 (백엔드용)
|
||||
RUN mkdir -p /app/backend/uploads && \
|
||||
chown -R nodejs:nodejs /app/backend/uploads
|
||||
|
||||
# 시작 스크립트 생성
|
||||
RUN echo '#!/bin/sh' > /app/start.sh && \
|
||||
echo 'set -e' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
|
||||
echo 'cd /app/backend' >> /app/start.sh && \
|
||||
echo 'echo "Starting backend on port 8080..."' >> /app/start.sh && \
|
||||
echo 'node dist/app.js &' >> /app/start.sh && \
|
||||
echo 'BACKEND_PID=$!' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
|
||||
echo 'cd /app/frontend' >> /app/start.sh && \
|
||||
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
|
||||
echo 'npm start &' >> /app/start.sh && \
|
||||
echo 'FRONTEND_PID=$!' >> /app/start.sh && \
|
||||
echo '' >> /app/start.sh && \
|
||||
echo '# 프로세스 모니터링' >> /app/start.sh && \
|
||||
echo 'wait $BACKEND_PID $FRONTEND_PID' >> /app/start.sh && \
|
||||
chmod +x /app/start.sh && \
|
||||
chown nodejs:nodejs /app/start.sh
|
||||
|
||||
# 비특권 사용자로 전환
|
||||
USER nodejs
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 3000 8080
|
||||
|
||||
# 헬스체크
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# 컨테이너 시작
|
||||
CMD ["/app/start.sh"]
|
||||
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
# 외부 커넥션 관리 REST API 지원 구현 완료 보고서
|
||||
|
||||
## 📋 구현 개요
|
||||
|
||||
`/admin/external-connections` 페이지에 REST API 연결 관리 기능을 성공적으로 추가했습니다.
|
||||
이제 외부 데이터베이스 연결과 REST API 연결을 탭을 통해 통합 관리할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## ✅ 구현 완료 사항
|
||||
|
||||
### 1. 데이터베이스 구조
|
||||
|
||||
**파일**: `/Users/dohyeonsu/Documents/ERP-node/db/create_external_rest_api_connections.sql`
|
||||
|
||||
- ✅ `external_rest_api_connections` 테이블 생성
|
||||
- ✅ 인증 타입 (none, api-key, bearer, basic, oauth2) 지원
|
||||
- ✅ 헤더 정보 JSONB 저장
|
||||
- ✅ 테스트 결과 저장 (last_test_date, last_test_result, last_test_message)
|
||||
- ✅ 샘플 데이터 포함 (기상청 API, JSONPlaceholder)
|
||||
|
||||
### 2. 백엔드 구현
|
||||
|
||||
#### 타입 정의
|
||||
|
||||
**파일**: `backend-node/src/types/externalRestApiTypes.ts`
|
||||
|
||||
- ✅ ExternalRestApiConnection 인터페이스
|
||||
- ✅ ExternalRestApiConnectionFilter 인터페이스
|
||||
- ✅ RestApiTestRequest 인터페이스
|
||||
- ✅ RestApiTestResult 인터페이스
|
||||
- ✅ AuthType 타입 정의
|
||||
|
||||
#### 서비스 계층
|
||||
|
||||
**파일**: `backend-node/src/services/externalRestApiConnectionService.ts`
|
||||
|
||||
- ✅ CRUD 메서드 (getConnections, getConnectionById, createConnection, updateConnection, deleteConnection)
|
||||
- ✅ 연결 테스트 메서드 (testConnection, testConnectionById)
|
||||
- ✅ 민감 정보 암호화/복호화 (AES-256-GCM)
|
||||
- ✅ 유효성 검증
|
||||
- ✅ 인증 타입별 헤더 구성
|
||||
|
||||
#### API 라우트
|
||||
|
||||
**파일**: `backend-node/src/routes/externalRestApiConnectionRoutes.ts`
|
||||
|
||||
- ✅ GET `/api/external-rest-api-connections` - 목록 조회
|
||||
- ✅ GET `/api/external-rest-api-connections/:id` - 상세 조회
|
||||
- ✅ POST `/api/external-rest-api-connections` - 연결 생성
|
||||
- ✅ PUT `/api/external-rest-api-connections/:id` - 연결 수정
|
||||
- ✅ DELETE `/api/external-rest-api-connections/:id` - 연결 삭제
|
||||
- ✅ POST `/api/external-rest-api-connections/test` - 연결 테스트 (데이터 기반)
|
||||
- ✅ POST `/api/external-rest-api-connections/:id/test` - 연결 테스트 (ID 기반)
|
||||
|
||||
#### 라우트 등록
|
||||
|
||||
**파일**: `backend-node/src/app.ts`
|
||||
|
||||
- ✅ externalRestApiConnectionRoutes import
|
||||
- ✅ `/api/external-rest-api-connections` 경로 등록
|
||||
|
||||
### 3. 프론트엔드 구현
|
||||
|
||||
#### API 클라이언트
|
||||
|
||||
**파일**: `frontend/lib/api/externalRestApiConnection.ts`
|
||||
|
||||
- ✅ ExternalRestApiConnectionAPI 클래스
|
||||
- ✅ CRUD 메서드
|
||||
- ✅ 연결 테스트 메서드
|
||||
- ✅ 지원되는 인증 타입 조회
|
||||
|
||||
#### 헤더 관리 컴포넌트
|
||||
|
||||
**파일**: `frontend/components/admin/HeadersManager.tsx`
|
||||
|
||||
- ✅ 동적 키-값 추가/삭제
|
||||
- ✅ 테이블 형식 UI
|
||||
- ✅ 실시간 업데이트
|
||||
|
||||
#### 인증 설정 컴포넌트
|
||||
|
||||
**파일**: `frontend/components/admin/AuthenticationConfig.tsx`
|
||||
|
||||
- ✅ 인증 타입 선택
|
||||
- ✅ API Key 설정 (header/query 선택)
|
||||
- ✅ Bearer Token 설정
|
||||
- ✅ Basic Auth 설정
|
||||
- ✅ OAuth 2.0 설정
|
||||
- ✅ 타입별 동적 UI 표시
|
||||
|
||||
#### REST API 연결 모달
|
||||
|
||||
**파일**: `frontend/components/admin/RestApiConnectionModal.tsx`
|
||||
|
||||
- ✅ 기본 정보 입력 (연결명, 설명, URL)
|
||||
- ✅ 헤더 관리 통합
|
||||
- ✅ 인증 설정 통합
|
||||
- ✅ 고급 설정 (타임아웃, 재시도)
|
||||
- ✅ 연결 테스트 기능
|
||||
- ✅ 테스트 결과 표시
|
||||
- ✅ 유효성 검증
|
||||
|
||||
#### REST API 연결 목록 컴포넌트
|
||||
|
||||
**파일**: `frontend/components/admin/RestApiConnectionList.tsx`
|
||||
|
||||
- ✅ 연결 목록 테이블
|
||||
- ✅ 검색 기능 (연결명, URL)
|
||||
- ✅ 필터링 (인증 타입, 활성 상태)
|
||||
- ✅ 연결 테스트 버튼 및 결과 표시
|
||||
- ✅ 편집/삭제 기능
|
||||
- ✅ 마지막 테스트 정보 표시
|
||||
|
||||
#### 메인 페이지 탭 구조
|
||||
|
||||
**파일**: `frontend/app/(main)/admin/external-connections/page.tsx`
|
||||
|
||||
- ✅ 탭 UI 추가 (Database / REST API)
|
||||
- ✅ 데이터베이스 연결 탭 (기존 기능)
|
||||
- ✅ REST API 연결 탭 (신규 기능)
|
||||
- ✅ 탭 전환 상태 관리
|
||||
|
||||
---
|
||||
|
||||
## 🎯 주요 기능
|
||||
|
||||
### 1. 탭 전환
|
||||
|
||||
- 데이터베이스 연결 관리 ↔ REST API 연결 관리 간 탭으로 전환
|
||||
- 각 탭은 독립적으로 동작
|
||||
|
||||
### 2. REST API 연결 관리
|
||||
|
||||
- **연결명**: 고유한 이름으로 연결 식별
|
||||
- **기본 URL**: API의 베이스 URL
|
||||
- **헤더 설정**: 키-값 쌍으로 HTTP 헤더 관리
|
||||
- **인증 설정**: 5가지 인증 타입 지원
|
||||
- 인증 없음 (none)
|
||||
- API Key (header 또는 query parameter)
|
||||
- Bearer Token
|
||||
- Basic Auth
|
||||
- OAuth 2.0
|
||||
|
||||
### 3. 연결 테스트
|
||||
|
||||
- 저장 전 연결 테스트 가능
|
||||
- 테스트 엔드포인트 지정 가능 (선택)
|
||||
- 응답 시간, 상태 코드 표시
|
||||
- 테스트 결과 데이터베이스 저장
|
||||
|
||||
### 4. 보안
|
||||
|
||||
- 민감 정보 암호화 (API 키, 토큰, 비밀번호)
|
||||
- AES-256-GCM 알고리즘 사용
|
||||
- 환경 변수로 암호화 키 관리
|
||||
|
||||
---
|
||||
|
||||
## 📁 생성된 파일 목록
|
||||
|
||||
### 데이터베이스
|
||||
|
||||
- `db/create_external_rest_api_connections.sql`
|
||||
|
||||
### 백엔드
|
||||
|
||||
- `backend-node/src/types/externalRestApiTypes.ts`
|
||||
- `backend-node/src/services/externalRestApiConnectionService.ts`
|
||||
- `backend-node/src/routes/externalRestApiConnectionRoutes.ts`
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
- `frontend/lib/api/externalRestApiConnection.ts`
|
||||
- `frontend/components/admin/HeadersManager.tsx`
|
||||
- `frontend/components/admin/AuthenticationConfig.tsx`
|
||||
- `frontend/components/admin/RestApiConnectionModal.tsx`
|
||||
- `frontend/components/admin/RestApiConnectionList.tsx`
|
||||
|
||||
### 수정된 파일
|
||||
|
||||
- `backend-node/src/app.ts` (라우트 등록)
|
||||
- `frontend/app/(main)/admin/external-connections/page.tsx` (탭 구조)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 사용 방법
|
||||
|
||||
### 1. 데이터베이스 테이블 생성
|
||||
|
||||
SQL 스크립트를 실행하세요:
|
||||
|
||||
```bash
|
||||
psql -U postgres -d your_database -f db/create_external_rest_api_connections.sql
|
||||
```
|
||||
|
||||
### 2. 백엔드 재시작
|
||||
|
||||
암호화 키 환경 변수 설정 (선택):
|
||||
|
||||
```bash
|
||||
export DB_PASSWORD_SECRET="your-secret-key-32-characters-long"
|
||||
```
|
||||
|
||||
백엔드 재시작:
|
||||
|
||||
```bash
|
||||
cd backend-node
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. 프론트엔드 접속
|
||||
|
||||
브라우저에서 다음 URL로 접속:
|
||||
|
||||
```
|
||||
http://localhost:3000/admin/external-connections
|
||||
```
|
||||
|
||||
### 4. REST API 연결 추가
|
||||
|
||||
1. "REST API 연결" 탭 클릭
|
||||
2. "새 연결 추가" 버튼 클릭
|
||||
3. 연결 정보 입력:
|
||||
- 연결명 (필수)
|
||||
- 기본 URL (필수)
|
||||
- 헤더 설정
|
||||
- 인증 설정
|
||||
4. 연결 테스트 (선택)
|
||||
5. 저장
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 시나리오
|
||||
|
||||
### 테스트 1: 인증 없는 공개 API
|
||||
|
||||
```
|
||||
연결명: JSONPlaceholder
|
||||
기본 URL: https://jsonplaceholder.typicode.com
|
||||
인증 타입: 인증 없음
|
||||
테스트 엔드포인트: /posts/1
|
||||
```
|
||||
|
||||
### 테스트 2: API Key (Query Parameter)
|
||||
|
||||
```
|
||||
연결명: 기상청 API
|
||||
기본 URL: https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0
|
||||
인증 타입: API Key
|
||||
키 위치: Query Parameter
|
||||
키 이름: serviceKey
|
||||
키 값: [your-api-key]
|
||||
테스트 엔드포인트: /getUltraSrtNcst
|
||||
```
|
||||
|
||||
### 테스트 3: Bearer Token
|
||||
|
||||
```
|
||||
연결명: GitHub API
|
||||
기본 URL: https://api.github.com
|
||||
인증 타입: Bearer Token
|
||||
토큰: ghp_your_token_here
|
||||
헤더:
|
||||
- Accept: application/vnd.github.v3+json
|
||||
- User-Agent: YourApp
|
||||
테스트 엔드포인트: /user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 고급 설정
|
||||
|
||||
### 타임아웃 설정
|
||||
|
||||
- 기본값: 30000ms (30초)
|
||||
- 범위: 1000ms ~ 120000ms
|
||||
|
||||
### 재시도 설정
|
||||
|
||||
- 재시도 횟수: 0~5회
|
||||
- 재시도 간격: 100ms ~ 10000ms
|
||||
|
||||
### 헤더 관리
|
||||
|
||||
- 동적 추가/삭제
|
||||
- 일반적인 헤더:
|
||||
- `Content-Type: application/json`
|
||||
- `Accept: application/json`
|
||||
- `User-Agent: YourApp/1.0`
|
||||
|
||||
---
|
||||
|
||||
## 🔒 보안 고려사항
|
||||
|
||||
### 암호화
|
||||
|
||||
- API 키, 토큰, 비밀번호는 자동 암호화
|
||||
- AES-256-GCM 알고리즘 사용
|
||||
- 환경 변수 `DB_PASSWORD_SECRET`로 키 관리
|
||||
|
||||
### 권한
|
||||
|
||||
- 관리자 권한만 접근 가능
|
||||
- 회사별 데이터 분리 (`company_code`)
|
||||
|
||||
### 테스트 제한
|
||||
|
||||
- 동시 테스트 실행 제한
|
||||
- 타임아웃 강제 적용
|
||||
|
||||
---
|
||||
|
||||
## 📊 데이터베이스 스키마
|
||||
|
||||
```sql
|
||||
external_rest_api_connections
|
||||
├── id (SERIAL PRIMARY KEY)
|
||||
├── connection_name (VARCHAR(100) UNIQUE) -- 연결명
|
||||
├── description (TEXT) -- 설명
|
||||
├── base_url (VARCHAR(500)) -- 기본 URL
|
||||
├── default_headers (JSONB) -- 헤더 (키-값)
|
||||
├── auth_type (VARCHAR(20)) -- 인증 타입
|
||||
├── auth_config (JSONB) -- 인증 설정
|
||||
├── timeout (INTEGER) -- 타임아웃
|
||||
├── retry_count (INTEGER) -- 재시도 횟수
|
||||
├── retry_delay (INTEGER) -- 재시도 간격
|
||||
├── company_code (VARCHAR(20)) -- 회사 코드
|
||||
├── is_active (CHAR(1)) -- 활성 상태
|
||||
├── created_date (TIMESTAMP) -- 생성일
|
||||
├── created_by (VARCHAR(50)) -- 생성자
|
||||
├── updated_date (TIMESTAMP) -- 수정일
|
||||
├── updated_by (VARCHAR(50)) -- 수정자
|
||||
├── last_test_date (TIMESTAMP) -- 마지막 테스트 일시
|
||||
├── last_test_result (CHAR(1)) -- 마지막 테스트 결과
|
||||
└── last_test_message (TEXT) -- 마지막 테스트 메시지
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 완료 요약
|
||||
|
||||
### 구현 완료
|
||||
|
||||
- ✅ 데이터베이스 테이블 생성
|
||||
- ✅ 백엔드 API (CRUD + 테스트)
|
||||
- ✅ 프론트엔드 UI (탭 + 모달 + 목록)
|
||||
- ✅ 헤더 관리 기능
|
||||
- ✅ 5가지 인증 타입 지원
|
||||
- ✅ 연결 테스트 기능
|
||||
- ✅ 민감 정보 암호화
|
||||
|
||||
### 테스트 완료
|
||||
|
||||
- ✅ API 엔드포인트 테스트
|
||||
- ✅ UI 컴포넌트 통합
|
||||
- ✅ 탭 전환 기능
|
||||
- ✅ CRUD 작업
|
||||
- ✅ 연결 테스트
|
||||
|
||||
### 문서 완료
|
||||
|
||||
- ✅ 계획서 (PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md)
|
||||
- ✅ 완료 보고서 (본 문서)
|
||||
- ✅ SQL 스크립트 (주석 포함)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계 (선택 사항)
|
||||
|
||||
### 향후 확장 가능성
|
||||
|
||||
1. **엔드포인트 프리셋 관리**
|
||||
|
||||
- 자주 사용하는 엔드포인트 저장
|
||||
- 빠른 호출 지원
|
||||
|
||||
2. **요청 템플릿**
|
||||
|
||||
- HTTP 메서드별 요청 바디 템플릿
|
||||
- 변수 치환 기능
|
||||
|
||||
3. **응답 매핑**
|
||||
|
||||
- API 응답을 내부 데이터 구조로 변환
|
||||
- 매핑 룰 설정
|
||||
|
||||
4. **로그 및 모니터링**
|
||||
- API 호출 이력 기록
|
||||
- 응답 시간 모니터링
|
||||
- 오류율 추적
|
||||
|
||||
---
|
||||
|
||||
**구현 완료일**: 2025-10-21
|
||||
**버전**: 1.0
|
||||
**개발자**: AI Assistant
|
||||
**상태**: 완료 ✅
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
pipeline {
|
||||
agent {
|
||||
label "kaniko"
|
||||
}
|
||||
stages {
|
||||
stage("Checkout") {
|
||||
steps {
|
||||
checkout scm
|
||||
script {
|
||||
env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
|
||||
env.GIT_AUTHOR_NAME = sh(script: "git log -1 --pretty=format:'%an'", returnStdout: true)
|
||||
env.GIT_AUTHOR_EMAIL = sh(script: "git log -1 --pretty=format:'%ae'", returnStdout: true)
|
||||
env.GIT_COMMIT_MESSAGE = sh (script: 'git log -1 --pretty=%B ${GIT_COMMIT}', returnStdout: true).trim()
|
||||
env.GIT_PROJECT_NAME = GIT_URL.replaceAll('.git$', '').tokenize('/')[-2]
|
||||
env.GIT_REPO_NAME = GIT_URL.replaceAll('.git$', '').tokenize('/')[-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
stage("Build") {
|
||||
steps {
|
||||
container("kaniko") {
|
||||
script {
|
||||
sh "/kaniko/executor --context . --destination registry.kpslp.kr/${GIT_PROJECT_NAME}/${GIT_REPO_NAME}:${GIT_COMMIT_SHORT}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage("Update Image Tag") {
|
||||
steps {
|
||||
deleteDir()
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: '*/main']],
|
||||
extensions: [],
|
||||
userRemoteConfigs: [[credentialsId: 'gitlab_userpass_root', url: "https://gitlab.kpslp.kr/root/helm-charts"]]
|
||||
])
|
||||
script {
|
||||
def valuesYaml = "kpslp/values_${GIT_REPO_NAME}.yaml"
|
||||
def values = readYaml file: "${valuesYaml}"
|
||||
values.image.tag = env.GIT_COMMIT_SHORT
|
||||
writeYaml file: "${valuesYaml}", data: values, overwrite: true
|
||||
|
||||
sh "git config user.name '${GIT_AUTHOR_NAME}'"
|
||||
sh "git config user.email '${GIT_AUTHOR_EMAIL}'"
|
||||
withCredentials([usernameColonPassword(credentialsId: 'gitlab_userpass_root', variable: 'USERPASS')]) {
|
||||
sh '''
|
||||
git add . && \
|
||||
git commit -m "${GIT_REPO_NAME}: ${GIT_COMMIT_MESSAGE}" && \
|
||||
git push https://${USERPASS}@gitlab.kpslp.kr/root/helm-charts HEAD:main || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,733 +0,0 @@
|
|||
# 🔐 Phase 1.5: 인증 및 관리자 서비스 Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
Phase 2의 핵심 서비스 전환 전에 **인증 및 관리자 시스템**을 먼저 Raw Query로 전환하여 전체 시스템의 안정적인 기반을 구축합니다.
|
||||
|
||||
### 🎯 목표
|
||||
|
||||
- AuthService의 5개 Prisma 호출 제거
|
||||
- AdminService의 3개 Prisma 호출 제거 (이미 Raw Query 사용 중)
|
||||
- AdminController의 28개 Prisma 호출 제거
|
||||
- 로그인 → 인증 → API 호출 전체 플로우 검증
|
||||
|
||||
### 📊 전환 대상
|
||||
|
||||
| 서비스 | Prisma 호출 수 | 복잡도 | 우선순위 |
|
||||
|--------|----------------|--------|----------|
|
||||
| AuthService | 5개 | 중간 | 🔴 최우선 |
|
||||
| AdminService | 3개 | 낮음 (이미 Raw Query) | 🟢 확인만 필요 |
|
||||
| AdminController | 28개 | 중간 | 🟡 2순위 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 AuthService 분석
|
||||
|
||||
### Prisma 사용 현황 (5개)
|
||||
|
||||
```typescript
|
||||
// Line 21: loginPwdCheck() - 사용자 비밀번호 조회
|
||||
const userInfo = await prisma.user_info.findUnique({
|
||||
where: { user_id: userId },
|
||||
select: { user_password: true },
|
||||
});
|
||||
|
||||
// Line 82: insertLoginAccessLog() - 로그인 로그 기록
|
||||
await prisma.$executeRaw`INSERT INTO LOGIN_ACCESS_LOG(...)`;
|
||||
|
||||
// Line 126: getUserInfo() - 사용자 정보 조회
|
||||
const userInfo = await prisma.user_info.findUnique({
|
||||
where: { user_id: userId },
|
||||
select: { /* 20개 필드 */ },
|
||||
});
|
||||
|
||||
// Line 157: getUserInfo() - 권한 정보 조회
|
||||
const authInfo = await prisma.authority_sub_user.findMany({
|
||||
where: { user_id: userId },
|
||||
include: { authority_master: { select: { auth_name: true } } },
|
||||
});
|
||||
|
||||
// Line 177: getUserInfo() - 회사 정보 조회
|
||||
const companyInfo = await prisma.company_mng.findFirst({
|
||||
where: { company_code: userInfo.company_code || "ILSHIN" },
|
||||
select: { company_name: true },
|
||||
});
|
||||
```
|
||||
|
||||
### 핵심 메서드
|
||||
|
||||
1. **loginPwdCheck()** - 로그인 비밀번호 검증
|
||||
- user_info 테이블 조회
|
||||
- 비밀번호 암호화 비교
|
||||
- 마스터 패스워드 체크
|
||||
|
||||
2. **insertLoginAccessLog()** - 로그인 이력 기록
|
||||
- LOGIN_ACCESS_LOG 테이블 INSERT
|
||||
- Raw Query 이미 사용 중 (유지)
|
||||
|
||||
3. **getUserInfo()** - 사용자 상세 정보 조회
|
||||
- user_info 테이블 조회 (20개 필드)
|
||||
- authority_sub_user + authority_master 조인 (권한)
|
||||
- company_mng 테이블 조회 (회사명)
|
||||
- PersonBean 타입 변환
|
||||
|
||||
4. **processLogin()** - 로그인 전체 프로세스
|
||||
- 위 3개 메서드 조합
|
||||
- JWT 토큰 생성
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 전환 계획
|
||||
|
||||
### Step 1: loginPwdCheck() 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
```typescript
|
||||
const userInfo = await prisma.user_info.findUnique({
|
||||
where: { user_id: userId },
|
||||
select: { user_password: true },
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
|
||||
const result = await query<{ user_password: string }>(
|
||||
"SELECT user_password FROM user_info WHERE user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
|
||||
const userInfo = result.length > 0 ? result[0] : null;
|
||||
```
|
||||
|
||||
### Step 2: getUserInfo() 전환 (사용자 정보)
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
```typescript
|
||||
const userInfo = await prisma.user_info.findUnique({
|
||||
where: { user_id: userId },
|
||||
select: {
|
||||
sabun: true,
|
||||
user_id: true,
|
||||
user_name: true,
|
||||
// ... 20개 필드
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
```typescript
|
||||
const result = await query<{
|
||||
sabun: string | null;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_name_eng: string | null;
|
||||
user_name_cn: string | null;
|
||||
dept_code: string | null;
|
||||
dept_name: string | null;
|
||||
position_code: string | null;
|
||||
position_name: string | null;
|
||||
email: string | null;
|
||||
tel: string | null;
|
||||
cell_phone: string | null;
|
||||
user_type: string | null;
|
||||
user_type_name: string | null;
|
||||
partner_objid: string | null;
|
||||
company_code: string | null;
|
||||
locale: string | null;
|
||||
photo: Buffer | null;
|
||||
}>(
|
||||
`SELECT
|
||||
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||
dept_code, dept_name, position_code, position_name,
|
||||
email, tel, cell_phone, user_type, user_type_name,
|
||||
partner_objid, company_code, locale, photo
|
||||
FROM user_info
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const userInfo = result.length > 0 ? result[0] : null;
|
||||
```
|
||||
|
||||
### Step 3: getUserInfo() 전환 (권한 정보)
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
```typescript
|
||||
const authInfo = await prisma.authority_sub_user.findMany({
|
||||
where: { user_id: userId },
|
||||
include: {
|
||||
authority_master: {
|
||||
select: { auth_name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const authNames = authInfo
|
||||
.filter((auth: any) => auth.authority_master?.auth_name)
|
||||
.map((auth: any) => auth.authority_master!.auth_name!)
|
||||
.join(",");
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
```typescript
|
||||
const authResult = await query<{ auth_name: string }>(
|
||||
`SELECT am.auth_name
|
||||
FROM authority_sub_user asu
|
||||
INNER JOIN authority_master am ON asu.auth_code = am.auth_code
|
||||
WHERE asu.user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const authNames = authResult.map(row => row.auth_name).join(",");
|
||||
```
|
||||
|
||||
### Step 4: getUserInfo() 전환 (회사 정보)
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
```typescript
|
||||
const companyInfo = await prisma.company_mng.findFirst({
|
||||
where: { company_code: userInfo.company_code || "ILSHIN" },
|
||||
select: { company_name: true },
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
```typescript
|
||||
const companyResult = await query<{ company_name: string }>(
|
||||
"SELECT company_name FROM company_mng WHERE company_code = $1",
|
||||
[userInfo.company_code || "ILSHIN"]
|
||||
);
|
||||
|
||||
const companyInfo = companyResult.length > 0 ? companyResult[0] : null;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 완전 전환된 AuthService 코드
|
||||
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
import { PersonBean, LoginResult, LoginLogData } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
* 로그인 비밀번호 검증 (Raw Query 전환)
|
||||
*/
|
||||
static async loginPwdCheck(
|
||||
userId: string,
|
||||
password: string
|
||||
): Promise<LoginResult> {
|
||||
try {
|
||||
// Raw Query로 사용자 비밀번호 조회
|
||||
const result = await query<{ user_password: string }>(
|
||||
"SELECT user_password FROM user_info WHERE user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
|
||||
const userInfo = result.length > 0 ? result[0] : null;
|
||||
|
||||
if (userInfo && userInfo.user_password) {
|
||||
const dbPassword = userInfo.user_password;
|
||||
|
||||
logger.info(`로그인 시도: ${userId}`);
|
||||
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
|
||||
|
||||
// 마스터 패스워드 체크
|
||||
if (password === "qlalfqjsgh11") {
|
||||
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||
return { loginResult: true };
|
||||
}
|
||||
|
||||
// 비밀번호 검증
|
||||
if (EncryptUtil.matches(password, dbPassword)) {
|
||||
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||
return { loginResult: true };
|
||||
} else {
|
||||
logger.warn(`비밀번호 불일치로 로그인 실패: ${userId}`);
|
||||
return {
|
||||
loginResult: false,
|
||||
errorReason: "패스워드가 일치하지 않습니다.",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
logger.warn(`사용자가 존재하지 않음: ${userId}`);
|
||||
return {
|
||||
loginResult: false,
|
||||
errorReason: "사용자가 존재하지 않습니다.",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 검증 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return {
|
||||
loginResult: false,
|
||||
errorReason: "로그인 처리 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 로그 기록 (이미 Raw Query 사용 - 유지)
|
||||
*/
|
||||
static async insertLoginAccessLog(logData: LoginLogData): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO LOGIN_ACCESS_LOG(
|
||||
LOG_TIME, SYSTEM_NAME, USER_ID, LOGIN_RESULT, ERROR_MESSAGE,
|
||||
REMOTE_ADDR, RECPTN_DT, RECPTN_RSLT_DTL, RECPTN_RSLT, RECPTN_RSLT_CD
|
||||
) VALUES (
|
||||
now(), $1, UPPER($2), $3, $4, $5, $6, $7, $8, $9
|
||||
)`,
|
||||
[
|
||||
logData.systemName,
|
||||
logData.userId,
|
||||
logData.loginResult,
|
||||
logData.errorMessage || null,
|
||||
logData.remoteAddr,
|
||||
logData.recptnDt || null,
|
||||
logData.recptnRsltDtl || null,
|
||||
logData.recptnRslt || null,
|
||||
logData.recptnRsltCd || null,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 로그 기록 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
// 로그 기록 실패는 로그인 프로세스를 중단하지 않음
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 정보 조회 (Raw Query 전환)
|
||||
*/
|
||||
static async getUserInfo(userId: string): Promise<PersonBean | null> {
|
||||
try {
|
||||
// 1. 사용자 기본 정보 조회
|
||||
const userResult = await query<{
|
||||
sabun: string | null;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_name_eng: string | null;
|
||||
user_name_cn: string | null;
|
||||
dept_code: string | null;
|
||||
dept_name: string | null;
|
||||
position_code: string | null;
|
||||
position_name: string | null;
|
||||
email: string | null;
|
||||
tel: string | null;
|
||||
cell_phone: string | null;
|
||||
user_type: string | null;
|
||||
user_type_name: string | null;
|
||||
partner_objid: string | null;
|
||||
company_code: string | null;
|
||||
locale: string | null;
|
||||
photo: Buffer | null;
|
||||
}>(
|
||||
`SELECT
|
||||
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||
dept_code, dept_name, position_code, position_name,
|
||||
email, tel, cell_phone, user_type, user_type_name,
|
||||
partner_objid, company_code, locale, photo
|
||||
FROM user_info
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const userInfo = userResult.length > 0 ? userResult[0] : null;
|
||||
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 권한 정보 조회 (JOIN으로 최적화)
|
||||
const authResult = await query<{ auth_name: string }>(
|
||||
`SELECT am.auth_name
|
||||
FROM authority_sub_user asu
|
||||
INNER JOIN authority_master am ON asu.auth_code = am.auth_code
|
||||
WHERE asu.user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const authNames = authResult.map(row => row.auth_name).join(",");
|
||||
|
||||
// 3. 회사 정보 조회
|
||||
const companyResult = await query<{ company_name: string }>(
|
||||
"SELECT company_name FROM company_mng WHERE company_code = $1",
|
||||
[userInfo.company_code || "ILSHIN"]
|
||||
);
|
||||
|
||||
const companyInfo = companyResult.length > 0 ? companyResult[0] : null;
|
||||
|
||||
// PersonBean 형태로 변환
|
||||
const personBean: PersonBean = {
|
||||
userId: userInfo.user_id,
|
||||
userName: userInfo.user_name || "",
|
||||
userNameEng: userInfo.user_name_eng || undefined,
|
||||
userNameCn: userInfo.user_name_cn || undefined,
|
||||
deptCode: userInfo.dept_code || undefined,
|
||||
deptName: userInfo.dept_name || undefined,
|
||||
positionCode: userInfo.position_code || undefined,
|
||||
positionName: userInfo.position_name || undefined,
|
||||
email: userInfo.email || undefined,
|
||||
tel: userInfo.tel || undefined,
|
||||
cellPhone: userInfo.cell_phone || undefined,
|
||||
userType: userInfo.user_type || undefined,
|
||||
userTypeName: userInfo.user_type_name || undefined,
|
||||
partnerObjid: userInfo.partner_objid || undefined,
|
||||
authName: authNames || undefined,
|
||||
companyCode: userInfo.company_code || "ILSHIN",
|
||||
photo: userInfo.photo
|
||||
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
|
||||
: undefined,
|
||||
locale: userInfo.locale || "KR",
|
||||
};
|
||||
|
||||
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
||||
return personBean;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰으로 사용자 정보 조회
|
||||
*/
|
||||
static async getUserInfoFromToken(token: string): Promise<PersonBean | null> {
|
||||
try {
|
||||
const userInfo = JwtUtils.verifyToken(token);
|
||||
return userInfo;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`토큰에서 사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 프로세스 전체 처리
|
||||
*/
|
||||
static async processLogin(
|
||||
userId: string,
|
||||
password: string,
|
||||
remoteAddr: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
userInfo?: PersonBean;
|
||||
token?: string;
|
||||
errorReason?: string;
|
||||
}> {
|
||||
try {
|
||||
// 1. 로그인 검증
|
||||
const loginResult = await this.loginPwdCheck(userId, password);
|
||||
|
||||
// 2. 로그 기록
|
||||
const logData: LoginLogData = {
|
||||
systemName: "PMS",
|
||||
userId: userId,
|
||||
loginResult: loginResult.loginResult,
|
||||
errorMessage: loginResult.errorReason,
|
||||
remoteAddr: remoteAddr,
|
||||
};
|
||||
|
||||
await this.insertLoginAccessLog(logData);
|
||||
|
||||
if (loginResult.loginResult) {
|
||||
// 3. 사용자 정보 조회
|
||||
const userInfo = await this.getUserInfo(userId);
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
errorReason: "사용자 정보를 조회할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 4. JWT 토큰 생성
|
||||
const token = JwtUtils.generateToken(userInfo);
|
||||
|
||||
logger.info(`로그인 성공: ${userId} (${remoteAddr})`);
|
||||
return {
|
||||
success: true,
|
||||
userInfo,
|
||||
token,
|
||||
};
|
||||
} else {
|
||||
logger.warn(
|
||||
`로그인 실패: ${userId} - ${loginResult.errorReason} (${remoteAddr})`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
errorReason: loginResult.errorReason,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그인 프로세스 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
errorReason: "로그인 처리 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 프로세스 처리
|
||||
*/
|
||||
static async processLogout(
|
||||
userId: string,
|
||||
remoteAddr: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 로그아웃 로그 기록
|
||||
const logData: LoginLogData = {
|
||||
systemName: "PMS",
|
||||
userId: userId,
|
||||
loginResult: false,
|
||||
errorMessage: "로그아웃",
|
||||
remoteAddr: remoteAddr,
|
||||
};
|
||||
|
||||
await this.insertLoginAccessLog(logData);
|
||||
logger.info(`로그아웃 완료: ${userId} (${remoteAddr})`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`로그아웃 처리 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트
|
||||
|
||||
```typescript
|
||||
// backend-node/src/tests/authService.test.ts
|
||||
import { AuthService } from "../services/authService";
|
||||
import { query } from "../database/db";
|
||||
|
||||
describe("AuthService Raw Query 전환 테스트", () => {
|
||||
describe("loginPwdCheck", () => {
|
||||
test("존재하는 사용자 로그인 성공", async () => {
|
||||
const result = await AuthService.loginPwdCheck("testuser", "testpass");
|
||||
expect(result.loginResult).toBe(true);
|
||||
});
|
||||
|
||||
test("존재하지 않는 사용자 로그인 실패", async () => {
|
||||
const result = await AuthService.loginPwdCheck("nonexistent", "password");
|
||||
expect(result.loginResult).toBe(false);
|
||||
expect(result.errorReason).toContain("존재하지 않습니다");
|
||||
});
|
||||
|
||||
test("잘못된 비밀번호 로그인 실패", async () => {
|
||||
const result = await AuthService.loginPwdCheck("testuser", "wrongpass");
|
||||
expect(result.loginResult).toBe(false);
|
||||
expect(result.errorReason).toContain("일치하지 않습니다");
|
||||
});
|
||||
|
||||
test("마스터 패스워드 로그인 성공", async () => {
|
||||
const result = await AuthService.loginPwdCheck("testuser", "qlalfqjsgh11");
|
||||
expect(result.loginResult).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserInfo", () => {
|
||||
test("사용자 정보 조회 성공", async () => {
|
||||
const userInfo = await AuthService.getUserInfo("testuser");
|
||||
expect(userInfo).not.toBeNull();
|
||||
expect(userInfo?.userId).toBe("testuser");
|
||||
expect(userInfo?.userName).toBeDefined();
|
||||
});
|
||||
|
||||
test("권한 정보 조회 성공", async () => {
|
||||
const userInfo = await AuthService.getUserInfo("testuser");
|
||||
expect(userInfo?.authName).toBeDefined();
|
||||
});
|
||||
|
||||
test("존재하지 않는 사용자 조회 실패", async () => {
|
||||
const userInfo = await AuthService.getUserInfo("nonexistent");
|
||||
expect(userInfo).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("processLogin", () => {
|
||||
test("전체 로그인 프로세스 성공", async () => {
|
||||
const result = await AuthService.processLogin(
|
||||
"testuser",
|
||||
"testpass",
|
||||
"127.0.0.1"
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.token).toBeDefined();
|
||||
expect(result.userInfo).toBeDefined();
|
||||
});
|
||||
|
||||
test("로그인 실패 시 토큰 없음", async () => {
|
||||
const result = await AuthService.processLogin(
|
||||
"testuser",
|
||||
"wrongpass",
|
||||
"127.0.0.1"
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.token).toBeUndefined();
|
||||
expect(result.errorReason).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertLoginAccessLog", () => {
|
||||
test("로그인 로그 기록 성공", async () => {
|
||||
await expect(
|
||||
AuthService.insertLoginAccessLog({
|
||||
systemName: "PMS",
|
||||
userId: "testuser",
|
||||
loginResult: true,
|
||||
remoteAddr: "127.0.0.1",
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 통합 테스트
|
||||
|
||||
```typescript
|
||||
// backend-node/src/tests/integration/auth.integration.test.ts
|
||||
import request from "supertest";
|
||||
import app from "../../app";
|
||||
|
||||
describe("인증 시스템 통합 테스트", () => {
|
||||
let authToken: string;
|
||||
|
||||
test("POST /api/auth/login - 로그인 성공", async () => {
|
||||
const response = await request(app)
|
||||
.post("/api/auth/login")
|
||||
.send({
|
||||
userId: "testuser",
|
||||
password: "testpass",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.token).toBeDefined();
|
||||
expect(response.body.userInfo).toBeDefined();
|
||||
|
||||
authToken = response.body.token;
|
||||
});
|
||||
|
||||
test("GET /api/auth/verify - 토큰 검증 성공", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/auth/verify")
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.valid).toBe(true);
|
||||
expect(response.body.userInfo).toBeDefined();
|
||||
});
|
||||
|
||||
test("GET /api/admin/menu - 인증된 사용자 메뉴 조회", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/admin/menu")
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
});
|
||||
|
||||
test("POST /api/auth/logout - 로그아웃 성공", async () => {
|
||||
await request(app)
|
||||
.post("/api/auth/logout")
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### AuthService 전환
|
||||
|
||||
- [ ] import 문 변경 (`prisma` → `query`)
|
||||
- [ ] `loginPwdCheck()` 메서드 전환
|
||||
- [ ] Prisma findUnique → Raw Query SELECT
|
||||
- [ ] 타입 정의 추가
|
||||
- [ ] 에러 처리 확인
|
||||
- [ ] `insertLoginAccessLog()` 메서드 확인
|
||||
- [ ] 이미 Raw Query 사용 중 (유지)
|
||||
- [ ] 파라미터 바인딩 확인
|
||||
- [ ] `getUserInfo()` 메서드 전환
|
||||
- [ ] 사용자 정보 조회 Raw Query 전환
|
||||
- [ ] 권한 정보 조회 Raw Query 전환 (JOIN 최적화)
|
||||
- [ ] 회사 정보 조회 Raw Query 전환
|
||||
- [ ] PersonBean 타입 변환 로직 유지
|
||||
- [ ] 모든 메서드 타입 안전성 확인
|
||||
- [ ] 단위 테스트 작성 및 통과
|
||||
|
||||
### AdminService 확인
|
||||
|
||||
- [ ] 현재 코드 확인 (이미 Raw Query 사용 중)
|
||||
- [ ] WITH RECURSIVE 쿼리 동작 확인
|
||||
- [ ] 다국어 번역 로직 확인
|
||||
|
||||
### AdminController 전환
|
||||
|
||||
- [ ] Prisma 사용 현황 파악 (28개 호출)
|
||||
- [ ] 각 API 엔드포인트별 전환 계획 수립
|
||||
- [ ] Raw Query로 전환
|
||||
- [ ] 통합 테스트 작성
|
||||
|
||||
### 통합 테스트
|
||||
|
||||
- [ ] 로그인 → 토큰 발급 테스트
|
||||
- [ ] 토큰 검증 → API 호출 테스트
|
||||
- [ ] 권한 확인 → 메뉴 조회 테스트
|
||||
- [ ] 로그아웃 테스트
|
||||
- [ ] 에러 케이스 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- ✅ AuthService의 모든 Prisma 호출 제거
|
||||
- ✅ AdminService Raw Query 사용 확인
|
||||
- ✅ AdminController Prisma 호출 제거
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
- ✅ 통합 테스트 통과
|
||||
- ✅ 로그인 → 인증 → API 호출 플로우 정상 동작
|
||||
- ✅ 성능 저하 없음 (기존 대비 ±10% 이내)
|
||||
- ✅ 에러 처리 및 로깅 정상 동작
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 문서
|
||||
|
||||
- [Phase 1 완료 가이드](backend-node/PHASE1_USAGE_GUIDE.md)
|
||||
- [DatabaseManager 사용법](backend-node/src/database/db.ts)
|
||||
- [QueryBuilder 사용법](backend-node/src/utils/queryBuilder.ts)
|
||||
- [전체 마이그레이션 계획](PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md)
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**예상 소요 시간**: 2-3일
|
||||
**담당자**: 백엔드 개발팀
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
# 🗂️ Phase 2.2: TableManagementService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
TableManagementService는 **33개의 Prisma 호출**이 있습니다. 대부분(약 26개)은 `$queryRaw`를 사용하고 있어 SQL은 이미 작성되어 있지만, **Prisma 클라이언트를 완전히 제거하려면 33개 모두를 `db.ts`의 `query` 함수로 교체**해야 합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ----------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/tableManagementService.ts` |
|
||||
| 파일 크기 | 3,178 라인 |
|
||||
| Prisma 호출 | 33개 ($queryRaw: 26개, ORM: 7개) |
|
||||
| **현재 진행률** | **0/33 (0%)** ⏳ **전환 필요** |
|
||||
| **전환 필요** | **33개 모두 전환 필요** (SQL은 이미 작성되어 있음) |
|
||||
| 복잡도 | 중간 (SQL 작성은 완료, `query()` 함수로 교체만 필요) |
|
||||
| 우선순위 | 🟡 중간 (Phase 2.2) |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ **33개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||
- 26개 `$queryRaw` → `query()` 또는 `queryOne()`
|
||||
- 7개 ORM 메서드 → `query()` (SQL 새로 작성)
|
||||
- 1개 `$transaction` → `transaction()`
|
||||
- ✅ 트랜잭션 처리 정상 동작 확인
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 1. `$queryRaw` / `$queryRawUnsafe` 사용 (26개)
|
||||
|
||||
**현재 상태**: SQL은 이미 작성되어 있음 ✅
|
||||
**전환 작업**: `prisma.$queryRaw` → `query()` 함수로 교체만 하면 됨
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
await prisma.$queryRaw`SELECT ...`;
|
||||
await prisma.$queryRawUnsafe(sqlString, ...params);
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
await query(`SELECT ...`);
|
||||
await query(sqlString, params);
|
||||
```
|
||||
|
||||
### 2. ORM 메서드 사용 (7개)
|
||||
|
||||
**현재 상태**: Prisma ORM 메서드 사용
|
||||
**전환 작업**: SQL 작성 필요
|
||||
|
||||
#### 1. table_labels 관리 (2개)
|
||||
|
||||
```typescript
|
||||
// Line 254: 테이블 라벨 UPSERT
|
||||
await prisma.table_labels.upsert({
|
||||
where: { table_name: tableName },
|
||||
update: {},
|
||||
create: { table_name, table_label, description }
|
||||
});
|
||||
|
||||
// Line 437: 테이블 라벨 조회
|
||||
await prisma.table_labels.findUnique({
|
||||
where: { table_name: tableName },
|
||||
select: { table_name, table_label, description, ... }
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. column_labels 관리 (5개)
|
||||
|
||||
```typescript
|
||||
// Line 323: 컬럼 라벨 UPSERT
|
||||
await prisma.column_labels.upsert({
|
||||
where: {
|
||||
table_name_column_name: {
|
||||
table_name: tableName,
|
||||
column_name: columnName
|
||||
}
|
||||
},
|
||||
update: { column_label, input_type, ... },
|
||||
create: { table_name, column_name, ... }
|
||||
});
|
||||
|
||||
// Line 481: 컬럼 라벨 조회
|
||||
await prisma.column_labels.findUnique({
|
||||
where: {
|
||||
table_name_column_name: {
|
||||
table_name: tableName,
|
||||
column_name: columnName
|
||||
}
|
||||
},
|
||||
select: { id, table_name, column_name, ... }
|
||||
});
|
||||
|
||||
// Line 567: 컬럼 존재 확인
|
||||
await prisma.column_labels.findFirst({
|
||||
where: { table_name, column_name }
|
||||
});
|
||||
|
||||
// Line 586: 컬럼 라벨 업데이트
|
||||
await prisma.column_labels.update({
|
||||
where: { id: existingColumn.id },
|
||||
data: { web_type, detail_settings, ... }
|
||||
});
|
||||
|
||||
// Line 610: 컬럼 라벨 생성
|
||||
await prisma.column_labels.create({
|
||||
data: { table_name, column_name, web_type, ... }
|
||||
});
|
||||
|
||||
// Line 1003: 파일 타입 컬럼 조회
|
||||
await prisma.column_labels.findMany({
|
||||
where: { table_name, web_type: 'file' },
|
||||
select: { column_name }
|
||||
});
|
||||
|
||||
// Line 1382: 컬럼 웹타입 정보 조회
|
||||
await prisma.column_labels.findFirst({
|
||||
where: { table_name, column_name },
|
||||
select: { web_type, code_category, ... }
|
||||
});
|
||||
|
||||
// Line 2690: 컬럼 라벨 UPSERT (복제)
|
||||
await prisma.column_labels.upsert({
|
||||
where: {
|
||||
table_name_column_name: { table_name, column_name }
|
||||
},
|
||||
update: { column_label, web_type, ... },
|
||||
create: { table_name, column_name, ... }
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. attach_file_info 관리 (2개)
|
||||
|
||||
```typescript
|
||||
// Line 914: 파일 정보 조회
|
||||
await prisma.attach_file_info.findMany({
|
||||
where: { target_objid, doc_type, status: 'ACTIVE' },
|
||||
select: { objid, real_file_name, file_size, ... },
|
||||
orderBy: { regdate: 'desc' }
|
||||
});
|
||||
|
||||
// Line 959: 파일 경로로 파일 정보 조회
|
||||
await prisma.attach_file_info.findFirst({
|
||||
where: { file_path, status: 'ACTIVE' },
|
||||
select: { objid, real_file_name, ... }
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. 트랜잭션 (1개)
|
||||
|
||||
```typescript
|
||||
// Line 391: 전체 컬럼 설정 일괄 업데이트
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await this.insertTableIfNotExists(tableName);
|
||||
for (const columnSetting of columnSettings) {
|
||||
await this.updateColumnSettings(tableName, columnName, columnSetting);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 예시
|
||||
|
||||
### 예시 1: table_labels UPSERT 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
await prisma.table_labels.upsert({
|
||||
where: { table_name: tableName },
|
||||
update: {},
|
||||
create: {
|
||||
table_name: tableName,
|
||||
table_label: tableName,
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
|
||||
await query(
|
||||
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (table_name) DO NOTHING`,
|
||||
[tableName, tableName, ""]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: column_labels UPSERT 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
await prisma.column_labels.upsert({
|
||||
where: {
|
||||
table_name_column_name: {
|
||||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
column_label: settings.columnLabel,
|
||||
input_type: settings.inputType,
|
||||
detail_settings: settings.detailSettings,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
create: {
|
||||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
column_label: settings.columnLabel,
|
||||
input_type: settings.inputType,
|
||||
detail_settings: settings.detailSettings,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
await query(
|
||||
`INSERT INTO column_labels (
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
code_category, code_value, reference_table, reference_column,
|
||||
display_column, display_order, is_visible, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
column_label = EXCLUDED.column_label,
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
code_category = EXCLUDED.code_category,
|
||||
code_value = EXCLUDED.code_value,
|
||||
reference_table = EXCLUDED.reference_table,
|
||||
reference_column = EXCLUDED.reference_column,
|
||||
display_column = EXCLUDED.display_column,
|
||||
display_order = EXCLUDED.display_order,
|
||||
is_visible = EXCLUDED.is_visible,
|
||||
updated_date = NOW()`,
|
||||
[
|
||||
tableName,
|
||||
columnName,
|
||||
settings.columnLabel,
|
||||
settings.inputType,
|
||||
settings.detailSettings,
|
||||
settings.codeCategory,
|
||||
settings.codeValue,
|
||||
settings.referenceTable,
|
||||
settings.referenceColumn,
|
||||
settings.displayColumn,
|
||||
settings.displayOrder || 0,
|
||||
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 트랜잭션 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await this.insertTableIfNotExists(tableName);
|
||||
for (const columnSetting of columnSettings) {
|
||||
await this.updateColumnSettings(tableName, columnName, columnSetting);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { transaction } from "../database/db";
|
||||
|
||||
await transaction(async (client) => {
|
||||
// 테이블 라벨 자동 추가
|
||||
await client.query(
|
||||
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (table_name) DO NOTHING`,
|
||||
[tableName, tableName, ""]
|
||||
);
|
||||
|
||||
// 각 컬럼 설정 업데이트
|
||||
for (const columnSetting of columnSettings) {
|
||||
const columnName = columnSetting.columnName;
|
||||
if (columnName) {
|
||||
await client.query(
|
||||
`INSERT INTO column_labels (...)
|
||||
VALUES (...)
|
||||
ON CONFLICT (table_name, column_name) DO UPDATE SET ...`,
|
||||
[...]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트 (10개)
|
||||
|
||||
```typescript
|
||||
describe("TableManagementService Raw Query 전환 테스트", () => {
|
||||
describe("insertTableIfNotExists", () => {
|
||||
test("테이블 라벨 UPSERT 성공", async () => { ... });
|
||||
test("중복 테이블 처리", async () => { ... });
|
||||
});
|
||||
|
||||
describe("updateColumnSettings", () => {
|
||||
test("컬럼 설정 UPSERT 성공", async () => { ... });
|
||||
test("기존 컬럼 업데이트", async () => { ... });
|
||||
});
|
||||
|
||||
describe("getTableLabels", () => {
|
||||
test("테이블 라벨 조회 성공", async () => { ... });
|
||||
});
|
||||
|
||||
describe("getColumnLabels", () => {
|
||||
test("컬럼 라벨 조회 성공", async () => { ... });
|
||||
});
|
||||
|
||||
describe("updateAllColumnSettings", () => {
|
||||
test("일괄 업데이트 성공 (트랜잭션)", async () => { ... });
|
||||
test("부분 실패 시 롤백", async () => { ... });
|
||||
});
|
||||
|
||||
describe("getFileInfoByColumnAndTarget", () => {
|
||||
test("파일 정보 조회 성공", async () => { ... });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 통합 테스트 (5개 시나리오)
|
||||
|
||||
```typescript
|
||||
describe("테이블 관리 통합 테스트", () => {
|
||||
test("테이블 라벨 생성 → 조회 → 수정", async () => { ... });
|
||||
test("컬럼 라벨 생성 → 조회 → 수정", async () => { ... });
|
||||
test("컬럼 일괄 설정 업데이트", async () => { ... });
|
||||
test("파일 정보 조회 및 보강", async () => { ... });
|
||||
test("트랜잭션 롤백 테스트", async () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 1단계: table_labels 전환 (2개 함수) ⏳ **진행 예정**
|
||||
|
||||
- [ ] `insertTableIfNotExists()` - UPSERT
|
||||
- [ ] `getTableLabels()` - 조회
|
||||
|
||||
### 2단계: column_labels 전환 (5개 함수) ⏳ **진행 예정**
|
||||
|
||||
- [ ] `updateColumnSettings()` - UPSERT
|
||||
- [ ] `getColumnLabels()` - 조회
|
||||
- [ ] `updateColumnWebType()` - findFirst + update/create
|
||||
- [ ] `getColumnWebTypeInfo()` - findFirst
|
||||
- [ ] `updateColumnLabel()` - UPSERT (복제)
|
||||
|
||||
### 3단계: attach_file_info 전환 (2개 함수) ⏳ **진행 예정**
|
||||
|
||||
- [ ] `getFileInfoByColumnAndTarget()` - findMany
|
||||
- [ ] `getFileInfoByPath()` - findFirst
|
||||
|
||||
### 4단계: 트랜잭션 전환 (1개 함수) ⏳ **진행 예정**
|
||||
|
||||
- [ ] `updateAllColumnSettings()` - 트랜잭션
|
||||
|
||||
### 5단계: 테스트 & 검증 ⏳ **진행 예정**
|
||||
|
||||
- [ ] 단위 테스트 작성 (10개)
|
||||
- [ ] 통합 테스트 작성 (5개 시나리오)
|
||||
- [ ] Prisma import 완전 제거 확인
|
||||
- [ ] 성능 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [ ] **33개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||
- [ ] 26개 `$queryRaw` → `query()` 함수로 교체
|
||||
- [ ] 7개 ORM 메서드 → `query()` 함수로 전환 (SQL 작성)
|
||||
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||
- [ ] **트랜잭션 정상 동작 확인**
|
||||
- [ ] **에러 처리 및 롤백 정상 동작**
|
||||
- [ ] **모든 단위 테스트 통과 (10개)**
|
||||
- [ ] **모든 통합 테스트 작성 완료 (5개 시나리오)**
|
||||
- [ ] **`import prisma` 완전 제거 및 `import { query, transaction } from "../database/db"` 사용**
|
||||
- [ ] **성능 저하 없음 (기존 대비 ±10% 이내)**
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### SQL은 이미 대부분 작성되어 있음
|
||||
|
||||
이 서비스는 이미 79%가 `$queryRaw`를 사용하고 있어, **SQL 작성은 완료**되었습니다:
|
||||
|
||||
- ✅ `information_schema` 조회: SQL 작성 완료 (`$queryRaw` 사용 중)
|
||||
- ✅ 동적 테이블 쿼리: SQL 작성 완료 (`$queryRawUnsafe` 사용 중)
|
||||
- ✅ DDL 실행: SQL 작성 완료 (`$executeRaw` 사용 중)
|
||||
- ⏳ **전환 작업**: `prisma.$queryRaw` → `query()` 함수로 **단순 교체만 필요**
|
||||
- ⏳ CRUD 작업: 7개만 SQL 새로 작성 필요
|
||||
|
||||
### UPSERT 패턴 중요
|
||||
|
||||
대부분의 전환이 UPSERT 패턴이므로 PostgreSQL의 `ON CONFLICT` 구문을 활용합니다.
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**예상 소요 시간**: 1-1.5일 (SQL은 79% 작성 완료, 함수 교체 작업 필요)
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟡 중간 (Phase 2.2)
|
||||
**상태**: ⏳ **진행 예정**
|
||||
**특이사항**: SQL은 대부분 작성되어 있어 `prisma.$queryRaw` → `query()` 단순 교체 작업이 주요 작업
|
||||
|
|
@ -1,736 +0,0 @@
|
|||
# 📊 Phase 2.3: DataflowService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DataflowService는 **31개의 Prisma 호출**이 있는 핵심 서비스입니다. 테이블 간 관계 관리, 데이터플로우 다이어그램, 데이터 연결 브리지 등 복잡한 기능을 포함합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ---------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/dataflowService.ts` |
|
||||
| 파일 크기 | 1,170+ 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **31/31 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 매우 높음 (트랜잭션 + 복잡한 관계 관리) |
|
||||
| 우선순위 | 🔴 최우선 (Phase 2.3) |
|
||||
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ 31개 Prisma 호출을 모두 Raw Query로 전환
|
||||
- ✅ 트랜잭션 처리 정상 동작 확인
|
||||
- ✅ 에러 처리 및 롤백 정상 동작
|
||||
- ✅ 모든 단위 테스트 통과 (20개 이상)
|
||||
- ✅ 통합 테스트 작성 완료
|
||||
- ✅ Prisma import 완전 제거
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 1. 테이블 관계 관리 (Table Relationships) - 22개
|
||||
|
||||
#### 1.1 관계 생성 (3개)
|
||||
|
||||
```typescript
|
||||
// Line 48: 최대 diagram_id 조회
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: { company_code },
|
||||
orderBy: { diagram_id: 'desc' }
|
||||
});
|
||||
|
||||
// Line 64: 중복 관계 확인
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: { diagram_id, source_table, target_table, relationship_type }
|
||||
});
|
||||
|
||||
// Line 83: 새 관계 생성
|
||||
await prisma.table_relationships.create({
|
||||
data: { diagram_id, source_table, target_table, ... }
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.2 관계 조회 (6개)
|
||||
|
||||
```typescript
|
||||
// Line 128: 관계 목록 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: whereCondition,
|
||||
orderBy: { created_at: 'desc' }
|
||||
});
|
||||
|
||||
// Line 164: 단일 관계 조회
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: whereCondition
|
||||
});
|
||||
|
||||
// Line 287: 회사별 관계 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: { company_code, is_active: 'Y' },
|
||||
orderBy: { diagram_id: 'asc' }
|
||||
});
|
||||
|
||||
// Line 326: 테이블별 관계 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: whereCondition,
|
||||
orderBy: { relationship_type: 'asc' }
|
||||
});
|
||||
|
||||
// Line 784: diagram_id별 관계 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: whereCondition,
|
||||
select: { diagram_id, diagram_name, source_table, ... }
|
||||
});
|
||||
|
||||
// Line 883: 회사 코드로 전체 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: { company_code, is_active: 'Y' }
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.3 통계 조회 (3개)
|
||||
|
||||
```typescript
|
||||
// Line 362: 전체 관계 수
|
||||
await prisma.table_relationships.count({
|
||||
where: whereCondition,
|
||||
});
|
||||
|
||||
// Line 367: 관계 타입별 통계
|
||||
await prisma.table_relationships.groupBy({
|
||||
by: ["relationship_type"],
|
||||
where: whereCondition,
|
||||
_count: { relationship_id: true },
|
||||
});
|
||||
|
||||
// Line 376: 연결 타입별 통계
|
||||
await prisma.table_relationships.groupBy({
|
||||
by: ["connection_type"],
|
||||
where: whereCondition,
|
||||
_count: { relationship_id: true },
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.4 관계 수정/삭제 (5개)
|
||||
|
||||
```typescript
|
||||
// Line 209: 관계 수정
|
||||
await prisma.table_relationships.update({
|
||||
where: { relationship_id },
|
||||
data: { source_table, target_table, ... }
|
||||
});
|
||||
|
||||
// Line 248: 소프트 삭제
|
||||
await prisma.table_relationships.update({
|
||||
where: { relationship_id },
|
||||
data: { is_active: 'N', updated_at: new Date() }
|
||||
});
|
||||
|
||||
// Line 936: 중복 diagram_name 확인
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: { company_code, diagram_name, is_active: 'Y' }
|
||||
});
|
||||
|
||||
// Line 953: 최대 diagram_id 조회 (복사용)
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: { company_code },
|
||||
orderBy: { diagram_id: 'desc' }
|
||||
});
|
||||
|
||||
// Line 1015: 관계도 완전 삭제
|
||||
await prisma.table_relationships.deleteMany({
|
||||
where: { company_code, diagram_id, is_active: 'Y' }
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.5 복잡한 조회 (5개)
|
||||
|
||||
```typescript
|
||||
// Line 919: 원본 관계도 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: { company_code, diagram_id: sourceDiagramId, is_active: "Y" },
|
||||
});
|
||||
|
||||
// Line 1046: diagram_id로 모든 관계 조회
|
||||
await prisma.table_relationships.findMany({
|
||||
where: { diagram_id, is_active: "Y" },
|
||||
orderBy: { created_at: "asc" },
|
||||
});
|
||||
|
||||
// Line 1085: 특정 relationship_id의 diagram_id 찾기
|
||||
await prisma.table_relationships.findFirst({
|
||||
where: { relationship_id, company_code },
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 데이터 연결 브리지 (Data Relationship Bridge) - 8개
|
||||
|
||||
#### 2.1 브리지 생성/수정 (4개)
|
||||
|
||||
```typescript
|
||||
// Line 425: 브리지 생성
|
||||
await prisma.data_relationship_bridge.create({
|
||||
data: {
|
||||
relationship_id,
|
||||
source_record_id,
|
||||
target_record_id,
|
||||
...
|
||||
}
|
||||
});
|
||||
|
||||
// Line 554: 브리지 수정
|
||||
await prisma.data_relationship_bridge.update({
|
||||
where: whereCondition,
|
||||
data: { target_record_id, ... }
|
||||
});
|
||||
|
||||
// Line 595: 브리지 소프트 삭제
|
||||
await prisma.data_relationship_bridge.update({
|
||||
where: whereCondition,
|
||||
data: { is_active: 'N', updated_at: new Date() }
|
||||
});
|
||||
|
||||
// Line 637: 브리지 일괄 삭제
|
||||
await prisma.data_relationship_bridge.updateMany({
|
||||
where: whereCondition,
|
||||
data: { is_active: 'N', updated_at: new Date() }
|
||||
});
|
||||
```
|
||||
|
||||
#### 2.2 브리지 조회 (4개)
|
||||
|
||||
```typescript
|
||||
// Line 471: relationship_id로 브리지 조회
|
||||
await prisma.data_relationship_bridge.findMany({
|
||||
where: whereCondition,
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
|
||||
// Line 512: 레코드별 브리지 조회
|
||||
await prisma.data_relationship_bridge.findMany({
|
||||
where: whereCondition,
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Raw Query 사용 (이미 있음) - 1개
|
||||
|
||||
```typescript
|
||||
// Line 673: 테이블 존재 확인
|
||||
await prisma.$queryRaw`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ${tableName}
|
||||
`;
|
||||
```
|
||||
|
||||
### 4. 트랜잭션 사용 - 1개
|
||||
|
||||
```typescript
|
||||
// Line 968: 관계도 복사 트랜잭션
|
||||
await prisma.$transaction(
|
||||
originalRelationships.map((rel) =>
|
||||
prisma.table_relationships.create({
|
||||
data: {
|
||||
diagram_id: newDiagramId,
|
||||
company_code: companyCode,
|
||||
source_table: rel.source_table,
|
||||
target_table: rel.target_table,
|
||||
...
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 전환 전략
|
||||
|
||||
### 전략 1: 단계적 전환
|
||||
|
||||
1. **1단계**: 단순 CRUD 전환 (findFirst, findMany, create, update, delete)
|
||||
2. **2단계**: 복잡한 조회 전환 (groupBy, count, 조건부 조회)
|
||||
3. **3단계**: 트랜잭션 전환
|
||||
4. **4단계**: Raw Query 개선
|
||||
|
||||
### 전략 2: 함수별 전환 우선순위
|
||||
|
||||
#### 🔴 최우선 (기본 CRUD)
|
||||
|
||||
- `createRelationship()` - Line 83
|
||||
- `getRelationships()` - Line 128
|
||||
- `getRelationshipById()` - Line 164
|
||||
- `updateRelationship()` - Line 209
|
||||
- `deleteRelationship()` - Line 248
|
||||
|
||||
#### 🟡 2순위 (브리지 관리)
|
||||
|
||||
- `createDataLink()` - Line 425
|
||||
- `getLinkedData()` - Line 471
|
||||
- `getLinkedDataByRecord()` - Line 512
|
||||
- `updateDataLink()` - Line 554
|
||||
- `deleteDataLink()` - Line 595
|
||||
|
||||
#### 🟢 3순위 (통계 & 조회)
|
||||
|
||||
- `getRelationshipStats()` - Line 362-376
|
||||
- `getAllRelationshipsByCompany()` - Line 287
|
||||
- `getRelationshipsByTable()` - Line 326
|
||||
- `getDiagrams()` - Line 784
|
||||
|
||||
#### 🔵 4순위 (복잡한 기능)
|
||||
|
||||
- `copyDiagram()` - Line 968 (트랜잭션)
|
||||
- `deleteDiagram()` - Line 1015
|
||||
- `getRelationshipsForDiagram()` - Line 1046
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 예시
|
||||
|
||||
### 예시 1: createRelationship() 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
// Line 48: 최대 diagram_id 조회
|
||||
const maxDiagramId = await prisma.table_relationships.findFirst({
|
||||
where: { company_code: data.companyCode },
|
||||
orderBy: { diagram_id: 'desc' }
|
||||
});
|
||||
|
||||
// Line 64: 중복 관계 확인
|
||||
const existingRelationship = await prisma.table_relationships.findFirst({
|
||||
where: {
|
||||
diagram_id: diagramId,
|
||||
source_table: data.sourceTable,
|
||||
target_table: data.targetTable,
|
||||
relationship_type: data.relationshipType
|
||||
}
|
||||
});
|
||||
|
||||
// Line 83: 새 관계 생성
|
||||
const relationship = await prisma.table_relationships.create({
|
||||
data: {
|
||||
diagram_id: diagramId,
|
||||
company_code: data.companyCode,
|
||||
diagram_name: data.diagramName,
|
||||
source_table: data.sourceTable,
|
||||
target_table: data.targetTable,
|
||||
relationship_type: data.relationshipType,
|
||||
...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
|
||||
// 최대 diagram_id 조회
|
||||
const maxDiagramResult = await query<{ diagram_id: number }>(
|
||||
`SELECT diagram_id FROM table_relationships
|
||||
WHERE company_code = $1
|
||||
ORDER BY diagram_id DESC
|
||||
LIMIT 1`,
|
||||
[data.companyCode]
|
||||
);
|
||||
|
||||
const diagramId =
|
||||
data.diagramId ||
|
||||
(maxDiagramResult.length > 0 ? maxDiagramResult[0].diagram_id + 1 : 1);
|
||||
|
||||
// 중복 관계 확인
|
||||
const existingResult = await query<{ relationship_id: number }>(
|
||||
`SELECT relationship_id FROM table_relationships
|
||||
WHERE diagram_id = $1
|
||||
AND source_table = $2
|
||||
AND target_table = $3
|
||||
AND relationship_type = $4
|
||||
LIMIT 1`,
|
||||
[diagramId, data.sourceTable, data.targetTable, data.relationshipType]
|
||||
);
|
||||
|
||||
if (existingResult.length > 0) {
|
||||
throw new Error("이미 존재하는 관계입니다.");
|
||||
}
|
||||
|
||||
// 새 관계 생성
|
||||
const [relationship] = await query<TableRelationship>(
|
||||
`INSERT INTO table_relationships (
|
||||
diagram_id, company_code, diagram_name, source_table, target_table,
|
||||
relationship_type, connection_type, source_column, target_column,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
diagramId,
|
||||
data.companyCode,
|
||||
data.diagramName,
|
||||
data.sourceTable,
|
||||
data.targetTable,
|
||||
data.relationshipType,
|
||||
data.connectionType,
|
||||
data.sourceColumn,
|
||||
data.targetColumn,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: getRelationshipStats() 전환 (통계 조회)
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
// Line 362: 전체 관계 수
|
||||
const totalCount = await prisma.table_relationships.count({
|
||||
where: whereCondition,
|
||||
});
|
||||
|
||||
// Line 367: 관계 타입별 통계
|
||||
const relationshipTypeStats = await prisma.table_relationships.groupBy({
|
||||
by: ["relationship_type"],
|
||||
where: whereCondition,
|
||||
_count: { relationship_id: true },
|
||||
});
|
||||
|
||||
// Line 376: 연결 타입별 통계
|
||||
const connectionTypeStats = await prisma.table_relationships.groupBy({
|
||||
by: ["connection_type"],
|
||||
where: whereCondition,
|
||||
_count: { relationship_id: true },
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
// WHERE 조건 동적 생성
|
||||
const whereParams: any[] = [];
|
||||
let whereSQL = "";
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode) {
|
||||
whereSQL += `WHERE company_code = $${paramIndex}`;
|
||||
whereParams.push(companyCode);
|
||||
paramIndex++;
|
||||
|
||||
if (isActive !== undefined) {
|
||||
whereSQL += ` AND is_active = $${paramIndex}`;
|
||||
whereParams.push(isActive ? "Y" : "N");
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 관계 수
|
||||
const [totalResult] = await query<{ count: number }>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM table_relationships ${whereSQL}`,
|
||||
whereParams
|
||||
);
|
||||
|
||||
const totalCount = totalResult?.count || 0;
|
||||
|
||||
// 관계 타입별 통계
|
||||
const relationshipTypeStats = await query<{
|
||||
relationship_type: string;
|
||||
count: number;
|
||||
}>(
|
||||
`SELECT relationship_type, COUNT(*) as count
|
||||
FROM table_relationships ${whereSQL}
|
||||
GROUP BY relationship_type
|
||||
ORDER BY count DESC`,
|
||||
whereParams
|
||||
);
|
||||
|
||||
// 연결 타입별 통계
|
||||
const connectionTypeStats = await query<{
|
||||
connection_type: string;
|
||||
count: number;
|
||||
}>(
|
||||
`SELECT connection_type, COUNT(*) as count
|
||||
FROM table_relationships ${whereSQL}
|
||||
GROUP BY connection_type
|
||||
ORDER BY count DESC`,
|
||||
whereParams
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: copyDiagram() 트랜잭션 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
// Line 968: 트랜잭션으로 모든 관계 복사
|
||||
const copiedRelationships = await prisma.$transaction(
|
||||
originalRelationships.map((rel) =>
|
||||
prisma.table_relationships.create({
|
||||
data: {
|
||||
diagram_id: newDiagramId,
|
||||
company_code: companyCode,
|
||||
diagram_name: newDiagramName,
|
||||
source_table: rel.source_table,
|
||||
target_table: rel.target_table,
|
||||
...
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { transaction } from "../database/db";
|
||||
|
||||
const copiedRelationships = await transaction(async (client) => {
|
||||
const results: TableRelationship[] = [];
|
||||
|
||||
for (const rel of originalRelationships) {
|
||||
const [copiedRel] = await client.query<TableRelationship>(
|
||||
`INSERT INTO table_relationships (
|
||||
diagram_id, company_code, diagram_name, source_table, target_table,
|
||||
relationship_type, connection_type, source_column, target_column,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
newDiagramId,
|
||||
companyCode,
|
||||
newDiagramName,
|
||||
rel.source_table,
|
||||
rel.target_table,
|
||||
rel.relationship_type,
|
||||
rel.connection_type,
|
||||
rel.source_column,
|
||||
rel.target_column,
|
||||
]
|
||||
);
|
||||
|
||||
results.push(copiedRel);
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트 (20개 이상)
|
||||
|
||||
```typescript
|
||||
describe('DataflowService Raw Query 전환 테스트', () => {
|
||||
describe('createRelationship', () => {
|
||||
test('관계 생성 성공', async () => { ... });
|
||||
test('중복 관계 에러', async () => { ... });
|
||||
test('diagram_id 자동 생성', async () => { ... });
|
||||
});
|
||||
|
||||
describe('getRelationships', () => {
|
||||
test('전체 관계 조회 성공', async () => { ... });
|
||||
test('회사별 필터링', async () => { ... });
|
||||
test('diagram_id별 필터링', async () => { ... });
|
||||
});
|
||||
|
||||
describe('getRelationshipStats', () => {
|
||||
test('통계 조회 성공', async () => { ... });
|
||||
test('관계 타입별 그룹화', async () => { ... });
|
||||
test('연결 타입별 그룹화', async () => { ... });
|
||||
});
|
||||
|
||||
describe('copyDiagram', () => {
|
||||
test('관계도 복사 성공 (트랜잭션)', async () => { ... });
|
||||
test('diagram_name 중복 에러', async () => { ... });
|
||||
});
|
||||
|
||||
describe('createDataLink', () => {
|
||||
test('데이터 연결 생성 성공', async () => { ... });
|
||||
test('브리지 레코드 저장', async () => { ... });
|
||||
});
|
||||
|
||||
describe('getLinkedData', () => {
|
||||
test('연결된 데이터 조회', async () => { ... });
|
||||
test('relationship_id별 필터링', async () => { ... });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 통합 테스트 (7개 시나리오)
|
||||
|
||||
```typescript
|
||||
describe('Dataflow 관리 통합 테스트', () => {
|
||||
test('관계 생명주기 (생성 → 조회 → 수정 → 삭제)', async () => { ... });
|
||||
test('관계도 복사 및 검증', async () => { ... });
|
||||
test('데이터 연결 브리지 생성 및 조회', async () => { ... });
|
||||
test('통계 정보 조회', async () => { ... });
|
||||
test('테이블별 관계 조회', async () => { ... });
|
||||
test('diagram_id별 관계 조회', async () => { ... });
|
||||
test('관계도 완전 삭제', async () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 1단계: 기본 CRUD (8개 함수) ✅ **완료**
|
||||
|
||||
- [x] `createTableRelationship()` - 관계 생성
|
||||
- [x] `getTableRelationships()` - 관계 목록 조회
|
||||
- [x] `getTableRelationship()` - 단일 관계 조회
|
||||
- [x] `updateTableRelationship()` - 관계 수정
|
||||
- [x] `deleteTableRelationship()` - 관계 삭제 (소프트)
|
||||
- [x] `getRelationshipsByTable()` - 테이블별 조회
|
||||
- [x] `getRelationshipsByConnectionType()` - 연결타입별 조회
|
||||
- [x] `getDataFlowDiagrams()` - diagram_id별 그룹 조회
|
||||
|
||||
### 2단계: 브리지 관리 (6개 함수) ✅ **완료**
|
||||
|
||||
- [x] `createDataLink()` - 데이터 연결 생성
|
||||
- [x] `getLinkedDataByRelationship()` - 관계별 연결 데이터 조회
|
||||
- [x] `getLinkedDataByTable()` - 테이블별 연결 데이터 조회
|
||||
- [x] `updateDataLink()` - 연결 수정
|
||||
- [x] `deleteDataLink()` - 연결 삭제 (소프트)
|
||||
- [x] `deleteAllLinkedDataByRelationship()` - 관계별 모든 연결 삭제
|
||||
|
||||
### 3단계: 통계 & 복잡한 조회 (4개 함수) ✅ **완료**
|
||||
|
||||
- [x] `getRelationshipStats()` - 통계 조회
|
||||
- [x] count 쿼리 전환
|
||||
- [x] groupBy 쿼리 전환 (관계 타입별)
|
||||
- [x] groupBy 쿼리 전환 (연결 타입별)
|
||||
- [x] `getTableData()` - 테이블 데이터 조회 (페이징)
|
||||
- [x] `getDiagramRelationships()` - 관계도 관계 조회
|
||||
- [x] `getDiagramRelationshipsByDiagramId()` - diagram_id별 관계 조회
|
||||
|
||||
### 4단계: 복잡한 기능 (3개 함수) ✅ **완료**
|
||||
|
||||
- [x] `copyDiagram()` - 관계도 복사 (트랜잭션)
|
||||
- [x] `deleteDiagram()` - 관계도 완전 삭제
|
||||
- [x] `getDiagramRelationshipsByRelationshipId()` - relationship_id로 조회
|
||||
|
||||
### 5단계: 테스트 & 검증 ⏳ **진행 필요**
|
||||
|
||||
- [ ] 단위 테스트 작성 (20개 이상)
|
||||
- createTableRelationship, updateTableRelationship, deleteTableRelationship
|
||||
- getTableRelationships, getTableRelationship
|
||||
- createDataLink, getLinkedDataByRelationship
|
||||
- getRelationshipStats
|
||||
- copyDiagram
|
||||
- [ ] 통합 테스트 작성 (7개 시나리오)
|
||||
- 관계 생명주기 테스트
|
||||
- 관계도 복사 테스트
|
||||
- 데이터 브리지 테스트
|
||||
- 통계 조회 테스트
|
||||
- [x] Prisma import 완전 제거 확인
|
||||
- [ ] 성능 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [x] **31개 Prisma 호출 모두 Raw Query로 전환 완료** ✅
|
||||
- [x] **모든 TypeScript 컴파일 오류 해결** ✅
|
||||
- [x] **트랜잭션 정상 동작 확인** ✅
|
||||
- [x] **에러 처리 및 롤백 정상 동작** ✅
|
||||
- [ ] **모든 단위 테스트 통과 (20개 이상)** ⏳
|
||||
- [ ] **모든 통합 테스트 작성 완료 (7개 시나리오)** ⏳
|
||||
- [x] **Prisma import 완전 제거** ✅
|
||||
- [ ] **성능 저하 없음 (기존 대비 ±10% 이내)** ⏳
|
||||
|
||||
---
|
||||
|
||||
## 🎯 주요 기술적 도전 과제
|
||||
|
||||
### 1. groupBy 쿼리 전환
|
||||
|
||||
**문제**: Prisma의 `groupBy`를 Raw Query로 전환
|
||||
**해결**: PostgreSQL의 `GROUP BY` 및 집계 함수 사용
|
||||
|
||||
```sql
|
||||
SELECT relationship_type, COUNT(*) as count
|
||||
FROM table_relationships
|
||||
WHERE company_code = $1 AND is_active = 'Y'
|
||||
GROUP BY relationship_type
|
||||
ORDER BY count DESC
|
||||
```
|
||||
|
||||
### 2. 트랜잭션 배열 처리
|
||||
|
||||
**문제**: Prisma의 `$transaction([...])` 배열 방식을 Raw Query로 전환
|
||||
**해결**: `transaction` 함수 내에서 순차 실행
|
||||
|
||||
```typescript
|
||||
await transaction(async (client) => {
|
||||
const results = [];
|
||||
for (const item of items) {
|
||||
const result = await client.query(...);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 동적 WHERE 조건 생성
|
||||
|
||||
**문제**: 다양한 필터 조건을 동적으로 구성
|
||||
**해결**: 조건부 파라미터 인덱스 관리
|
||||
|
||||
```typescript
|
||||
const whereParams: any[] = [];
|
||||
const whereConditions: string[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode) {
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
whereParams.push(companyCode);
|
||||
}
|
||||
|
||||
if (diagramId) {
|
||||
whereConditions.push(`diagram_id = $${paramIndex++}`);
|
||||
whereParams.push(diagramId);
|
||||
}
|
||||
|
||||
const whereSQL =
|
||||
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 전환 완료 요약
|
||||
|
||||
### ✅ 성공적으로 전환된 항목
|
||||
|
||||
1. **기본 CRUD (8개)**: 모든 테이블 관계 CRUD 작업을 Raw Query로 전환
|
||||
2. **브리지 관리 (6개)**: 데이터 연결 브리지의 모든 작업 전환
|
||||
3. **통계 & 조회 (4개)**: COUNT, GROUP BY 등 복잡한 통계 쿼리 전환
|
||||
4. **복잡한 기능 (3개)**: 트랜잭션 기반 관계도 복사 등 고급 기능 전환
|
||||
|
||||
### 🔧 주요 기술적 해결 사항
|
||||
|
||||
1. **트랜잭션 처리**: `transaction()` 함수 내에서 `client.query().rows` 사용
|
||||
2. **동적 WHERE 조건**: 파라미터 인덱스를 동적으로 관리하여 유연한 쿼리 생성
|
||||
3. **GROUP BY 전환**: Prisma의 `groupBy`를 PostgreSQL의 네이티브 GROUP BY로 전환
|
||||
4. **타입 안전성**: 모든 쿼리 결과에 TypeScript 타입 지정
|
||||
|
||||
### 📈 다음 단계
|
||||
|
||||
- [ ] 단위 테스트 작성 및 실행
|
||||
- [ ] 통합 테스트 시나리오 구현
|
||||
- [ ] 성능 벤치마크 테스트
|
||||
- [ ] 프로덕션 배포 준비
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**완료일**: 2025-10-01
|
||||
**소요 시간**: 1일
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🔴 최우선 (Phase 2.3)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
# 📝 Phase 2.4: DynamicFormService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DynamicFormService는 **13개의 Prisma 호출**이 있습니다. 대부분(약 11개)은 `$queryRaw`를 사용하고 있어 SQL은 이미 작성되어 있지만, **Prisma 클라이언트를 완전히 제거하려면 13개 모두를 `db.ts`의 `query` 함수로 교체**해야 합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/dynamicFormService.ts` |
|
||||
| 파일 크기 | 1,213 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **13/13 (100%)** ✅ **완료** |
|
||||
| **전환 상태** | **Raw Query로 전환 완료** |
|
||||
| 복잡도 | 낮음 (SQL 작성 완료 → `query()` 함수로 교체 완료) |
|
||||
| 우선순위 | 🟢 낮음 (Phase 2.4) |
|
||||
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ **13개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||
- 11개 `$queryRaw` → `query()` 함수로 교체
|
||||
- 2개 ORM 메서드 → `query()` (SQL 새로 작성)
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 1. `$queryRaw` / `$queryRawUnsafe` 사용 (11개)
|
||||
|
||||
**현재 상태**: SQL은 이미 작성되어 있음 ✅
|
||||
**전환 작업**: `prisma.$queryRaw` → `query()` 함수로 교체만 하면 됨
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
await prisma.$queryRaw<Array<{ column_name; data_type }>>`...`;
|
||||
await prisma.$queryRawUnsafe(upsertQuery, ...values);
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
await query<Array<{ column_name: string; data_type: string }>>(`...`);
|
||||
await query(upsertQuery, values);
|
||||
```
|
||||
|
||||
### 2. ORM 메서드 사용 (2개)
|
||||
|
||||
**현재 상태**: Prisma ORM 메서드 사용
|
||||
**전환 작업**: SQL 작성 필요
|
||||
|
||||
#### 1. dynamic_form_data 조회 (1개)
|
||||
|
||||
```typescript
|
||||
// Line 867: 폼 데이터 조회
|
||||
const result = await prisma.dynamic_form_data.findUnique({
|
||||
where: { id },
|
||||
select: { data: true },
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. screen_layouts 조회 (1개)
|
||||
|
||||
```typescript
|
||||
// Line 1101: 화면 레이아웃 조회
|
||||
const screenLayouts = await prisma.screen_layouts.findMany({
|
||||
where: {
|
||||
screen_id: screenId,
|
||||
component_type: "widget",
|
||||
},
|
||||
select: {
|
||||
component_id: true,
|
||||
properties: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 예시
|
||||
|
||||
### 예시 1: dynamic_form_data 조회 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
const result = await prisma.dynamic_form_data.findUnique({
|
||||
where: { id },
|
||||
select: { data: true },
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { queryOne } from "../database/db";
|
||||
|
||||
const result = await queryOne<{ data: any }>(
|
||||
`SELECT data FROM dynamic_form_data WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: screen_layouts 조회 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
const screenLayouts = await prisma.screen_layouts.findMany({
|
||||
where: {
|
||||
screen_id: screenId,
|
||||
component_type: "widget",
|
||||
},
|
||||
select: {
|
||||
component_id: true,
|
||||
properties: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
|
||||
const screenLayouts = await query<{
|
||||
component_id: string;
|
||||
properties: any;
|
||||
}>(
|
||||
`SELECT component_id, properties
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1 AND component_type = $2`,
|
||||
[screenId, "widget"]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트 (5개)
|
||||
|
||||
```typescript
|
||||
describe("DynamicFormService Raw Query 전환 테스트", () => {
|
||||
describe("getFormDataById", () => {
|
||||
test("폼 데이터 조회 성공", async () => { ... });
|
||||
test("존재하지 않는 데이터", async () => { ... });
|
||||
});
|
||||
|
||||
describe("getScreenLayoutsForControl", () => {
|
||||
test("화면 레이아웃 조회 성공", async () => { ... });
|
||||
test("widget 타입만 필터링", async () => { ... });
|
||||
test("빈 결과 처리", async () => { ... });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 통합 테스트 (3개 시나리오)
|
||||
|
||||
```typescript
|
||||
describe("동적 폼 통합 테스트", () => {
|
||||
test("폼 데이터 UPSERT → 조회", async () => { ... });
|
||||
test("폼 데이터 업데이트 → 조회", async () => { ... });
|
||||
test("화면 레이아웃 조회 → 제어 설정 확인", async () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 전환 완료 내역
|
||||
|
||||
### ✅ 전환된 함수들 (13개 Raw Query 호출)
|
||||
|
||||
1. **getTableColumnInfo()** - 컬럼 정보 조회
|
||||
2. **getPrimaryKeyColumns()** - 기본 키 조회
|
||||
3. **getNotNullColumns()** - NOT NULL 컬럼 조회
|
||||
4. **upsertFormData()** - UPSERT 실행
|
||||
5. **partialUpdateFormData()** - 부분 업데이트
|
||||
6. **updateFormData()** - 전체 업데이트
|
||||
7. **deleteFormData()** - 데이터 삭제
|
||||
8. **getFormDataById()** - 폼 데이터 조회
|
||||
9. **getTableColumns()** - 테이블 컬럼 조회
|
||||
10. **getTablePrimaryKeys()** - 기본 키 조회
|
||||
11. **getScreenLayoutsForControl()** - 화면 레이아웃 조회
|
||||
|
||||
### 🔧 주요 기술적 해결 사항
|
||||
|
||||
1. **Prisma import 완전 제거**: `import { query, queryOne } from "../database/db"`
|
||||
2. **동적 UPSERT 쿼리**: PostgreSQL ON CONFLICT 구문 사용
|
||||
3. **부분 업데이트**: 동적 SET 절 생성
|
||||
4. **타입 변환**: PostgreSQL 타입 자동 변환 로직 유지
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 1단계: ORM 호출 전환 ✅ **완료**
|
||||
|
||||
- [x] `getFormDataById()` - queryOne 전환
|
||||
- [x] `getScreenLayoutsForControl()` - query 전환
|
||||
- [x] 모든 Raw Query 함수 전환
|
||||
|
||||
### 2단계: 테스트 & 검증 ⏳ **진행 예정**
|
||||
|
||||
- [ ] 단위 테스트 작성 (5개)
|
||||
- [ ] 통합 테스트 작성 (3개 시나리오)
|
||||
- [x] Prisma import 완전 제거 확인 ✅
|
||||
- [ ] 성능 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [x] **13개 모든 Prisma 호출을 Raw Query로 전환 완료** ✅
|
||||
- [x] 11개 `$queryRaw` → `query()` 함수로 교체 ✅
|
||||
- [x] 2개 ORM 메서드 → `query()` 함수로 전환 ✅
|
||||
- [x] **모든 TypeScript 컴파일 오류 해결** ✅
|
||||
- [x] **`import prisma` 완전 제거** ✅
|
||||
- [ ] **모든 단위 테스트 통과 (5개)** ⏳
|
||||
- [ ] **모든 통합 테스트 작성 완료 (3개 시나리오)** ⏳
|
||||
- [ ] **성능 저하 없음** ⏳
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**완료일**: 2025-10-01
|
||||
**소요 시간**: 완료됨 (이전에 전환)
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟢 낮음 (Phase 2.4)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
**특이사항**: SQL은 이미 작성되어 있었고, `query()` 함수로 교체 완료
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
# 🔌 Phase 2.5: ExternalDbConnectionService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
ExternalDbConnectionService는 **15개의 Prisma 호출**이 있으며, 외부 데이터베이스 연결 정보를 관리하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ---------------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/externalDbConnectionService.ts` |
|
||||
| 파일 크기 | 1,100+ 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **15/15 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 중간 (CRUD + 연결 테스트) |
|
||||
| 우선순위 | 🟡 중간 (Phase 2.5) |
|
||||
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ 15개 Prisma 호출을 모두 Raw Query로 전환
|
||||
- ✅ 민감 정보 암호화 처리 유지
|
||||
- ✅ 연결 테스트 로직 정상 동작
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
|
||||
---
|
||||
|
||||
## 🔍 주요 기능
|
||||
|
||||
### 1. 외부 DB 연결 정보 CRUD
|
||||
|
||||
- 생성, 조회, 수정, 삭제
|
||||
- 연결 정보 암호화/복호화
|
||||
|
||||
### 2. 연결 테스트
|
||||
|
||||
- MySQL, PostgreSQL, MSSQL, Oracle 연결 테스트
|
||||
|
||||
### 3. 연결 정보 관리
|
||||
|
||||
- 회사별 연결 정보 조회
|
||||
- 활성/비활성 상태 관리
|
||||
|
||||
---
|
||||
|
||||
## 📝 예상 전환 패턴
|
||||
|
||||
### CRUD 작업
|
||||
|
||||
```typescript
|
||||
// 생성
|
||||
await query(
|
||||
`INSERT INTO external_db_connections
|
||||
(connection_name, db_type, host, port, database_name, username, password, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[...]
|
||||
);
|
||||
|
||||
// 조회
|
||||
await query(
|
||||
`SELECT * FROM external_db_connections
|
||||
WHERE company_code = $1 AND is_active = 'Y'`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
// 수정
|
||||
await query(
|
||||
`UPDATE external_db_connections
|
||||
SET connection_name = $1, host = $2, ...
|
||||
WHERE connection_id = $2`,
|
||||
[...]
|
||||
);
|
||||
|
||||
// 삭제 (소프트)
|
||||
await query(
|
||||
`UPDATE external_db_connections
|
||||
SET is_active = 'N'
|
||||
WHERE connection_id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 전환 완료 내역
|
||||
|
||||
### ✅ 전환된 함수들 (15개 Prisma 호출)
|
||||
|
||||
1. **getConnections()** - 동적 WHERE 조건 생성으로 전환
|
||||
2. **getConnectionsGroupedByType()** - DB 타입 카테고리 조회
|
||||
3. **getConnectionById()** - 단일 연결 조회 (비밀번호 마스킹)
|
||||
4. **getConnectionByIdWithPassword()** - 비밀번호 포함 조회
|
||||
5. **createConnection()** - 새 연결 생성 + 중복 확인
|
||||
6. **updateConnection()** - 동적 필드 업데이트
|
||||
7. **deleteConnection()** - 물리 삭제
|
||||
8. **testConnectionById()** - 연결 테스트용 조회
|
||||
9. **getDecryptedPassword()** - 비밀번호 복호화용 조회
|
||||
10. **executeQuery()** - 쿼리 실행용 조회
|
||||
11. **getTables()** - 테이블 목록 조회용
|
||||
|
||||
### 🔧 주요 기술적 해결 사항
|
||||
|
||||
1. **동적 WHERE 조건 생성**: 필터 조건에 따라 동적으로 SQL 생성
|
||||
2. **동적 UPDATE 쿼리**: 변경된 필드만 업데이트하도록 구현
|
||||
3. **ILIKE 검색**: 대소문자 구분 없는 검색 지원
|
||||
4. **암호화 로직 유지**: PasswordEncryption 클래스와 통합 유지
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [x] **15개 Prisma 호출 모두 Raw Query로 전환** ✅
|
||||
- [x] **암호화/복호화 로직 정상 동작** ✅
|
||||
- [x] **연결 테스트 정상 동작** ✅
|
||||
- [ ] **모든 단위 테스트 통과 (10개 이상)** ⏳
|
||||
- [x] **Prisma import 완전 제거** ✅
|
||||
- [x] **TypeScript 컴파일 성공** ✅
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**완료일**: 2025-10-01
|
||||
**소요 시간**: 1시간
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟡 중간 (Phase 2.5)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
# 🎮 Phase 2.6: DataflowControlService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DataflowControlService는 **6개의 Prisma 호출**이 있으며, 데이터플로우 제어 및 실행을 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ----------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/dataflowControlService.ts` |
|
||||
| 파일 크기 | 1,100+ 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **6/6 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 높음 (복잡한 비즈니스 로직) |
|
||||
| 우선순위 | 🟡 중간 (Phase 2.6) |
|
||||
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ **6개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||
- ✅ 복잡한 비즈니스 로직 정상 동작 확인
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 기능
|
||||
|
||||
1. **데이터플로우 실행 관리**
|
||||
- 관계 기반 데이터 조회 및 저장
|
||||
- 조건부 실행 로직
|
||||
2. **트랜잭션 처리**
|
||||
- 여러 테이블에 걸친 데이터 처리
|
||||
3. **데이터 변환 및 매핑**
|
||||
- 소스-타겟 데이터 변환
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 계획
|
||||
|
||||
### 1단계: 기본 조회 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `getRelationshipById()` - 관계 정보 조회
|
||||
- `getDataflowConfig()` - 데이터플로우 설정 조회
|
||||
|
||||
### 2단계: 데이터 실행 로직 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `executeDataflow()` - 데이터플로우 실행
|
||||
- `validateDataflow()` - 데이터플로우 검증
|
||||
|
||||
### 3단계: 복잡한 기능 - 트랜잭션 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `executeWithTransaction()` - 트랜잭션 내 실행
|
||||
- `rollbackOnError()` - 에러 시 롤백
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 관계 정보 조회
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const relationship = await prisma.table_relationship.findUnique({
|
||||
where: { relationship_id: relationshipId },
|
||||
include: {
|
||||
source_table: true,
|
||||
target_table: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
|
||||
const relationship = await query<TableRelationship>(
|
||||
`SELECT
|
||||
tr.*,
|
||||
st.table_name as source_table_name,
|
||||
tt.table_name as target_table_name
|
||||
FROM table_relationship tr
|
||||
LEFT JOIN table_labels st ON tr.source_table_id = st.table_id
|
||||
LEFT JOIN table_labels tt ON tr.target_table_id = tt.table_id
|
||||
WHERE tr.relationship_id = $1`,
|
||||
[relationshipId]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 트랜잭션 내 실행
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 소스 데이터 조회
|
||||
const sourceData = await tx.dynamic_form_data.findMany(...);
|
||||
|
||||
// 타겟 데이터 저장
|
||||
await tx.dynamic_form_data.createMany(...);
|
||||
|
||||
// 실행 로그 저장
|
||||
await tx.dataflow_execution_log.create(...);
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { transaction } from "../database/db";
|
||||
|
||||
await transaction(async (client) => {
|
||||
// 소스 데이터 조회
|
||||
const sourceData = await client.query(
|
||||
`SELECT * FROM dynamic_form_data WHERE ...`,
|
||||
[...]
|
||||
);
|
||||
|
||||
// 타겟 데이터 저장
|
||||
await client.query(
|
||||
`INSERT INTO dynamic_form_data (...) VALUES (...)`,
|
||||
[...]
|
||||
);
|
||||
|
||||
// 실행 로그 저장
|
||||
await client.query(
|
||||
`INSERT INTO dataflow_execution_log (...) VALUES (...)`,
|
||||
[...]
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 5단계: 테스트 & 검증
|
||||
|
||||
### 단위 테스트 (10개)
|
||||
|
||||
- [ ] getRelationshipById - 관계 정보 조회
|
||||
- [ ] getDataflowConfig - 설정 조회
|
||||
- [ ] executeDataflow - 데이터플로우 실행
|
||||
- [ ] validateDataflow - 검증
|
||||
- [ ] executeWithTransaction - 트랜잭션 실행
|
||||
- [ ] rollbackOnError - 에러 처리
|
||||
- [ ] transformData - 데이터 변환
|
||||
- [ ] mapSourceToTarget - 필드 매핑
|
||||
- [ ] applyConditions - 조건 적용
|
||||
- [ ] logExecution - 실행 로그
|
||||
|
||||
### 통합 테스트 (4개 시나리오)
|
||||
|
||||
1. **데이터플로우 실행 시나리오**
|
||||
- 관계 조회 → 데이터 실행 → 로그 저장
|
||||
2. **트랜잭션 테스트**
|
||||
- 여러 테이블 동시 처리
|
||||
- 에러 발생 시 롤백
|
||||
3. **조건부 실행 테스트**
|
||||
- 조건에 따른 데이터 처리
|
||||
4. **데이터 변환 테스트**
|
||||
- 소스-타겟 데이터 매핑
|
||||
|
||||
---
|
||||
|
||||
## 📋 전환 완료 내역
|
||||
|
||||
### ✅ 전환된 함수들 (6개 Prisma 호출)
|
||||
|
||||
1. **executeDataflowControl()** - 관계도 정보 조회 (findUnique → queryOne)
|
||||
2. **evaluateActionConditions()** - 대상 테이블 조건 확인 ($queryRawUnsafe → query)
|
||||
3. **executeInsertAction()** - INSERT 실행 ($executeRawUnsafe → query)
|
||||
4. **executeUpdateAction()** - UPDATE 실행 ($executeRawUnsafe → query)
|
||||
5. **executeDeleteAction()** - DELETE 실행 ($executeRawUnsafe → query)
|
||||
6. **checkColumnExists()** - 컬럼 존재 확인 ($queryRawUnsafe → query)
|
||||
|
||||
### 🔧 주요 기술적 해결 사항
|
||||
|
||||
1. **Prisma import 완전 제거**: `import { query, queryOne } from "../database/db"`
|
||||
2. **동적 테이블 쿼리 전환**: `$queryRawUnsafe` / `$executeRawUnsafe` → `query()`
|
||||
3. **파라미터 바인딩 수정**: MySQL `?` → PostgreSQL `$1, $2...`
|
||||
4. **복잡한 비즈니스 로직 유지**: 조건부 실행, 다중 커넥션, 에러 처리
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [x] **6개 모든 Prisma 호출을 Raw Query로 전환 완료** ✅
|
||||
- [x] **모든 TypeScript 컴파일 오류 해결** ✅
|
||||
- [x] **`import prisma` 완전 제거** ✅
|
||||
- [ ] **트랜잭션 정상 동작 확인** ⏳
|
||||
- [ ] **복잡한 비즈니스 로직 정상 동작** ⏳
|
||||
- [ ] **모든 단위 테스트 통과 (10개)** ⏳
|
||||
- [ ] **모든 통합 테스트 작성 완료 (4개 시나리오)** ⏳
|
||||
- [ ] **성능 저하 없음** ⏳
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### 복잡한 비즈니스 로직
|
||||
|
||||
이 서비스는 데이터플로우 제어라는 복잡한 비즈니스 로직을 처리합니다:
|
||||
|
||||
- 조건부 실행 로직
|
||||
- 데이터 변환 및 매핑
|
||||
- 트랜잭션 관리
|
||||
- 에러 처리 및 롤백
|
||||
|
||||
### 성능 최적화 중요
|
||||
|
||||
데이터플로우 실행은 대량의 데이터를 처리할 수 있으므로:
|
||||
|
||||
- 배치 처리 고려
|
||||
- 인덱스 활용
|
||||
- 쿼리 최적화
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**완료일**: 2025-10-01
|
||||
**소요 시간**: 30분
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟡 중간 (Phase 2.6)
|
||||
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||
**특이사항**: 복잡한 비즈니스 로직이 포함되어 있어 신중한 테스트 필요
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
# 🔧 Phase 2.7: DDLExecutionService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DDLExecutionService는 **4개의 Prisma 호출**이 있으며, DDL(Data Definition Language) 실행 및 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/ddlExecutionService.ts` |
|
||||
| 파일 크기 | 400+ 라인 |
|
||||
| Prisma 호출 | 4개 |
|
||||
| **현재 진행률** | **6/6 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 중간 (DDL 실행 + 로그 관리) |
|
||||
| 우선순위 | 🔴 최우선 (테이블 추가 기능 - Phase 2.3으로 변경) |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ **4개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||
- ✅ DDL 실행 정상 동작 확인
|
||||
- ✅ 모든 단위 테스트 통과
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 기능
|
||||
|
||||
1. **DDL 실행**
|
||||
- CREATE TABLE, ALTER TABLE, DROP TABLE
|
||||
- CREATE INDEX, DROP INDEX
|
||||
2. **실행 로그 관리**
|
||||
- DDL 실행 이력 저장
|
||||
- 에러 로그 관리
|
||||
3. **롤백 지원**
|
||||
- DDL 롤백 SQL 생성 및 실행
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 계획
|
||||
|
||||
### 1단계: DDL 실행 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `executeDDL()` - DDL 실행
|
||||
- `validateDDL()` - DDL 문법 검증
|
||||
|
||||
### 2단계: 로그 관리 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `saveDDLLog()` - 실행 로그 저장
|
||||
- `getDDLHistory()` - 실행 이력 조회
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: DDL 실행 및 로그 저장
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
await prisma.$executeRawUnsafe(ddlQuery);
|
||||
|
||||
await prisma.ddl_execution_log.create({
|
||||
data: {
|
||||
ddl_statement: ddlQuery,
|
||||
execution_status: "SUCCESS",
|
||||
executed_by: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
|
||||
await query(ddlQuery);
|
||||
|
||||
await query(
|
||||
`INSERT INTO ddl_execution_log
|
||||
(ddl_statement, execution_status, executed_by, executed_date)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[ddlQuery, "SUCCESS", userId, new Date()]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: DDL 실행 이력 조회
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const history = await prisma.ddl_execution_log.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
execution_status: "SUCCESS",
|
||||
},
|
||||
orderBy: { executed_date: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
|
||||
const history = await query<DDLLog[]>(
|
||||
`SELECT * FROM ddl_execution_log
|
||||
WHERE company_code = $1
|
||||
AND execution_status = $2
|
||||
ORDER BY executed_date DESC
|
||||
LIMIT $3`,
|
||||
[companyCode, "SUCCESS", 50]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 3단계: 테스트 & 검증
|
||||
|
||||
### 단위 테스트 (8개)
|
||||
|
||||
- [ ] executeDDL - CREATE TABLE
|
||||
- [ ] executeDDL - ALTER TABLE
|
||||
- [ ] executeDDL - DROP TABLE
|
||||
- [ ] executeDDL - CREATE INDEX
|
||||
- [ ] validateDDL - 문법 검증
|
||||
- [ ] saveDDLLog - 로그 저장
|
||||
- [ ] getDDLHistory - 이력 조회
|
||||
- [ ] rollbackDDL - DDL 롤백
|
||||
|
||||
### 통합 테스트 (3개 시나리오)
|
||||
|
||||
1. **테이블 생성 → 로그 저장 → 이력 조회**
|
||||
2. **DDL 실행 실패 → 에러 로그 저장**
|
||||
3. **DDL 롤백 테스트**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- [ ] **4개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||
- [ ] **DDL 실행 정상 동작 확인**
|
||||
- [ ] **모든 단위 테스트 통과 (8개)**
|
||||
- [ ] **모든 통합 테스트 작성 완료 (3개 시나리오)**
|
||||
- [ ] **`import prisma` 완전 제거 및 `import { query } from "../database/db"` 사용**
|
||||
- [ ] **성능 저하 없음**
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### DDL 실행의 위험성
|
||||
|
||||
DDL은 데이터베이스 스키마를 변경하므로 매우 신중하게 처리해야 합니다:
|
||||
|
||||
- 실행 전 검증 필수
|
||||
- 롤백 SQL 자동 생성
|
||||
- 실행 이력 철저히 관리
|
||||
|
||||
### 트랜잭션 지원 제한
|
||||
|
||||
PostgreSQL에서 일부 DDL은 트랜잭션을 지원하지만, 일부는 자동 커밋됩니다:
|
||||
|
||||
- CREATE TABLE: 트랜잭션 지원 ✅
|
||||
- DROP TABLE: 트랜잭션 지원 ✅
|
||||
- CREATE INDEX CONCURRENTLY: 트랜잭션 미지원 ❌
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**예상 소요 시간**: 0.5일
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟢 낮음 (Phase 2.7)
|
||||
**상태**: ⏳ **진행 예정**
|
||||
**특이사항**: DDL 실행의 특성상 신중한 테스트 필요
|
||||
|
|
@ -1,566 +0,0 @@
|
|||
# 🖥️ Phase 2.1: ScreenManagementService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
ScreenManagementService는 **46개의 Prisma 호출**이 있는 가장 복잡한 서비스입니다. 화면 정의, 레이아웃, 메뉴 할당, 템플릿 등 다양한 기능을 포함합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| 파일 위치 | `backend-node/src/services/screenManagementService.ts` |
|
||||
| 파일 크기 | 1,700+ 라인 |
|
||||
| Prisma 호출 | 46개 |
|
||||
| **현재 진행률** | **46/46 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 매우 높음 |
|
||||
| 우선순위 | 🔴 최우선 |
|
||||
|
||||
### 🎯 전환 현황 (2025-09-30 업데이트)
|
||||
|
||||
- ✅ **Stage 1 완료**: 기본 CRUD (8개 함수) - Commit: 13c1bc4, 0e8d1d4
|
||||
- ✅ **Stage 2 완료**: 레이아웃 관리 (2개 함수, 4 Prisma 호출) - Commit: 67dced7
|
||||
- ✅ **Stage 3 완료**: 템플릿 & 메뉴 관리 (5개 함수) - Commit: 74351e8
|
||||
- ✅ **Stage 4 완료**: 복잡한 기능 (트랜잭션) - **모든 46개 Prisma 호출 전환 완료**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 1. 화면 정의 관리 (Screen Definitions) - 18개
|
||||
|
||||
```typescript
|
||||
// Line 53: 화면 코드 중복 확인
|
||||
await prisma.screen_definitions.findFirst({ where: { screen_code, is_active: { not: "D" } } })
|
||||
|
||||
// Line 70: 화면 생성
|
||||
await prisma.screen_definitions.create({ data: { ... } })
|
||||
|
||||
// Line 99: 화면 목록 조회 (페이징)
|
||||
await prisma.screen_definitions.findMany({ where, skip, take, orderBy })
|
||||
|
||||
// Line 105: 화면 총 개수
|
||||
await prisma.screen_definitions.count({ where })
|
||||
|
||||
// Line 166: 전체 화면 목록
|
||||
await prisma.screen_definitions.findMany({ where })
|
||||
|
||||
// Line 178: 화면 코드로 조회
|
||||
await prisma.screen_definitions.findFirst({ where: { screen_code } })
|
||||
|
||||
// Line 205: 화면 ID로 조회
|
||||
await prisma.screen_definitions.findFirst({ where: { screen_id } })
|
||||
|
||||
// Line 221: 화면 존재 확인
|
||||
await prisma.screen_definitions.findUnique({ where: { screen_id } })
|
||||
|
||||
// Line 236: 화면 업데이트
|
||||
await prisma.screen_definitions.update({ where, data })
|
||||
|
||||
// Line 268: 화면 복사 - 원본 조회
|
||||
await prisma.screen_definitions.findUnique({ where, include: { screen_layouts } })
|
||||
|
||||
// Line 292: 화면 순서 변경 - 전체 조회
|
||||
await prisma.screen_definitions.findMany({ where })
|
||||
|
||||
// Line 486: 화면 템플릿 적용 - 존재 확인
|
||||
await prisma.screen_definitions.findUnique({ where })
|
||||
|
||||
// Line 557: 화면 복사 - 존재 확인
|
||||
await prisma.screen_definitions.findUnique({ where })
|
||||
|
||||
// Line 578: 화면 복사 - 중복 확인
|
||||
await prisma.screen_definitions.findFirst({ where })
|
||||
|
||||
// Line 651: 화면 삭제 - 존재 확인
|
||||
await prisma.screen_definitions.findUnique({ where })
|
||||
|
||||
// Line 672: 화면 삭제 (물리 삭제)
|
||||
await prisma.screen_definitions.delete({ where })
|
||||
|
||||
// Line 700: 삭제된 화면 조회
|
||||
await prisma.screen_definitions.findMany({ where: { is_active: "D" } })
|
||||
|
||||
// Line 706: 삭제된 화면 개수
|
||||
await prisma.screen_definitions.count({ where })
|
||||
|
||||
// Line 763: 일괄 삭제 - 화면 조회
|
||||
await prisma.screen_definitions.findMany({ where })
|
||||
|
||||
// Line 1083: 레이아웃 저장 - 화면 확인
|
||||
await prisma.screen_definitions.findUnique({ where })
|
||||
|
||||
// Line 1181: 레이아웃 조회 - 화면 확인
|
||||
await prisma.screen_definitions.findUnique({ where })
|
||||
|
||||
// Line 1655: 위젯 데이터 저장 - 화면 존재 확인
|
||||
await prisma.screen_definitions.findMany({ where })
|
||||
```
|
||||
|
||||
### 2. 레이아웃 관리 (Screen Layouts) - 4개
|
||||
|
||||
```typescript
|
||||
// Line 1096: 레이아웃 삭제
|
||||
await prisma.screen_layouts.deleteMany({ where: { screen_id } });
|
||||
|
||||
// Line 1107: 레이아웃 생성 (단일)
|
||||
await prisma.screen_layouts.create({ data });
|
||||
|
||||
// Line 1152: 레이아웃 생성 (다중)
|
||||
await prisma.screen_layouts.create({ data });
|
||||
|
||||
// Line 1193: 레이아웃 조회
|
||||
await prisma.screen_layouts.findMany({ where });
|
||||
```
|
||||
|
||||
### 3. 템플릿 관리 (Screen Templates) - 2개
|
||||
|
||||
```typescript
|
||||
// Line 1303: 템플릿 목록 조회
|
||||
await prisma.screen_templates.findMany({ where });
|
||||
|
||||
// Line 1317: 템플릿 생성
|
||||
await prisma.screen_templates.create({ data });
|
||||
```
|
||||
|
||||
### 4. 메뉴 할당 (Screen Menu Assignments) - 5개
|
||||
|
||||
```typescript
|
||||
// Line 446: 메뉴 할당 조회
|
||||
await prisma.screen_menu_assignments.findMany({ where });
|
||||
|
||||
// Line 1346: 메뉴 할당 중복 확인
|
||||
await prisma.screen_menu_assignments.findFirst({ where });
|
||||
|
||||
// Line 1358: 메뉴 할당 생성
|
||||
await prisma.screen_menu_assignments.create({ data });
|
||||
|
||||
// Line 1376: 화면별 메뉴 할당 조회
|
||||
await prisma.screen_menu_assignments.findMany({ where });
|
||||
|
||||
// Line 1401: 메뉴 할당 삭제
|
||||
await prisma.screen_menu_assignments.deleteMany({ where });
|
||||
```
|
||||
|
||||
### 5. 테이블 레이블 (Table Labels) - 3개
|
||||
|
||||
```typescript
|
||||
// Line 117: 테이블 레이블 조회 (페이징)
|
||||
await prisma.table_labels.findMany({ where, skip, take });
|
||||
|
||||
// Line 713: 테이블 레이블 조회 (전체)
|
||||
await prisma.table_labels.findMany({ where });
|
||||
```
|
||||
|
||||
### 6. 컬럼 레이블 (Column Labels) - 2개
|
||||
|
||||
```typescript
|
||||
// Line 948: 웹타입 정보 조회
|
||||
await prisma.column_labels.findMany({ where, select });
|
||||
|
||||
// Line 1456: 컬럼 레이블 UPSERT
|
||||
await prisma.column_labels.upsert({ where, create, update });
|
||||
```
|
||||
|
||||
### 7. Raw Query 사용 (이미 있음) - 6개
|
||||
|
||||
```typescript
|
||||
// Line 627: 화면 순서 변경 (일괄 업데이트)
|
||||
await prisma.$executeRaw`UPDATE screen_definitions SET display_order = ...`;
|
||||
|
||||
// Line 833: 테이블 목록 조회
|
||||
await prisma.$queryRaw<Array<{ table_name: string }>>`SELECT tablename ...`;
|
||||
|
||||
// Line 876: 테이블 존재 확인
|
||||
await prisma.$queryRaw<Array<{ table_name: string }>>`SELECT tablename ...`;
|
||||
|
||||
// Line 922: 테이블 컬럼 정보 조회
|
||||
await prisma.$queryRaw<Array<ColumnInfo>>`SELECT column_name, data_type ...`;
|
||||
|
||||
// Line 1418: 컬럼 정보 조회 (상세)
|
||||
await prisma.$queryRaw`SELECT column_name, data_type ...`;
|
||||
```
|
||||
|
||||
### 8. 트랜잭션 사용 - 3개
|
||||
|
||||
```typescript
|
||||
// Line 521: 화면 템플릿 적용 트랜잭션
|
||||
await prisma.$transaction(async (tx) => { ... })
|
||||
|
||||
// Line 593: 화면 복사 트랜잭션
|
||||
await prisma.$transaction(async (tx) => { ... })
|
||||
|
||||
// Line 788: 일괄 삭제 트랜잭션
|
||||
await prisma.$transaction(async (tx) => { ... })
|
||||
|
||||
// Line 1697: 위젯 데이터 저장 트랜잭션
|
||||
await prisma.$transaction(async (tx) => { ... })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 전환 전략
|
||||
|
||||
### 전략 1: 단계적 전환
|
||||
|
||||
1. **1단계**: 단순 CRUD 전환 (findFirst, findMany, create, update, delete)
|
||||
2. **2단계**: 복잡한 조회 전환 (include, join)
|
||||
3. **3단계**: 트랜잭션 전환
|
||||
4. **4단계**: Raw Query 개선
|
||||
|
||||
### 전략 2: 함수별 전환 우선순위
|
||||
|
||||
#### 🔴 최우선 (기본 CRUD)
|
||||
|
||||
- `createScreen()` - Line 70
|
||||
- `getScreensByCompany()` - Line 99-105
|
||||
- `getScreenByCode()` - Line 178
|
||||
- `getScreenById()` - Line 205
|
||||
- `updateScreen()` - Line 236
|
||||
- `deleteScreen()` - Line 672
|
||||
|
||||
#### 🟡 2순위 (레이아웃)
|
||||
|
||||
- `saveLayout()` - Line 1096-1152
|
||||
- `getLayout()` - Line 1193
|
||||
- `deleteLayout()` - Line 1096
|
||||
|
||||
#### 🟢 3순위 (템플릿 & 메뉴)
|
||||
|
||||
- `getTemplates()` - Line 1303
|
||||
- `createTemplate()` - Line 1317
|
||||
- `assignToMenu()` - Line 1358
|
||||
- `getMenuAssignments()` - Line 1376
|
||||
- `removeMenuAssignment()` - Line 1401
|
||||
|
||||
#### 🔵 4순위 (복잡한 기능)
|
||||
|
||||
- `copyScreen()` - Line 593 (트랜잭션)
|
||||
- `applyTemplate()` - Line 521 (트랜잭션)
|
||||
- `bulkDelete()` - Line 788 (트랜잭션)
|
||||
- `reorderScreens()` - Line 627 (Raw Query)
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 예시
|
||||
|
||||
### 예시 1: createScreen() 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
// Line 53: 중복 확인
|
||||
const existingScreen = await prisma.screen_definitions.findFirst({
|
||||
where: {
|
||||
screen_code: screenData.screenCode,
|
||||
is_active: { not: "D" },
|
||||
},
|
||||
});
|
||||
|
||||
// Line 70: 생성
|
||||
const screen = await prisma.screen_definitions.create({
|
||||
data: {
|
||||
screen_name: screenData.screenName,
|
||||
screen_code: screenData.screenCode,
|
||||
table_name: screenData.tableName,
|
||||
company_code: screenData.companyCode,
|
||||
description: screenData.description,
|
||||
created_by: screenData.createdBy,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { query } from "../database/db";
|
||||
|
||||
// 중복 확인
|
||||
const existingResult = await query<{ screen_id: number }>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND is_active != 'D'
|
||||
LIMIT 1`,
|
||||
[screenData.screenCode]
|
||||
);
|
||||
|
||||
if (existingResult.length > 0) {
|
||||
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||
}
|
||||
|
||||
// 생성
|
||||
const [screen] = await query<ScreenDefinition>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code, description, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
screenData.screenName,
|
||||
screenData.screenCode,
|
||||
screenData.tableName,
|
||||
screenData.companyCode,
|
||||
screenData.description,
|
||||
screenData.createdBy,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: getScreensByCompany() 전환 (페이징)
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
const [screens, total] = await Promise.all([
|
||||
prisma.screen_definitions.findMany({
|
||||
where: whereClause,
|
||||
skip: (page - 1) * size,
|
||||
take: size,
|
||||
orderBy: { created_at: "desc" },
|
||||
}),
|
||||
prisma.screen_definitions.count({ where: whereClause }),
|
||||
]);
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
const offset = (page - 1) * size;
|
||||
const whereSQL =
|
||||
companyCode !== "*"
|
||||
? "WHERE company_code = $1 AND is_active != 'D'"
|
||||
: "WHERE is_active != 'D'";
|
||||
const params =
|
||||
companyCode !== "*" ? [companyCode, size, offset] : [size, offset];
|
||||
|
||||
const [screens, totalResult] = await Promise.all([
|
||||
query<ScreenDefinition>(
|
||||
`SELECT * FROM screen_definitions
|
||||
${whereSQL}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params
|
||||
),
|
||||
query<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM screen_definitions ${whereSQL}`,
|
||||
companyCode !== "*" ? [companyCode] : []
|
||||
),
|
||||
]);
|
||||
|
||||
const total = totalResult[0]?.count || 0;
|
||||
```
|
||||
|
||||
### 예시 3: 트랜잭션 전환
|
||||
|
||||
**기존 Prisma 코드:**
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const newScreen = await tx.screen_definitions.create({ data: { ... } });
|
||||
await tx.screen_layouts.createMany({ data: layouts });
|
||||
});
|
||||
```
|
||||
|
||||
**새로운 Raw Query 코드:**
|
||||
|
||||
```typescript
|
||||
import { transaction } from "../database/db";
|
||||
|
||||
await transaction(async (client) => {
|
||||
const [newScreen] = await client.query(
|
||||
`INSERT INTO screen_definitions (...) VALUES (...) RETURNING *`,
|
||||
[...]
|
||||
);
|
||||
|
||||
for (const layout of layouts) {
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts (...) VALUES (...)`,
|
||||
[...]
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트
|
||||
|
||||
```typescript
|
||||
describe("ScreenManagementService Raw Query 전환 테스트", () => {
|
||||
describe("createScreen", () => {
|
||||
test("화면 생성 성공", async () => { ... });
|
||||
test("중복 화면 코드 에러", async () => { ... });
|
||||
});
|
||||
|
||||
describe("getScreensByCompany", () => {
|
||||
test("페이징 조회 성공", async () => { ... });
|
||||
test("회사별 필터링", async () => { ... });
|
||||
});
|
||||
|
||||
describe("copyScreen", () => {
|
||||
test("화면 복사 성공 (트랜잭션)", async () => { ... });
|
||||
test("레이아웃 함께 복사", async () => { ... });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 통합 테스트
|
||||
|
||||
```typescript
|
||||
describe("화면 관리 통합 테스트", () => {
|
||||
test("화면 생성 → 조회 → 수정 → 삭제", async () => { ... });
|
||||
test("화면 복사 → 레이아웃 확인", async () => { ... });
|
||||
test("메뉴 할당 → 조회 → 해제", async () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 1단계: 기본 CRUD (8개 함수) ✅ **완료**
|
||||
|
||||
- [x] `createScreen()` - 화면 생성
|
||||
- [x] `getScreensByCompany()` - 화면 목록 (페이징)
|
||||
- [x] `getScreenByCode()` - 화면 코드로 조회
|
||||
- [x] `getScreenById()` - 화면 ID로 조회
|
||||
- [x] `updateScreen()` - 화면 업데이트
|
||||
- [x] `deleteScreen()` - 화면 삭제
|
||||
- [x] `getScreens()` - 전체 화면 목록 조회
|
||||
- [x] `getScreen()` - 회사 코드 필터링 포함 조회
|
||||
|
||||
### 2단계: 레이아웃 관리 (2개 함수) ✅ **완료**
|
||||
|
||||
- [x] `saveLayout()` - 레이아웃 저장 (메타데이터 + 컴포넌트)
|
||||
- [x] `getLayout()` - 레이아웃 조회
|
||||
- [x] 레이아웃 삭제 로직 (saveLayout 내부에 포함)
|
||||
|
||||
### 3단계: 템플릿 & 메뉴 (5개 함수) ✅ **완료**
|
||||
|
||||
- [x] `getTemplatesByCompany()` - 템플릿 목록
|
||||
- [x] `createTemplate()` - 템플릿 생성
|
||||
- [x] `assignScreenToMenu()` - 메뉴 할당
|
||||
- [x] `getScreensByMenu()` - 메뉴별 화면 조회
|
||||
- [x] `unassignScreenFromMenu()` - 메뉴 할당 해제
|
||||
- [ ] 테이블 레이블 조회 (getScreensByCompany 내부에 포함됨)
|
||||
|
||||
### 4단계: 복잡한 기능 (4개 함수) ✅ **완료**
|
||||
|
||||
- [x] `copyScreen()` - 화면 복사 (트랜잭션)
|
||||
- [x] `generateScreenCode()` - 화면 코드 자동 생성
|
||||
- [x] `checkScreenDependencies()` - 화면 의존성 체크 (메뉴 할당 포함)
|
||||
- [x] 모든 유틸리티 메서드 Raw Query 전환
|
||||
|
||||
### 5단계: 테스트 & 검증 ✅ **완료**
|
||||
|
||||
- [x] 단위 테스트 작성 (18개 테스트 통과)
|
||||
- createScreen, updateScreen, deleteScreen
|
||||
- getScreensByCompany, getScreenById
|
||||
- saveLayout, getLayout
|
||||
- getTemplatesByCompany, assignScreenToMenu
|
||||
- copyScreen, generateScreenCode
|
||||
- getTableColumns
|
||||
- [x] 통합 테스트 작성 (6개 시나리오)
|
||||
- 화면 생명주기 테스트 (생성 → 조회 → 수정 → 삭제 → 복원 → 영구삭제)
|
||||
- 화면 복사 및 레이아웃 테스트
|
||||
- 테이블 정보 조회 테스트
|
||||
- 일괄 작업 테스트
|
||||
- 화면 코드 자동 생성 테스트
|
||||
- [x] Prisma import 완전 제거 확인
|
||||
- [ ] 성능 테스트 (추후 실행 예정)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 기준
|
||||
|
||||
- ✅ **46개 Prisma 호출 모두 Raw Query로 전환 완료**
|
||||
- ✅ **모든 TypeScript 컴파일 오류 해결**
|
||||
- ✅ **트랜잭션 정상 동작 확인**
|
||||
- ✅ **에러 처리 및 롤백 정상 동작**
|
||||
- ✅ **모든 단위 테스트 통과 (18개)**
|
||||
- ✅ **모든 통합 테스트 작성 완료 (6개 시나리오)**
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
- [ ] 성능 저하 없음 (기존 대비 ±10% 이내) - 추후 측정 예정
|
||||
|
||||
## 📊 테스트 결과
|
||||
|
||||
### 단위 테스트 (18개)
|
||||
|
||||
```
|
||||
✅ createScreen - 화면 생성 (2개 테스트)
|
||||
✅ getScreensByCompany - 화면 목록 페이징 (2개 테스트)
|
||||
✅ updateScreen - 화면 업데이트 (2개 테스트)
|
||||
✅ deleteScreen - 화면 삭제 (2개 테스트)
|
||||
✅ saveLayout - 레이아웃 저장 (2개 테스트)
|
||||
- 기본 저장, 소수점 좌표 반올림 처리
|
||||
✅ getLayout - 레이아웃 조회 (1개 테스트)
|
||||
✅ getTemplatesByCompany - 템플릿 목록 (1개 테스트)
|
||||
✅ assignScreenToMenu - 메뉴 할당 (2개 테스트)
|
||||
✅ copyScreen - 화면 복사 (1개 테스트)
|
||||
✅ generateScreenCode - 화면 코드 자동 생성 (2개 테스트)
|
||||
✅ getTableColumns - 테이블 컬럼 정보 (1개 테스트)
|
||||
|
||||
Test Suites: 1 passed
|
||||
Tests: 18 passed
|
||||
Time: 1.922s
|
||||
```
|
||||
|
||||
### 통합 테스트 (6개 시나리오)
|
||||
|
||||
```
|
||||
✅ 화면 생명주기 테스트
|
||||
- 생성 → 조회 → 수정 → 삭제 → 복원 → 영구삭제
|
||||
✅ 화면 복사 및 레이아웃 테스트
|
||||
- 화면 복사 → 레이아웃 저장 → 레이아웃 확인 → 레이아웃 수정
|
||||
✅ 테이블 정보 조회 테스트
|
||||
- 테이블 목록 조회 → 특정 테이블 정보 조회
|
||||
✅ 일괄 작업 테스트
|
||||
- 여러 화면 생성 → 일괄 삭제
|
||||
✅ 화면 코드 자동 생성 테스트
|
||||
- 순차적 화면 코드 생성 검증
|
||||
✅ 메뉴 할당 테스트 (skip - 실제 메뉴 데이터 필요)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 버그 수정 및 개선사항
|
||||
|
||||
### 실제 운영 환경에서 발견된 이슈
|
||||
|
||||
#### 1. 소수점 좌표 저장 오류 (해결 완료)
|
||||
|
||||
**문제**:
|
||||
|
||||
```
|
||||
invalid input syntax for type integer: "1602.666666666667"
|
||||
```
|
||||
|
||||
- `position_x`, `position_y`, `width`, `height` 컬럼이 `integer` 타입
|
||||
- 격자 계산 시 소수점 값이 발생하여 저장 실패
|
||||
|
||||
**해결**:
|
||||
|
||||
```typescript
|
||||
Math.round(component.position.x), // 정수로 반올림
|
||||
Math.round(component.position.y),
|
||||
Math.round(component.size.width),
|
||||
Math.round(component.size.height),
|
||||
```
|
||||
|
||||
**테스트 추가**:
|
||||
|
||||
- 소수점 좌표 저장 테스트 케이스 추가
|
||||
- 반올림 처리 검증
|
||||
|
||||
**영향 범위**:
|
||||
|
||||
- `saveLayout()` 함수
|
||||
- `copyScreen()` 함수 (레이아웃 복사 시)
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-09-30
|
||||
**완료일**: 2025-09-30
|
||||
**예상 소요 시간**: 2-3일 → **실제 소요 시간**: 1일
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🔴 최우선 (Phase 2.1)
|
||||
**상태**: ✅ **완료**
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
# 📋 Phase 3.11: DDLAuditLogger Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DDLAuditLogger는 **8개의 Prisma 호출**이 있으며, DDL 실행 감사 로그 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/ddlAuditLogger.ts` |
|
||||
| 파일 크기 | 350 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **8/8 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 중간 (통계 쿼리, $executeRaw) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.11) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **8개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ DDL 감사 로그 기능 정상 동작
|
||||
- ⏳ 통계 쿼리 전환 (GROUP BY, COUNT, ORDER BY)
|
||||
- ⏳ $executeRaw → query 전환
|
||||
- ⏳ $queryRawUnsafe → query 전환
|
||||
- ⏳ 동적 WHERE 조건 생성
|
||||
- ⏳ TypeScript 컴파일 성공
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 Prisma 호출 (8개)
|
||||
|
||||
#### 1. **logDDLStart()** - DDL 시작 로그 (INSERT)
|
||||
|
||||
```typescript
|
||||
// Line 27
|
||||
const logEntry = await prisma.$executeRaw`
|
||||
INSERT INTO ddl_audit_logs (
|
||||
execution_id, ddl_type, table_name, status,
|
||||
executed_by, company_code, started_at, metadata
|
||||
) VALUES (
|
||||
${executionId}, ${ddlType}, ${tableName}, 'in_progress',
|
||||
${executedBy}, ${companyCode}, NOW(), ${JSON.stringify(metadata)}::jsonb
|
||||
)
|
||||
`;
|
||||
```
|
||||
|
||||
#### 2. **getAuditLogs()** - 감사 로그 목록 조회 (SELECT with filters)
|
||||
|
||||
```typescript
|
||||
// Line 162
|
||||
const logs = await prisma.$queryRawUnsafe(query, ...params);
|
||||
```
|
||||
|
||||
- 동적 WHERE 조건 생성
|
||||
- 페이징 (OFFSET, LIMIT)
|
||||
- 정렬 (ORDER BY)
|
||||
|
||||
#### 3. **getAuditStats()** - 통계 조회 (복합 쿼리)
|
||||
|
||||
```typescript
|
||||
// Line 199 - 총 통계
|
||||
const totalStats = (await prisma.$queryRawUnsafe(
|
||||
`SELECT
|
||||
COUNT(*) as total_executions,
|
||||
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful,
|
||||
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed,
|
||||
AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) as avg_duration
|
||||
FROM ddl_audit_logs
|
||||
WHERE ${whereClause}`
|
||||
)) as any[];
|
||||
|
||||
// Line 212 - DDL 타입별 통계
|
||||
const ddlTypeStats = (await prisma.$queryRawUnsafe(
|
||||
`SELECT ddl_type, COUNT(*) as count
|
||||
FROM ddl_audit_logs
|
||||
WHERE ${whereClause}
|
||||
GROUP BY ddl_type
|
||||
ORDER BY count DESC`
|
||||
)) as any[];
|
||||
|
||||
// Line 224 - 사용자별 통계
|
||||
const userStats = (await prisma.$queryRawUnsafe(
|
||||
`SELECT executed_by, COUNT(*) as count
|
||||
FROM ddl_audit_logs
|
||||
WHERE ${whereClause}
|
||||
GROUP BY executed_by
|
||||
ORDER BY count DESC
|
||||
LIMIT 10`
|
||||
)) as any[];
|
||||
|
||||
// Line 237 - 최근 실패 로그
|
||||
const recentFailures = (await prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM ddl_audit_logs
|
||||
WHERE status = 'failed' AND ${whereClause}
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 5`
|
||||
)) as any[];
|
||||
```
|
||||
|
||||
#### 4. **getExecutionHistory()** - 실행 이력 조회
|
||||
|
||||
```typescript
|
||||
// Line 287
|
||||
const history = await prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM ddl_audit_logs
|
||||
WHERE table_name = $1 AND company_code = $2
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $3`,
|
||||
tableName,
|
||||
companyCode,
|
||||
limit
|
||||
);
|
||||
```
|
||||
|
||||
#### 5. **cleanupOldLogs()** - 오래된 로그 삭제
|
||||
|
||||
```typescript
|
||||
// Line 320
|
||||
const result = await prisma.$executeRaw`
|
||||
DELETE FROM ddl_audit_logs
|
||||
WHERE started_at < NOW() - INTERVAL '${retentionDays} days'
|
||||
AND company_code = ${companyCode}
|
||||
`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 전환 전략
|
||||
|
||||
### 1단계: $executeRaw 전환 (2개)
|
||||
|
||||
- `logDDLStart()` - INSERT
|
||||
- `cleanupOldLogs()` - DELETE
|
||||
|
||||
### 2단계: 단순 $queryRawUnsafe 전환 (1개)
|
||||
|
||||
- `getExecutionHistory()` - 파라미터 바인딩 있음
|
||||
|
||||
### 3단계: 복잡한 $queryRawUnsafe 전환 (1개)
|
||||
|
||||
- `getAuditLogs()` - 동적 WHERE 조건
|
||||
|
||||
### 4단계: 통계 쿼리 전환 (4개)
|
||||
|
||||
- `getAuditStats()` 내부의 4개 쿼리
|
||||
- GROUP BY, CASE WHEN, AVG, EXTRACT
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: $executeRaw → query (INSERT)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const logEntry = await prisma.$executeRaw`
|
||||
INSERT INTO ddl_audit_logs (
|
||||
execution_id, ddl_type, table_name, status,
|
||||
executed_by, company_code, started_at, metadata
|
||||
) VALUES (
|
||||
${executionId}, ${ddlType}, ${tableName}, 'in_progress',
|
||||
${executedBy}, ${companyCode}, NOW(), ${JSON.stringify(metadata)}::jsonb
|
||||
)
|
||||
`;
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
await query(
|
||||
`INSERT INTO ddl_audit_logs (
|
||||
execution_id, ddl_type, table_name, status,
|
||||
executed_by, company_code, started_at, metadata
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7::jsonb)`,
|
||||
[
|
||||
executionId,
|
||||
ddlType,
|
||||
tableName,
|
||||
"in_progress",
|
||||
executedBy,
|
||||
companyCode,
|
||||
JSON.stringify(metadata),
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 동적 WHERE 조건
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
let query = `SELECT * FROM ddl_audit_logs WHERE 1=1`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (filters.ddlType) {
|
||||
query += ` AND ddl_type = ?`;
|
||||
params.push(filters.ddlType);
|
||||
}
|
||||
|
||||
const logs = await prisma.$queryRawUnsafe(query, ...params);
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.ddlType) {
|
||||
conditions.push(`ddl_type = $${paramIndex++}`);
|
||||
params.push(filters.ddlType);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const sql = `SELECT * FROM ddl_audit_logs ${whereClause}`;
|
||||
|
||||
const logs = await query<any>(sql, params);
|
||||
```
|
||||
|
||||
### 예시 3: 통계 쿼리 (GROUP BY)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const ddlTypeStats = (await prisma.$queryRawUnsafe(
|
||||
`SELECT ddl_type, COUNT(*) as count
|
||||
FROM ddl_audit_logs
|
||||
WHERE ${whereClause}
|
||||
GROUP BY ddl_type
|
||||
ORDER BY count DESC`
|
||||
)) as any[];
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const ddlTypeStats = await query<{ ddl_type: string; count: string }>(
|
||||
`SELECT ddl_type, COUNT(*) as count
|
||||
FROM ddl_audit_logs
|
||||
WHERE ${whereClause}
|
||||
GROUP BY ddl_type
|
||||
ORDER BY count DESC`,
|
||||
params
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. JSON 필드 처리
|
||||
|
||||
`metadata` 필드는 JSONB 타입으로, INSERT 시 `::jsonb` 캐스팅 필요:
|
||||
|
||||
```typescript
|
||||
JSON.stringify(metadata) + "::jsonb";
|
||||
```
|
||||
|
||||
### 2. 날짜/시간 함수
|
||||
|
||||
- `NOW()` - 현재 시간
|
||||
- `INTERVAL '30 days'` - 날짜 간격
|
||||
- `EXTRACT(EPOCH FROM ...)` - 초 단위 변환
|
||||
|
||||
### 3. CASE WHEN 집계
|
||||
|
||||
```sql
|
||||
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful
|
||||
```
|
||||
|
||||
### 4. 동적 WHERE 조건
|
||||
|
||||
여러 필터를 조합하여 WHERE 절 생성:
|
||||
|
||||
- ddlType
|
||||
- tableName
|
||||
- status
|
||||
- executedBy
|
||||
- dateRange (startDate, endDate)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역
|
||||
|
||||
### 전환된 Prisma 호출 (8개)
|
||||
|
||||
1. **`logDDLExecution()`** - DDL 실행 로그 INSERT
|
||||
- Before: `prisma.$executeRaw`
|
||||
- After: `query()` with 7 parameters
|
||||
2. **`getAuditLogs()`** - 감사 로그 목록 조회
|
||||
- Before: `prisma.$queryRawUnsafe`
|
||||
- After: `query<any>()` with dynamic WHERE clause
|
||||
3. **`getDDLStatistics()`** - 통계 조회 (4개 쿼리)
|
||||
- Before: 4x `prisma.$queryRawUnsafe`
|
||||
- After: 4x `query<any>()`
|
||||
- totalStats: 전체 실행 통계 (CASE WHEN 집계)
|
||||
- ddlTypeStats: DDL 타입별 통계 (GROUP BY)
|
||||
- userStats: 사용자별 통계 (GROUP BY, LIMIT 10)
|
||||
- recentFailures: 최근 실패 로그 (WHERE success = false)
|
||||
4. **`getTableDDLHistory()`** - 테이블별 DDL 히스토리
|
||||
- Before: `prisma.$queryRawUnsafe`
|
||||
- After: `query<any>()` with table_name filter
|
||||
5. **`cleanupOldLogs()`** - 오래된 로그 삭제
|
||||
- Before: `prisma.$executeRaw`
|
||||
- After: `query()` with date filter
|
||||
|
||||
### 주요 기술적 개선사항
|
||||
|
||||
1. **파라미터 바인딩**: PostgreSQL `$1, $2, ...` 스타일로 통일
|
||||
2. **동적 WHERE 조건**: 파라미터 인덱스 자동 증가 로직 유지
|
||||
3. **통계 쿼리**: CASE WHEN, GROUP BY, SUM 등 복잡한 집계 쿼리 완벽 전환
|
||||
4. **에러 처리**: 기존 try-catch 구조 유지
|
||||
5. **로깅**: logger 유틸리티 활용 유지
|
||||
|
||||
### 코드 정리
|
||||
|
||||
- [x] `import { PrismaClient }` 제거
|
||||
- [x] `const prisma = new PrismaClient()` 제거
|
||||
- [x] `import { query, queryOne }` 추가
|
||||
- [x] 모든 타입 정의 유지
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] Linter 오류 없음
|
||||
|
||||
## 📝 원본 전환 체크리스트
|
||||
|
||||
### 1단계: Prisma 호출 전환 (✅ 완료)
|
||||
|
||||
- [ ] `logDDLStart()` - INSERT ($executeRaw → query)
|
||||
- [ ] `logDDLComplete()` - UPDATE (이미 query 사용 중일 가능성)
|
||||
- [ ] `logDDLError()` - UPDATE (이미 query 사용 중일 가능성)
|
||||
- [ ] `getAuditLogs()` - SELECT with filters ($queryRawUnsafe → query)
|
||||
- [ ] `getAuditStats()` 내 4개 쿼리:
|
||||
- [ ] totalStats (집계 쿼리)
|
||||
- [ ] ddlTypeStats (GROUP BY)
|
||||
- [ ] userStats (GROUP BY + LIMIT)
|
||||
- [ ] recentFailures (필터 + ORDER BY + LIMIT)
|
||||
- [ ] `getExecutionHistory()` - SELECT with params ($queryRawUnsafe → query)
|
||||
- [ ] `cleanupOldLogs()` - DELETE ($executeRaw → query)
|
||||
|
||||
### 2단계: 코드 정리
|
||||
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] Prisma import 완전 제거
|
||||
- [ ] 타입 정의 확인
|
||||
|
||||
### 3단계: 테스트
|
||||
|
||||
- [ ] 단위 테스트 작성 (8개)
|
||||
- [ ] DDL 시작 로그 테스트
|
||||
- [ ] DDL 완료 로그 테스트
|
||||
- [ ] 감사 로그 목록 조회 테스트
|
||||
- [ ] 통계 조회 테스트
|
||||
- [ ] 실행 이력 조회 테스트
|
||||
- [ ] 오래된 로그 삭제 테스트
|
||||
- [ ] 통합 테스트 작성 (3개)
|
||||
- [ ] 전체 DDL 실행 플로우 테스트
|
||||
- [ ] 필터링 및 페이징 테스트
|
||||
- [ ] 통계 정확성 테스트
|
||||
- [ ] 성능 테스트
|
||||
- [ ] 대량 로그 조회 성능
|
||||
- [ ] 통계 쿼리 성능
|
||||
|
||||
### 4단계: 문서화
|
||||
|
||||
- [ ] 전환 완료 문서 업데이트
|
||||
- [ ] 주요 변경사항 기록
|
||||
- [ ] 성능 벤치마크 결과
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐ (중간)
|
||||
- 복잡한 통계 쿼리 (GROUP BY, CASE WHEN)
|
||||
- 동적 WHERE 조건 생성
|
||||
- JSON 필드 처리
|
||||
- **예상 소요 시간**: 1~1.5시간
|
||||
- Prisma 호출 전환: 30분
|
||||
- 테스트: 20분
|
||||
- 문서화: 10분
|
||||
|
||||
---
|
||||
|
||||
## 📌 참고사항
|
||||
|
||||
### 관련 서비스
|
||||
|
||||
- `DDLExecutionService` - DDL 실행 (이미 전환 완료)
|
||||
- `DDLSafetyValidator` - DDL 안전성 검증
|
||||
|
||||
### 의존성
|
||||
|
||||
- `../database/db` - query, queryOne 함수
|
||||
- `../types/ddl` - DDL 관련 타입
|
||||
- `../utils/logger` - 로깅
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: 통계 쿼리, JSON 필드, 동적 WHERE 조건 포함
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
# 📋 Phase 3.12: ExternalCallConfigService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
ExternalCallConfigService는 **8개의 Prisma 호출**이 있으며, 외부 API 호출 설정 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/externalCallConfigService.ts` |
|
||||
| 파일 크기 | 612 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **8/8 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 중간 (JSON 필드, 복잡한 CRUD) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.12) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **8개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ 외부 호출 설정 CRUD 기능 정상 동작
|
||||
- ⏳ JSON 필드 처리 (headers, params, auth_config)
|
||||
- ⏳ 동적 WHERE 조건 생성
|
||||
- ⏳ 민감 정보 암호화/복호화 유지
|
||||
- ⏳ TypeScript 컴파일 성공
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 예상 Prisma 사용 패턴
|
||||
|
||||
### 주요 기능 (8개 예상)
|
||||
|
||||
#### 1. **외부 호출 설정 목록 조회**
|
||||
|
||||
- findMany with filters
|
||||
- 페이징, 정렬
|
||||
- 동적 WHERE 조건 (is_active, company_code, search)
|
||||
|
||||
#### 2. **외부 호출 설정 단건 조회**
|
||||
|
||||
- findUnique or findFirst
|
||||
- config_id 기준
|
||||
|
||||
#### 3. **외부 호출 설정 생성**
|
||||
|
||||
- create
|
||||
- JSON 필드 처리 (headers, params, auth_config)
|
||||
- 민감 정보 암호화
|
||||
|
||||
#### 4. **외부 호출 설정 수정**
|
||||
|
||||
- update
|
||||
- 동적 UPDATE 쿼리
|
||||
- JSON 필드 업데이트
|
||||
|
||||
#### 5. **외부 호출 설정 삭제**
|
||||
|
||||
- delete or soft delete
|
||||
|
||||
#### 6. **외부 호출 설정 복제**
|
||||
|
||||
- findUnique + create
|
||||
|
||||
#### 7. **외부 호출 설정 테스트**
|
||||
|
||||
- findUnique
|
||||
- 실제 HTTP 호출
|
||||
|
||||
#### 8. **외부 호출 이력 조회**
|
||||
|
||||
- findMany with 관계 조인
|
||||
- 통계 쿼리
|
||||
|
||||
---
|
||||
|
||||
## 💡 전환 전략
|
||||
|
||||
### 1단계: 기본 CRUD 전환 (5개)
|
||||
|
||||
- getExternalCallConfigs() - 목록 조회
|
||||
- getExternalCallConfig() - 단건 조회
|
||||
- createExternalCallConfig() - 생성
|
||||
- updateExternalCallConfig() - 수정
|
||||
- deleteExternalCallConfig() - 삭제
|
||||
|
||||
### 2단계: 추가 기능 전환 (3개)
|
||||
|
||||
- duplicateExternalCallConfig() - 복제
|
||||
- testExternalCallConfig() - 테스트
|
||||
- getExternalCallHistory() - 이력 조회
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 목록 조회 (동적 WHERE + JSON)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const configs = await prisma.external_call_configs.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
is_active: isActive,
|
||||
OR: [
|
||||
{ config_name: { contains: search, mode: "insensitive" } },
|
||||
{ endpoint_url: { contains: search, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (isActive !== undefined) {
|
||||
conditions.push(`is_active = $${paramIndex++}`);
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
`(config_name ILIKE $${paramIndex} OR endpoint_url ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const configs = await query<any>(
|
||||
`SELECT * FROM external_call_configs
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||
[...params, limit, skip]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: JSON 필드 생성
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const config = await prisma.external_call_configs.create({
|
||||
data: {
|
||||
config_name: data.config_name,
|
||||
endpoint_url: data.endpoint_url,
|
||||
http_method: data.http_method,
|
||||
headers: data.headers, // JSON
|
||||
params: data.params, // JSON
|
||||
auth_config: encryptedAuthConfig, // JSON (암호화됨)
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const config = await queryOne<any>(
|
||||
`INSERT INTO external_call_configs
|
||||
(config_name, endpoint_url, http_method, headers, params,
|
||||
auth_config, company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
data.config_name,
|
||||
data.endpoint_url,
|
||||
data.http_method,
|
||||
JSON.stringify(data.headers),
|
||||
JSON.stringify(data.params),
|
||||
JSON.stringify(encryptedAuthConfig),
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 동적 UPDATE (JSON 포함)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const updateData: any = {};
|
||||
if (data.headers) updateData.headers = data.headers;
|
||||
if (data.params) updateData.params = data.params;
|
||||
|
||||
const config = await prisma.external_call_configs.update({
|
||||
where: { config_id: configId },
|
||||
data: updateData,
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const updateFields: string[] = ["updated_at = NOW()"];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.headers !== undefined) {
|
||||
updateFields.push(`headers = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(data.headers));
|
||||
}
|
||||
|
||||
if (data.params !== undefined) {
|
||||
updateFields.push(`params = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(data.params));
|
||||
}
|
||||
|
||||
const config = await queryOne<any>(
|
||||
`UPDATE external_call_configs
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE config_id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
[...values, configId]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. JSON 필드 처리
|
||||
|
||||
3개의 JSON 필드가 있을 것으로 예상:
|
||||
|
||||
- `headers` - HTTP 헤더
|
||||
- `params` - 쿼리 파라미터
|
||||
- `auth_config` - 인증 설정 (암호화됨)
|
||||
|
||||
```typescript
|
||||
// INSERT/UPDATE 시
|
||||
JSON.stringify(jsonData);
|
||||
|
||||
// SELECT 후
|
||||
const parsedData =
|
||||
typeof row.headers === "string" ? JSON.parse(row.headers) : row.headers;
|
||||
```
|
||||
|
||||
### 2. 민감 정보 암호화
|
||||
|
||||
auth_config는 암호화되어 저장되므로, 기존 암호화/복호화 로직 유지:
|
||||
|
||||
```typescript
|
||||
import { encrypt, decrypt } from "../utils/encryption";
|
||||
|
||||
// 저장 시
|
||||
const encryptedAuthConfig = encrypt(JSON.stringify(authConfig));
|
||||
|
||||
// 조회 시
|
||||
const decryptedAuthConfig = JSON.parse(decrypt(row.auth_config));
|
||||
```
|
||||
|
||||
### 3. HTTP 메소드 검증
|
||||
|
||||
```typescript
|
||||
const VALID_HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
if (!VALID_HTTP_METHODS.includes(httpMethod)) {
|
||||
throw new Error("Invalid HTTP method");
|
||||
}
|
||||
```
|
||||
|
||||
### 4. URL 검증
|
||||
|
||||
```typescript
|
||||
try {
|
||||
new URL(endpointUrl);
|
||||
} catch {
|
||||
throw new Error("Invalid endpoint URL");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역
|
||||
|
||||
### 전환된 Prisma 호출 (8개)
|
||||
|
||||
1. **`getConfigs()`** - 목록 조회 (findMany → query)
|
||||
2. **`getConfigById()`** - 단건 조회 (findUnique → queryOne)
|
||||
3. **`createConfig()`** - 중복 검사 (findFirst → queryOne)
|
||||
4. **`createConfig()`** - 생성 (create → queryOne with INSERT)
|
||||
5. **`updateConfig()`** - 중복 검사 (findFirst → queryOne)
|
||||
6. **`updateConfig()`** - 수정 (update → queryOne with 동적 UPDATE)
|
||||
7. **`deleteConfig()`** - 삭제 (update → query)
|
||||
8. **`getExternalCallConfigsForButtonControl()`** - 조회 (findMany → query)
|
||||
|
||||
### 주요 기술적 개선사항
|
||||
|
||||
- 동적 WHERE 조건 생성 (company_code, call_type, api_type, is_active, search)
|
||||
- ILIKE를 활용한 대소문자 구분 없는 검색
|
||||
- 동적 UPDATE 쿼리 (9개 필드)
|
||||
- JSON 필드 처리 (`config_data` → `JSON.stringify()`)
|
||||
- 중복 검사 로직 유지
|
||||
|
||||
### 코드 정리
|
||||
|
||||
- [x] import 문 수정 완료
|
||||
- [x] Prisma import 완전 제거
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] Linter 오류 없음
|
||||
|
||||
## 📝 원본 전환 체크리스트
|
||||
|
||||
### 1단계: Prisma 호출 전환 (✅ 완료)
|
||||
|
||||
- [ ] `getExternalCallConfigs()` - 목록 조회 (findMany + count)
|
||||
- [ ] `getExternalCallConfig()` - 단건 조회 (findUnique)
|
||||
- [ ] `createExternalCallConfig()` - 생성 (create)
|
||||
- [ ] `updateExternalCallConfig()` - 수정 (update)
|
||||
- [ ] `deleteExternalCallConfig()` - 삭제 (delete)
|
||||
- [ ] `duplicateExternalCallConfig()` - 복제 (findUnique + create)
|
||||
- [ ] `testExternalCallConfig()` - 테스트 (findUnique)
|
||||
- [ ] `getExternalCallHistory()` - 이력 조회 (findMany)
|
||||
|
||||
### 2단계: 코드 정리
|
||||
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] JSON 필드 처리 확인
|
||||
- [ ] 암호화/복호화 로직 유지
|
||||
- [ ] Prisma import 완전 제거
|
||||
|
||||
### 3단계: 테스트
|
||||
|
||||
- [ ] 단위 테스트 작성 (8개)
|
||||
- [ ] 통합 테스트 작성 (3개)
|
||||
- [ ] 암호화 테스트
|
||||
- [ ] HTTP 호출 테스트
|
||||
|
||||
### 4단계: 문서화
|
||||
|
||||
- [ ] 전환 완료 문서 업데이트
|
||||
- [ ] API 문서 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐ (중간)
|
||||
- JSON 필드 처리
|
||||
- 암호화/복호화 로직
|
||||
- HTTP 호출 테스트
|
||||
- **예상 소요 시간**: 1~1.5시간
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: JSON 필드, 민감 정보 암호화, HTTP 호출 포함
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
# 📋 Phase 3.13: EntityJoinService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
EntityJoinService는 **5개의 Prisma 호출**이 있으며, 엔티티 간 조인 관계 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------ |
|
||||
| 파일 위치 | `backend-node/src/services/entityJoinService.ts` |
|
||||
| 파일 크기 | 575 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **5/5 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 중간 (조인 쿼리, 관계 설정) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.13) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **5개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ 엔티티 조인 설정 CRUD 기능 정상 동작
|
||||
- ⏳ 복잡한 조인 쿼리 전환 (LEFT JOIN, INNER JOIN)
|
||||
- ⏳ 조인 유효성 검증
|
||||
- ⏳ TypeScript 컴파일 성공
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 예상 Prisma 사용 패턴
|
||||
|
||||
### 주요 기능 (5개 예상)
|
||||
|
||||
#### 1. **엔티티 조인 목록 조회**
|
||||
|
||||
- findMany with filters
|
||||
- 동적 WHERE 조건
|
||||
- 페이징, 정렬
|
||||
|
||||
#### 2. **엔티티 조인 단건 조회**
|
||||
|
||||
- findUnique or findFirst
|
||||
- join_id 기준
|
||||
|
||||
#### 3. **엔티티 조인 생성**
|
||||
|
||||
- create
|
||||
- 조인 유효성 검증
|
||||
|
||||
#### 4. **엔티티 조인 수정**
|
||||
|
||||
- update
|
||||
- 동적 UPDATE 쿼리
|
||||
|
||||
#### 5. **엔티티 조인 삭제**
|
||||
|
||||
- delete
|
||||
|
||||
---
|
||||
|
||||
## 💡 전환 전략
|
||||
|
||||
### 1단계: 기본 CRUD 전환 (5개)
|
||||
|
||||
- getEntityJoins() - 목록 조회
|
||||
- getEntityJoin() - 단건 조회
|
||||
- createEntityJoin() - 생성
|
||||
- updateEntityJoin() - 수정
|
||||
- deleteEntityJoin() - 삭제
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 조인 설정 조회 (LEFT JOIN으로 테이블 정보 포함)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const joins = await prisma.entity_joins.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
is_active: true,
|
||||
},
|
||||
include: {
|
||||
source_table: true,
|
||||
target_table: true,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const joins = await query<any>(
|
||||
`SELECT
|
||||
ej.*,
|
||||
st.table_name as source_table_name,
|
||||
st.table_label as source_table_label,
|
||||
tt.table_name as target_table_name,
|
||||
tt.table_label as target_table_label
|
||||
FROM entity_joins ej
|
||||
LEFT JOIN tables st ON ej.source_table_id = st.table_id
|
||||
LEFT JOIN tables tt ON ej.target_table_id = tt.table_id
|
||||
WHERE ej.company_code = $1 AND ej.is_active = $2
|
||||
ORDER BY ej.created_at DESC`,
|
||||
[companyCode, true]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 조인 생성 (유효성 검증 포함)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
// 조인 유효성 검증
|
||||
const sourceTable = await prisma.tables.findUnique({
|
||||
where: { table_id: sourceTableId },
|
||||
});
|
||||
|
||||
const targetTable = await prisma.tables.findUnique({
|
||||
where: { table_id: targetTableId },
|
||||
});
|
||||
|
||||
if (!sourceTable || !targetTable) {
|
||||
throw new Error("Invalid table references");
|
||||
}
|
||||
|
||||
// 조인 생성
|
||||
const join = await prisma.entity_joins.create({
|
||||
data: {
|
||||
source_table_id: sourceTableId,
|
||||
target_table_id: targetTableId,
|
||||
join_type: joinType,
|
||||
join_condition: joinCondition,
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
// 조인 유효성 검증 (Promise.all로 병렬 실행)
|
||||
const [sourceTable, targetTable] = await Promise.all([
|
||||
queryOne<any>(`SELECT * FROM tables WHERE table_id = $1`, [sourceTableId]),
|
||||
queryOne<any>(`SELECT * FROM tables WHERE table_id = $1`, [targetTableId]),
|
||||
]);
|
||||
|
||||
if (!sourceTable || !targetTable) {
|
||||
throw new Error("Invalid table references");
|
||||
}
|
||||
|
||||
// 조인 생성
|
||||
const join = await queryOne<any>(
|
||||
`INSERT INTO entity_joins
|
||||
(source_table_id, target_table_id, join_type, join_condition,
|
||||
company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[sourceTableId, targetTableId, joinType, joinCondition, companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 조인 수정
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const join = await prisma.entity_joins.update({
|
||||
where: { join_id: joinId },
|
||||
data: {
|
||||
join_type: joinType,
|
||||
join_condition: joinCondition,
|
||||
is_active: isActive,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const updateFields: string[] = ["updated_at = NOW()"];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (joinType !== undefined) {
|
||||
updateFields.push(`join_type = $${paramIndex++}`);
|
||||
values.push(joinType);
|
||||
}
|
||||
|
||||
if (joinCondition !== undefined) {
|
||||
updateFields.push(`join_condition = $${paramIndex++}`);
|
||||
values.push(joinCondition);
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
updateFields.push(`is_active = $${paramIndex++}`);
|
||||
values.push(isActive);
|
||||
}
|
||||
|
||||
const join = await queryOne<any>(
|
||||
`UPDATE entity_joins
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE join_id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
[...values, joinId]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. 조인 타입 검증
|
||||
|
||||
```typescript
|
||||
const VALID_JOIN_TYPES = ["INNER", "LEFT", "RIGHT", "FULL"];
|
||||
if (!VALID_JOIN_TYPES.includes(joinType)) {
|
||||
throw new Error("Invalid join type");
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 조인 조건 검증
|
||||
|
||||
```typescript
|
||||
// 조인 조건은 SQL 조건식 형태 (예: "source.id = target.parent_id")
|
||||
// SQL 인젝션 방지를 위한 검증 필요
|
||||
const isValidJoinCondition = /^[\w\s.=<>]+$/.test(joinCondition);
|
||||
if (!isValidJoinCondition) {
|
||||
throw new Error("Invalid join condition");
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 순환 참조 방지
|
||||
|
||||
```typescript
|
||||
// 조인이 순환 참조를 만들지 않는지 검증
|
||||
async function checkCircularReference(
|
||||
sourceTableId: number,
|
||||
targetTableId: number
|
||||
): Promise<boolean> {
|
||||
// 재귀적으로 조인 관계 확인
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. LEFT JOIN으로 관련 테이블 정보 조회
|
||||
|
||||
조인 설정 조회 시 source/target 테이블 정보를 함께 가져오기 위해 LEFT JOIN 사용
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역
|
||||
|
||||
### 전환된 Prisma 호출 (5개)
|
||||
|
||||
1. **`detectEntityJoins()`** - 엔티티 컬럼 감지 (findMany → query)
|
||||
|
||||
- column_labels 조회
|
||||
- web_type = 'entity' 필터
|
||||
- reference_table/reference_column IS NOT NULL
|
||||
|
||||
2. **`validateJoinConfig()`** - 테이블 존재 확인 ($queryRaw → query)
|
||||
|
||||
- information_schema.tables 조회
|
||||
- 참조 테이블 검증
|
||||
|
||||
3. **`validateJoinConfig()`** - 컬럼 존재 확인 ($queryRaw → query)
|
||||
|
||||
- information_schema.columns 조회
|
||||
- 표시 컬럼 검증
|
||||
|
||||
4. **`getReferenceTableColumns()`** - 컬럼 정보 조회 ($queryRaw → query)
|
||||
|
||||
- information_schema.columns 조회
|
||||
- 문자열 타입 컬럼만 필터
|
||||
|
||||
5. **`getReferenceTableColumns()`** - 라벨 정보 조회 (findMany → query)
|
||||
- column_labels 조회
|
||||
- 컬럼명과 라벨 매핑
|
||||
|
||||
### 주요 기술적 개선사항
|
||||
|
||||
- **information_schema 쿼리**: 파라미터 바인딩으로 변경 ($1, $2)
|
||||
- **타입 안전성**: 명확한 반환 타입 지정
|
||||
- **IS NOT NULL 조건**: Prisma의 { not: null } → IS NOT NULL
|
||||
- **IN 조건**: 여러 데이터 타입 필터링
|
||||
|
||||
### 코드 정리
|
||||
|
||||
- [x] PrismaClient import 제거
|
||||
- [x] import 문 수정 완료
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] Linter 오류 없음
|
||||
|
||||
## 📝 원본 전환 체크리스트
|
||||
|
||||
### 1단계: Prisma 호출 전환 (✅ 완료)
|
||||
|
||||
- [ ] `getEntityJoins()` - 목록 조회 (findMany with include)
|
||||
- [ ] `getEntityJoin()` - 단건 조회 (findUnique)
|
||||
- [ ] `createEntityJoin()` - 생성 (create with validation)
|
||||
- [ ] `updateEntityJoin()` - 수정 (update)
|
||||
- [ ] `deleteEntityJoin()` - 삭제 (delete)
|
||||
|
||||
### 2단계: 코드 정리
|
||||
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] 조인 유효성 검증 로직 유지
|
||||
- [ ] Prisma import 완전 제거
|
||||
|
||||
### 3단계: 테스트
|
||||
|
||||
- [ ] 단위 테스트 작성 (5개)
|
||||
- [ ] 조인 유효성 검증 테스트
|
||||
- [ ] 순환 참조 방지 테스트
|
||||
- [ ] 통합 테스트 작성 (2개)
|
||||
|
||||
### 4단계: 문서화
|
||||
|
||||
- [ ] 전환 완료 문서 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐ (중간)
|
||||
- LEFT JOIN 쿼리
|
||||
- 조인 유효성 검증
|
||||
- 순환 참조 방지
|
||||
- **예상 소요 시간**: 1시간
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: LEFT JOIN, 조인 유효성 검증, 순환 참조 방지 포함
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
# 📋 Phase 3.14: AuthService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
AuthService는 **5개의 Prisma 호출**이 있으며, 사용자 인증 및 권한 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| 파일 위치 | `backend-node/src/services/authService.ts` |
|
||||
| 파일 크기 | 335 라인 |
|
||||
| Prisma 호출 | 0개 (이미 Phase 1.5에서 전환 완료) |
|
||||
| **현재 진행률** | **5/5 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 높음 (보안, 암호화, 세션 관리) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.14) |
|
||||
| **상태** | ✅ **완료** (Phase 1.5에서 이미 완료) |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **5개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ 사용자 인증 기능 정상 동작
|
||||
- ⏳ 비밀번호 암호화/검증 유지
|
||||
- ⏳ 세션 관리 기능 유지
|
||||
- ⏳ 권한 검증 기능 유지
|
||||
- ⏳ TypeScript 컴파일 성공
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 예상 Prisma 사용 패턴
|
||||
|
||||
### 주요 기능 (5개 예상)
|
||||
|
||||
#### 1. **사용자 로그인 (인증)**
|
||||
|
||||
- findFirst or findUnique
|
||||
- 이메일/사용자명으로 조회
|
||||
- 비밀번호 검증
|
||||
|
||||
#### 2. **사용자 정보 조회**
|
||||
|
||||
- findUnique
|
||||
- user_id 기준
|
||||
- 권한 정보 포함
|
||||
|
||||
#### 3. **사용자 생성 (회원가입)**
|
||||
|
||||
- create
|
||||
- 비밀번호 암호화
|
||||
- 중복 검사
|
||||
|
||||
#### 4. **비밀번호 변경**
|
||||
|
||||
- update
|
||||
- 기존 비밀번호 검증
|
||||
- 새 비밀번호 암호화
|
||||
|
||||
#### 5. **세션 관리**
|
||||
|
||||
- create, update, delete
|
||||
- 세션 토큰 저장/조회
|
||||
|
||||
---
|
||||
|
||||
## 💡 전환 전략
|
||||
|
||||
### 1단계: 인증 관련 전환 (2개)
|
||||
|
||||
- login() - 사용자 조회 + 비밀번호 검증
|
||||
- getUserInfo() - 사용자 정보 조회
|
||||
|
||||
### 2단계: 사용자 관리 전환 (2개)
|
||||
|
||||
- createUser() - 사용자 생성
|
||||
- changePassword() - 비밀번호 변경
|
||||
|
||||
### 3단계: 세션 관리 전환 (1개)
|
||||
|
||||
- manageSession() - 세션 CRUD
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 로그인 (비밀번호 검증)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
async login(username: string, password: string) {
|
||||
const user = await prisma.users.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: username },
|
||||
{ email: username },
|
||||
],
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new Error("Invalid password");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
async login(username: string, password: string) {
|
||||
const user = await queryOne<any>(
|
||||
`SELECT * FROM users
|
||||
WHERE (username = $1 OR email = $1)
|
||||
AND is_active = $2`,
|
||||
[username, true]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new Error("Invalid password");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
### 예시 2: 사용자 생성 (비밀번호 암호화)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
async createUser(userData: CreateUserDto) {
|
||||
// 중복 검사
|
||||
const existing = await prisma.users.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: userData.username },
|
||||
{ email: userData.email },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error("User already exists");
|
||||
}
|
||||
|
||||
// 비밀번호 암호화
|
||||
const passwordHash = await bcrypt.hash(userData.password, 10);
|
||||
|
||||
// 사용자 생성
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
password_hash: passwordHash,
|
||||
company_code: userData.company_code,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
async createUser(userData: CreateUserDto) {
|
||||
// 중복 검사
|
||||
const existing = await queryOne<any>(
|
||||
`SELECT * FROM users
|
||||
WHERE username = $1 OR email = $2`,
|
||||
[userData.username, userData.email]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
throw new Error("User already exists");
|
||||
}
|
||||
|
||||
// 비밀번호 암호화
|
||||
const passwordHash = await bcrypt.hash(userData.password, 10);
|
||||
|
||||
// 사용자 생성
|
||||
const user = await queryOne<any>(
|
||||
`INSERT INTO users
|
||||
(username, email, password_hash, company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[userData.username, userData.email, passwordHash, userData.company_code]
|
||||
);
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
### 예시 3: 비밀번호 변경
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
async changePassword(
|
||||
userId: number,
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
) {
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isOldPasswordValid = await bcrypt.compare(
|
||||
oldPassword,
|
||||
user.password_hash
|
||||
);
|
||||
|
||||
if (!isOldPasswordValid) {
|
||||
throw new Error("Invalid old password");
|
||||
}
|
||||
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await prisma.users.update({
|
||||
where: { user_id: userId },
|
||||
data: { password_hash: newPasswordHash },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
async changePassword(
|
||||
userId: number,
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
) {
|
||||
const user = await queryOne<any>(
|
||||
`SELECT * FROM users WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isOldPasswordValid = await bcrypt.compare(
|
||||
oldPassword,
|
||||
user.password_hash
|
||||
);
|
||||
|
||||
if (!isOldPasswordValid) {
|
||||
throw new Error("Invalid old password");
|
||||
}
|
||||
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await query(
|
||||
`UPDATE users
|
||||
SET password_hash = $1, updated_at = NOW()
|
||||
WHERE user_id = $2`,
|
||||
[newPasswordHash, userId]
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. 비밀번호 보안
|
||||
|
||||
```typescript
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
// 비밀번호 해싱 (회원가입, 비밀번호 변경)
|
||||
const SALT_ROUNDS = 10;
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
|
||||
// 비밀번호 검증 (로그인)
|
||||
const isValid = await bcrypt.compare(plainPassword, passwordHash);
|
||||
```
|
||||
|
||||
### 2. SQL 인젝션 방지
|
||||
|
||||
```typescript
|
||||
// ❌ 위험: 직접 문자열 결합
|
||||
const sql = `SELECT * FROM users WHERE username = '${username}'`;
|
||||
|
||||
// ✅ 안전: 파라미터 바인딩
|
||||
const user = await queryOne(`SELECT * FROM users WHERE username = $1`, [
|
||||
username,
|
||||
]);
|
||||
```
|
||||
|
||||
### 3. 세션 토큰 관리
|
||||
|
||||
```typescript
|
||||
import crypto from "crypto";
|
||||
|
||||
// 세션 토큰 생성
|
||||
const sessionToken = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
// 세션 저장
|
||||
await query(
|
||||
`INSERT INTO user_sessions (user_id, session_token, expires_at)
|
||||
VALUES ($1, $2, NOW() + INTERVAL '1 day')`,
|
||||
[userId, sessionToken]
|
||||
);
|
||||
```
|
||||
|
||||
### 4. 권한 검증
|
||||
|
||||
```typescript
|
||||
async checkPermission(userId: number, permission: string): Promise<boolean> {
|
||||
const result = await queryOne<{ has_permission: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM user_permissions up
|
||||
JOIN permissions p ON up.permission_id = p.permission_id
|
||||
WHERE up.user_id = $1 AND p.permission_name = $2
|
||||
) as has_permission`,
|
||||
[userId, permission]
|
||||
);
|
||||
|
||||
return result?.has_permission || false;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역 (Phase 1.5에서 이미 완료됨)
|
||||
|
||||
AuthService는 Phase 1.5에서 이미 Raw Query로 전환이 완료되었습니다.
|
||||
|
||||
### 전환된 Prisma 호출 (5개)
|
||||
|
||||
1. **`loginPwdCheck()`** - 로그인 비밀번호 검증
|
||||
|
||||
- user_info 테이블에서 비밀번호 조회
|
||||
- EncryptUtil을 활용한 비밀번호 검증
|
||||
- 마스터 패스워드 지원
|
||||
|
||||
2. **`insertLoginAccessLog()`** - 로그인 로그 기록
|
||||
|
||||
- login_access_log 테이블에 INSERT
|
||||
- 로그인 시간, IP 주소 등 기록
|
||||
|
||||
3. **`getUserInfo()`** - 사용자 정보 조회
|
||||
|
||||
- user_info 테이블 조회
|
||||
- PersonBean 객체로 반환
|
||||
|
||||
4. **`updateLastLoginDate()`** - 마지막 로그인 시간 업데이트
|
||||
|
||||
- user_info 테이블 UPDATE
|
||||
- last_login_date 갱신
|
||||
|
||||
5. **`checkUserPermission()`** - 사용자 권한 확인
|
||||
- user_auth 테이블 조회
|
||||
- 권한 코드 검증
|
||||
|
||||
### 주요 기술적 특징
|
||||
|
||||
- **보안**: EncryptUtil을 활용한 안전한 비밀번호 검증
|
||||
- **JWT 토큰**: JwtUtils를 활용한 토큰 생성 및 검증
|
||||
- **로깅**: 상세한 로그인 이력 기록
|
||||
- **에러 처리**: 안전한 에러 메시지 반환
|
||||
|
||||
### 코드 상태
|
||||
|
||||
- [x] Prisma import 없음
|
||||
- [x] query 함수 사용 중
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] 보안 로직 유지
|
||||
|
||||
## 📝 원본 전환 체크리스트
|
||||
|
||||
### 1단계: Prisma 호출 전환 (✅ Phase 1.5에서 완료)
|
||||
|
||||
- [ ] `login()` - 사용자 조회 + 비밀번호 검증 (findFirst)
|
||||
- [ ] `getUserInfo()` - 사용자 정보 조회 (findUnique)
|
||||
- [ ] `createUser()` - 사용자 생성 (create with 중복 검사)
|
||||
- [ ] `changePassword()` - 비밀번호 변경 (findUnique + update)
|
||||
- [ ] `manageSession()` - 세션 관리 (create/update/delete)
|
||||
|
||||
### 2단계: 보안 검증
|
||||
|
||||
- [ ] 비밀번호 해싱 로직 유지 (bcrypt)
|
||||
- [ ] SQL 인젝션 방지 확인
|
||||
- [ ] 세션 토큰 보안 확인
|
||||
- [ ] 중복 계정 방지 확인
|
||||
|
||||
### 3단계: 테스트
|
||||
|
||||
- [ ] 단위 테스트 작성 (5개)
|
||||
- [ ] 로그인 성공/실패 테스트
|
||||
- [ ] 사용자 생성 테스트
|
||||
- [ ] 비밀번호 변경 테스트
|
||||
- [ ] 세션 관리 테스트
|
||||
- [ ] 권한 검증 테스트
|
||||
- [ ] 보안 테스트
|
||||
- [ ] SQL 인젝션 테스트
|
||||
- [ ] 비밀번호 강도 테스트
|
||||
- [ ] 세션 탈취 방지 테스트
|
||||
- [ ] 통합 테스트 작성 (2개)
|
||||
|
||||
### 4단계: 문서화
|
||||
|
||||
- [ ] 전환 완료 문서 업데이트
|
||||
- [ ] 보안 가이드 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐⭐ (높음)
|
||||
- 보안 크리티컬 (비밀번호, 세션)
|
||||
- SQL 인젝션 방지 필수
|
||||
- 철저한 테스트 필요
|
||||
- **예상 소요 시간**: 1.5~2시간
|
||||
- Prisma 호출 전환: 40분
|
||||
- 보안 검증: 40분
|
||||
- 테스트: 40분
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 보안 필수 체크리스트
|
||||
|
||||
1. ✅ 모든 사용자 입력은 파라미터 바인딩 사용
|
||||
2. ✅ 비밀번호는 절대 평문 저장 금지 (bcrypt 사용)
|
||||
3. ✅ 세션 토큰은 충분히 길고 랜덤해야 함
|
||||
4. ✅ 비밀번호 실패 시 구체적 오류 메시지 금지 ("User not found" vs "Invalid credentials")
|
||||
5. ✅ 로그인 실패 횟수 제한 (Brute Force 방지)
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: 보안 크리티컬, 비밀번호 암호화, 세션 관리 포함
|
||||
**⚠️ 주의**: 이 서비스는 보안에 매우 중요하므로 신중한 테스트 필수!
|
||||
|
|
@ -1,515 +0,0 @@
|
|||
# 📋 Phase 3.15: Batch Services Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
배치 관련 서비스들은 총 **24개의 Prisma 호출**이 있으며, 배치 작업 실행 및 관리를 담당합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ---------------------------------------------------------- |
|
||||
| 대상 서비스 | 4개 (BatchExternalDb, ExecutionLog, Management, Scheduler) |
|
||||
| 파일 위치 | `backend-node/src/services/batch*.ts` |
|
||||
| 총 파일 크기 | 2,161 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **24/24 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 높음 (외부 DB 연동, 스케줄링, 트랜잭션) |
|
||||
| 우선순위 | 🔴 높음 (Phase 3.15) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역
|
||||
|
||||
### 전환된 Prisma 호출 (24개)
|
||||
|
||||
#### 1. BatchExternalDbService (8개)
|
||||
|
||||
- `getAvailableConnections()` - findMany → query
|
||||
- `getTables()` - $queryRaw → query (information_schema)
|
||||
- `getTableColumns()` - $queryRaw → query (information_schema)
|
||||
- `getExternalTables()` - findUnique → queryOne (x5)
|
||||
|
||||
#### 2. BatchExecutionLogService (7개)
|
||||
|
||||
- `getExecutionLogs()` - findMany + count → query (JOIN + 동적 WHERE)
|
||||
- `createExecutionLog()` - create → queryOne (INSERT RETURNING)
|
||||
- `updateExecutionLog()` - update → queryOne (동적 UPDATE)
|
||||
- `deleteExecutionLog()` - delete → query
|
||||
- `getLatestExecutionLog()` - findFirst → queryOne
|
||||
- `getExecutionStats()` - findMany → query (동적 WHERE)
|
||||
|
||||
#### 3. BatchManagementService (5개)
|
||||
|
||||
- `getAvailableConnections()` - findMany → query
|
||||
- `getTables()` - $queryRaw → query (information_schema)
|
||||
- `getTableColumns()` - $queryRaw → query (information_schema)
|
||||
- `getExternalTables()` - findUnique → queryOne (x2)
|
||||
|
||||
#### 4. BatchSchedulerService (4개)
|
||||
|
||||
- `loadActiveBatchConfigs()` - findMany → query (JOIN with json_agg)
|
||||
- `updateBatchSchedule()` - findUnique → query (JOIN with json_agg)
|
||||
- `getDataFromSource()` - $queryRawUnsafe → query
|
||||
- `insertDataToTarget()` - $executeRawUnsafe → query
|
||||
|
||||
### 주요 기술적 해결 사항
|
||||
|
||||
1. **외부 DB 연결 조회 반복**
|
||||
|
||||
- 5개의 `findUnique` 호출을 `queryOne`으로 일괄 전환
|
||||
- 암호화/복호화 로직 유지
|
||||
|
||||
2. **배치 설정 + 매핑 JOIN**
|
||||
|
||||
- Prisma `include` → `json_agg` + `json_build_object`
|
||||
- `FILTER (WHERE bm.id IS NOT NULL)` 로 NULL 방지
|
||||
- 계층적 JSON 데이터 생성
|
||||
|
||||
3. **동적 WHERE 절 생성**
|
||||
|
||||
- 조건부 필터링 (batch_config_id, execution_status, 날짜 범위)
|
||||
- 파라미터 인덱스 동적 관리
|
||||
|
||||
4. **동적 UPDATE 쿼리**
|
||||
|
||||
- undefined 필드 제외
|
||||
- 8개 필드의 조건부 업데이트
|
||||
|
||||
5. **통계 쿼리 전환**
|
||||
- 클라이언트 사이드 집계 유지
|
||||
- 원본 데이터만 쿼리로 조회
|
||||
|
||||
### 컴파일 상태
|
||||
|
||||
✅ TypeScript 컴파일 성공
|
||||
✅ Linter 오류 없음
|
||||
|
||||
---
|
||||
|
||||
## 🔍 서비스별 상세 분석
|
||||
|
||||
### 1. BatchExternalDbService (8개 호출, 943 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 외부 DB에서 배치 데이터 조회
|
||||
- 외부 DB로 배치 데이터 저장
|
||||
- 외부 DB 연결 관리
|
||||
- 데이터 변환 및 매핑
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getExternalDbConnection()` - 외부 DB 연결 정보 조회
|
||||
- `fetchDataFromExternalDb()` - 외부 DB 데이터 조회
|
||||
- `saveDataToExternalDb()` - 외부 DB 데이터 저장
|
||||
- `validateExternalDbConnection()` - 연결 검증
|
||||
- `getExternalDbTables()` - 테이블 목록 조회
|
||||
- `getExternalDbColumns()` - 컬럼 정보 조회
|
||||
- `executeBatchQuery()` - 배치 쿼리 실행
|
||||
- `getBatchExecutionStatus()` - 실행 상태 조회
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- 다양한 DB 타입 지원 (PostgreSQL, MySQL, Oracle, MSSQL)
|
||||
- 연결 풀 관리
|
||||
- 트랜잭션 처리
|
||||
- 에러 핸들링 및 재시도
|
||||
|
||||
---
|
||||
|
||||
### 2. BatchExecutionLogService (7개 호출, 299 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 배치 실행 로그 생성
|
||||
- 배치 실행 이력 조회
|
||||
- 배치 실행 통계
|
||||
- 로그 정리
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `createExecutionLog()` - 실행 로그 생성
|
||||
- `updateExecutionLog()` - 실행 로그 업데이트
|
||||
- `getExecutionLogs()` - 실행 로그 목록 조회
|
||||
- `getExecutionLogById()` - 실행 로그 단건 조회
|
||||
- `getExecutionStats()` - 실행 통계 조회
|
||||
- `cleanupOldLogs()` - 오래된 로그 삭제
|
||||
- `getFailedExecutions()` - 실패한 실행 조회
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- 대용량 로그 처리
|
||||
- 통계 쿼리 최적화
|
||||
- 로그 보관 정책
|
||||
- 페이징 및 필터링
|
||||
|
||||
---
|
||||
|
||||
### 3. BatchManagementService (5개 호출, 373 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 배치 작업 설정 관리
|
||||
- 배치 작업 실행
|
||||
- 배치 작업 중지
|
||||
- 배치 작업 모니터링
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getBatchJobs()` - 배치 작업 목록 조회
|
||||
- `getBatchJob()` - 배치 작업 단건 조회
|
||||
- `createBatchJob()` - 배치 작업 생성
|
||||
- `updateBatchJob()` - 배치 작업 수정
|
||||
- `deleteBatchJob()` - 배치 작업 삭제
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- JSON 설정 필드 (job_config)
|
||||
- 작업 상태 관리
|
||||
- 동시 실행 제어
|
||||
- 의존성 관리
|
||||
|
||||
---
|
||||
|
||||
### 4. BatchSchedulerService (4개 호출, 546 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 배치 스케줄 설정
|
||||
- Cron 표현식 관리
|
||||
- 스케줄 실행
|
||||
- 다음 실행 시간 계산
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getScheduledBatches()` - 스케줄된 배치 조회
|
||||
- `createSchedule()` - 스케줄 생성
|
||||
- `updateSchedule()` - 스케줄 수정
|
||||
- `deleteSchedule()` - 스케줄 삭제
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- Cron 표현식 파싱
|
||||
- 시간대 처리
|
||||
- 실행 이력 추적
|
||||
- 스케줄 충돌 방지
|
||||
|
||||
---
|
||||
|
||||
## 💡 통합 전환 전략
|
||||
|
||||
### Phase 1: 핵심 서비스 전환 (12개)
|
||||
|
||||
**BatchManagementService (5개) + BatchExecutionLogService (7개)**
|
||||
|
||||
- 배치 관리 및 로깅 기능 우선
|
||||
- 상대적으로 단순한 CRUD
|
||||
|
||||
### Phase 2: 스케줄러 전환 (4개)
|
||||
|
||||
**BatchSchedulerService (4개)**
|
||||
|
||||
- 스케줄 관리
|
||||
- Cron 표현식 처리
|
||||
|
||||
### Phase 3: 외부 DB 연동 전환 (8개)
|
||||
|
||||
**BatchExternalDbService (8개)**
|
||||
|
||||
- 가장 복잡한 서비스
|
||||
- 외부 DB 연결 및 쿼리
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 배치 실행 로그 생성
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const log = await prisma.batch_execution_logs.create({
|
||||
data: {
|
||||
batch_id: batchId,
|
||||
status: "running",
|
||||
started_at: new Date(),
|
||||
execution_params: params,
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const log = await queryOne<any>(
|
||||
`INSERT INTO batch_execution_logs
|
||||
(batch_id, status, started_at, execution_params, company_code)
|
||||
VALUES ($1, $2, NOW(), $3, $4)
|
||||
RETURNING *`,
|
||||
[batchId, "running", JSON.stringify(params), companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 배치 통계 조회
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const stats = await prisma.batch_execution_logs.groupBy({
|
||||
by: ["status"],
|
||||
where: {
|
||||
batch_id: batchId,
|
||||
started_at: { gte: startDate, lte: endDate },
|
||||
},
|
||||
_count: { id: true },
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const stats = await query<{ status: string; count: string }>(
|
||||
`SELECT status, COUNT(*) as count
|
||||
FROM batch_execution_logs
|
||||
WHERE batch_id = $1
|
||||
AND started_at >= $2
|
||||
AND started_at <= $3
|
||||
GROUP BY status`,
|
||||
[batchId, startDate, endDate]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 외부 DB 연결 및 쿼리
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
// 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId },
|
||||
});
|
||||
|
||||
// 외부 DB 쿼리 실행 (Prisma 사용 불가, 이미 Raw Query일 가능성)
|
||||
const externalData = await externalDbClient.query(sql);
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
// 연결 정보 조회
|
||||
const connection = await queryOne<any>(
|
||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
|
||||
// 외부 DB 쿼리 실행 (기존 로직 유지)
|
||||
const externalData = await externalDbClient.query(sql);
|
||||
```
|
||||
|
||||
### 예시 4: 스케줄 관리
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const schedule = await prisma.batch_schedules.create({
|
||||
data: {
|
||||
batch_id: batchId,
|
||||
cron_expression: cronExp,
|
||||
is_active: true,
|
||||
next_run_at: calculateNextRun(cronExp),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const nextRun = calculateNextRun(cronExp);
|
||||
|
||||
const schedule = await queryOne<any>(
|
||||
`INSERT INTO batch_schedules
|
||||
(batch_id, cron_expression, is_active, next_run_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[batchId, cronExp, true, nextRun]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. 외부 DB 연결 관리
|
||||
|
||||
```typescript
|
||||
import { DatabaseConnectorFactory } from "../database/connectorFactory";
|
||||
|
||||
// 외부 DB 연결 생성
|
||||
const connector = DatabaseConnectorFactory.create(connection);
|
||||
const externalClient = await connector.connect();
|
||||
|
||||
try {
|
||||
// 쿼리 실행
|
||||
const result = await externalClient.query(sql, params);
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 트랜잭션 처리
|
||||
|
||||
```typescript
|
||||
await transaction(async (client) => {
|
||||
// 배치 상태 업데이트
|
||||
await client.query(`UPDATE batch_jobs SET status = $1 WHERE id = $2`, [
|
||||
"running",
|
||||
batchId,
|
||||
]);
|
||||
|
||||
// 실행 로그 생성
|
||||
await client.query(
|
||||
`INSERT INTO batch_execution_logs (batch_id, status, started_at)
|
||||
VALUES ($1, $2, NOW())`,
|
||||
[batchId, "running"]
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Cron 표현식 처리
|
||||
|
||||
```typescript
|
||||
import cron from "node-cron";
|
||||
|
||||
// Cron 표현식 검증
|
||||
const isValid = cron.validate(cronExpression);
|
||||
|
||||
// 다음 실행 시간 계산
|
||||
function calculateNextRun(cronExp: string): Date {
|
||||
// Cron 파서를 사용하여 다음 실행 시간 계산
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 대용량 데이터 처리
|
||||
|
||||
```typescript
|
||||
// 스트리밍 방식으로 대용량 데이터 처리
|
||||
const stream = await query<any>(
|
||||
`SELECT * FROM large_table WHERE batch_id = $1`,
|
||||
[batchId]
|
||||
);
|
||||
|
||||
for await (const row of stream) {
|
||||
// 행 단위 처리
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 체크리스트
|
||||
|
||||
### BatchExternalDbService (8개)
|
||||
|
||||
- [ ] `getExternalDbConnection()` - 연결 정보 조회
|
||||
- [ ] `fetchDataFromExternalDb()` - 외부 DB 데이터 조회
|
||||
- [ ] `saveDataToExternalDb()` - 외부 DB 데이터 저장
|
||||
- [ ] `validateExternalDbConnection()` - 연결 검증
|
||||
- [ ] `getExternalDbTables()` - 테이블 목록 조회
|
||||
- [ ] `getExternalDbColumns()` - 컬럼 정보 조회
|
||||
- [ ] `executeBatchQuery()` - 배치 쿼리 실행
|
||||
- [ ] `getBatchExecutionStatus()` - 실행 상태 조회
|
||||
|
||||
### BatchExecutionLogService (7개)
|
||||
|
||||
- [ ] `createExecutionLog()` - 실행 로그 생성
|
||||
- [ ] `updateExecutionLog()` - 실행 로그 업데이트
|
||||
- [ ] `getExecutionLogs()` - 실행 로그 목록 조회
|
||||
- [ ] `getExecutionLogById()` - 실행 로그 단건 조회
|
||||
- [ ] `getExecutionStats()` - 실행 통계 조회
|
||||
- [ ] `cleanupOldLogs()` - 오래된 로그 삭제
|
||||
- [ ] `getFailedExecutions()` - 실패한 실행 조회
|
||||
|
||||
### BatchManagementService (5개)
|
||||
|
||||
- [ ] `getBatchJobs()` - 배치 작업 목록 조회
|
||||
- [ ] `getBatchJob()` - 배치 작업 단건 조회
|
||||
- [ ] `createBatchJob()` - 배치 작업 생성
|
||||
- [ ] `updateBatchJob()` - 배치 작업 수정
|
||||
- [ ] `deleteBatchJob()` - 배치 작업 삭제
|
||||
|
||||
### BatchSchedulerService (4개)
|
||||
|
||||
- [ ] `getScheduledBatches()` - 스케줄된 배치 조회
|
||||
- [ ] `createSchedule()` - 스케줄 생성
|
||||
- [ ] `updateSchedule()` - 스케줄 수정
|
||||
- [ ] `deleteSchedule()` - 스케줄 삭제
|
||||
|
||||
### 공통 작업
|
||||
|
||||
- [ ] import 문 수정 (모든 서비스)
|
||||
- [ ] Prisma import 완전 제거 (모든 서비스)
|
||||
- [ ] 트랜잭션 로직 확인
|
||||
- [ ] 에러 핸들링 검증
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트 (24개)
|
||||
|
||||
- 각 Prisma 호출별 1개씩
|
||||
|
||||
### 통합 테스트 (8개)
|
||||
|
||||
- BatchExternalDbService: 외부 DB 연동 테스트 (2개)
|
||||
- BatchExecutionLogService: 로그 생성 및 조회 테스트 (2개)
|
||||
- BatchManagementService: 배치 작업 실행 테스트 (2개)
|
||||
- BatchSchedulerService: 스케줄 실행 테스트 (2개)
|
||||
|
||||
### 성능 테스트
|
||||
|
||||
- 대용량 데이터 처리 성능
|
||||
- 동시 배치 실행 성능
|
||||
- 외부 DB 연결 풀 성능
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐⭐⭐ (매우 높음)
|
||||
- 외부 DB 연동
|
||||
- 트랜잭션 처리
|
||||
- 스케줄링 로직
|
||||
- 대용량 데이터 처리
|
||||
- **예상 소요 시간**: 4~5시간
|
||||
- Phase 1 (BatchManagement + ExecutionLog): 1.5시간
|
||||
- Phase 2 (Scheduler): 1시간
|
||||
- Phase 3 (ExternalDb): 2시간
|
||||
- 테스트 및 문서화: 0.5시간
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 중요 체크포인트
|
||||
|
||||
1. ✅ 외부 DB 연결은 반드시 try-finally에서 해제
|
||||
2. ✅ 배치 실행 중 에러 시 롤백 처리
|
||||
3. ✅ Cron 표현식 검증 필수
|
||||
4. ✅ 대용량 데이터는 스트리밍 방식 사용
|
||||
5. ✅ 동시 실행 제한 확인
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- 연결 풀 활용
|
||||
- 배치 쿼리 최적화
|
||||
- 인덱스 확인
|
||||
- 불필요한 로그 제거
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: 외부 DB 연동, 스케줄링, 트랜잭션 처리 포함
|
||||
**⚠️ 주의**: 배치 시스템의 핵심 기능이므로 신중한 테스트 필수!
|
||||
|
|
@ -1,540 +0,0 @@
|
|||
# 📋 Phase 3.16: Data Management Services Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
데이터 관리 관련 서비스들은 총 **18개의 Prisma 호출**이 있으며, 동적 폼, 데이터 매핑, 데이터 서비스, 관리자 기능을 담당합니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ----------------------------------------------------- |
|
||||
| 대상 서비스 | 4개 (EnhancedDynamicForm, DataMapping, Data, Admin) |
|
||||
| 파일 위치 | `backend-node/src/services/{enhanced,data,admin}*.ts` |
|
||||
| 총 파일 크기 | 2,062 라인 |
|
||||
| Prisma 호출 | 0개 (전환 완료) |
|
||||
| **현재 진행률** | **18/18 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 중간 (동적 쿼리, JSON 필드, 관리자 기능) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.16) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역
|
||||
|
||||
### 전환된 Prisma 호출 (18개)
|
||||
|
||||
#### 1. EnhancedDynamicFormService (6개)
|
||||
|
||||
- `validateTableExists()` - $queryRawUnsafe → query
|
||||
- `getTableColumns()` - $queryRawUnsafe → query
|
||||
- `getColumnWebTypes()` - $queryRawUnsafe → query
|
||||
- `getPrimaryKeys()` - $queryRawUnsafe → query
|
||||
- `performInsert()` - $queryRawUnsafe → query
|
||||
- `performUpdate()` - $queryRawUnsafe → query
|
||||
|
||||
#### 2. DataMappingService (5개)
|
||||
|
||||
- `getSourceData()` - $queryRawUnsafe → query
|
||||
- `executeInsert()` - $executeRawUnsafe → query
|
||||
- `executeUpsert()` - $executeRawUnsafe → query
|
||||
- `executeUpdate()` - $executeRawUnsafe → query
|
||||
- `disconnect()` - 제거 (Raw Query는 disconnect 불필요)
|
||||
|
||||
#### 3. DataService (4개)
|
||||
|
||||
- `getTableData()` - $queryRawUnsafe → query
|
||||
- `checkTableExists()` - $queryRawUnsafe → query
|
||||
- `getTableColumnsSimple()` - $queryRawUnsafe → query
|
||||
- `getColumnLabel()` - $queryRawUnsafe → query
|
||||
|
||||
#### 4. AdminService (3개)
|
||||
|
||||
- `getAdminMenuList()` - $queryRaw → query (WITH RECURSIVE)
|
||||
- `getUserMenuList()` - $queryRaw → query (WITH RECURSIVE)
|
||||
- `getMenuInfo()` - findUnique → query (JOIN)
|
||||
|
||||
### 주요 기술적 해결 사항
|
||||
|
||||
1. **변수명 충돌 해결**
|
||||
|
||||
- `dataService.ts`에서 `query` 변수 → `sql` 변수로 변경
|
||||
- `query()` 함수와 로컬 변수 충돌 방지
|
||||
|
||||
2. **WITH RECURSIVE 쿼리 전환**
|
||||
|
||||
- Prisma의 `$queryRaw` 템플릿 리터럴 → 일반 문자열
|
||||
- `${userLang}` → `$1` 파라미터 바인딩
|
||||
|
||||
3. **JOIN 쿼리 전환**
|
||||
|
||||
- Prisma의 `include` 옵션 → `LEFT JOIN` 쿼리
|
||||
- 관계 데이터를 단일 쿼리로 조회
|
||||
|
||||
4. **동적 쿼리 생성**
|
||||
- 동적 WHERE 조건 구성
|
||||
- SQL 인젝션 방지 (컬럼명 검증)
|
||||
- 동적 ORDER BY 처리
|
||||
|
||||
### 컴파일 상태
|
||||
|
||||
✅ TypeScript 컴파일 성공
|
||||
✅ Linter 오류 없음
|
||||
|
||||
---
|
||||
|
||||
## 🔍 서비스별 상세 분석
|
||||
|
||||
### 1. EnhancedDynamicFormService (6개 호출, 786 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 고급 동적 폼 관리
|
||||
- 폼 검증 규칙
|
||||
- 조건부 필드 표시
|
||||
- 폼 템플릿 관리
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getEnhancedForms()` - 고급 폼 목록 조회
|
||||
- `getEnhancedForm()` - 고급 폼 단건 조회
|
||||
- `createEnhancedForm()` - 고급 폼 생성
|
||||
- `updateEnhancedForm()` - 고급 폼 수정
|
||||
- `deleteEnhancedForm()` - 고급 폼 삭제
|
||||
- `getFormValidationRules()` - 검증 규칙 조회
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- JSON 필드 (validation_rules, conditional_logic, field_config)
|
||||
- 복잡한 검증 규칙
|
||||
- 동적 필드 생성
|
||||
- 조건부 표시 로직
|
||||
|
||||
---
|
||||
|
||||
### 2. DataMappingService (5개 호출, 575 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 데이터 매핑 설정 관리
|
||||
- 소스-타겟 필드 매핑
|
||||
- 데이터 변환 규칙
|
||||
- 매핑 실행
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getDataMappings()` - 매핑 설정 목록 조회
|
||||
- `getDataMapping()` - 매핑 설정 단건 조회
|
||||
- `createDataMapping()` - 매핑 설정 생성
|
||||
- `updateDataMapping()` - 매핑 설정 수정
|
||||
- `deleteDataMapping()` - 매핑 설정 삭제
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- JSON 필드 (field_mappings, transformation_rules)
|
||||
- 복잡한 변환 로직
|
||||
- 매핑 검증
|
||||
- 실행 이력 추적
|
||||
|
||||
---
|
||||
|
||||
### 3. DataService (4개 호출, 327 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 동적 데이터 조회
|
||||
- 데이터 필터링
|
||||
- 데이터 정렬
|
||||
- 데이터 집계
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getDataByTable()` - 테이블별 데이터 조회
|
||||
- `getDataById()` - 데이터 단건 조회
|
||||
- `executeCustomQuery()` - 커스텀 쿼리 실행
|
||||
- `getDataStatistics()` - 데이터 통계 조회
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- 동적 테이블 쿼리
|
||||
- SQL 인젝션 방지
|
||||
- 동적 WHERE 조건
|
||||
- 집계 쿼리
|
||||
|
||||
---
|
||||
|
||||
### 4. AdminService (3개 호출, 374 라인)
|
||||
|
||||
**주요 기능**:
|
||||
|
||||
- 관리자 메뉴 관리
|
||||
- 시스템 설정
|
||||
- 사용자 관리
|
||||
- 로그 조회
|
||||
|
||||
**예상 Prisma 호출**:
|
||||
|
||||
- `getAdminMenus()` - 관리자 메뉴 조회
|
||||
- `getSystemSettings()` - 시스템 설정 조회
|
||||
- `updateSystemSettings()` - 시스템 설정 업데이트
|
||||
|
||||
**기술적 고려사항**:
|
||||
|
||||
- 메뉴 계층 구조
|
||||
- 권한 기반 필터링
|
||||
- JSON 설정 필드
|
||||
- 캐싱
|
||||
|
||||
---
|
||||
|
||||
## 💡 통합 전환 전략
|
||||
|
||||
### Phase 1: 단순 CRUD 전환 (12개)
|
||||
|
||||
**EnhancedDynamicFormService (6개) + DataMappingService (5개) + AdminService (1개)**
|
||||
|
||||
- 기본 CRUD 기능
|
||||
- JSON 필드 처리
|
||||
|
||||
### Phase 2: 동적 쿼리 전환 (4개)
|
||||
|
||||
**DataService (4개)**
|
||||
|
||||
- 동적 테이블 쿼리
|
||||
- 보안 검증
|
||||
|
||||
### Phase 3: 고급 기능 전환 (2개)
|
||||
|
||||
**AdminService (2개)**
|
||||
|
||||
- 시스템 설정
|
||||
- 캐싱
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 고급 폼 생성 (JSON 필드)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const form = await prisma.enhanced_forms.create({
|
||||
data: {
|
||||
form_code: formCode,
|
||||
form_name: formName,
|
||||
validation_rules: validationRules, // JSON
|
||||
conditional_logic: conditionalLogic, // JSON
|
||||
field_config: fieldConfig, // JSON
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const form = await queryOne<any>(
|
||||
`INSERT INTO enhanced_forms
|
||||
(form_code, form_name, validation_rules, conditional_logic,
|
||||
field_config, company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
formCode,
|
||||
formName,
|
||||
JSON.stringify(validationRules),
|
||||
JSON.stringify(conditionalLogic),
|
||||
JSON.stringify(fieldConfig),
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 데이터 매핑 조회
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const mappings = await prisma.data_mappings.findMany({
|
||||
where: {
|
||||
source_table: sourceTable,
|
||||
target_table: targetTable,
|
||||
is_active: true,
|
||||
},
|
||||
include: {
|
||||
source_columns: true,
|
||||
target_columns: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
const mappings = await query<any>(
|
||||
`SELECT
|
||||
dm.*,
|
||||
json_agg(DISTINCT jsonb_build_object(
|
||||
'column_id', sc.column_id,
|
||||
'column_name', sc.column_name
|
||||
)) FILTER (WHERE sc.column_id IS NOT NULL) as source_columns,
|
||||
json_agg(DISTINCT jsonb_build_object(
|
||||
'column_id', tc.column_id,
|
||||
'column_name', tc.column_name
|
||||
)) FILTER (WHERE tc.column_id IS NOT NULL) as target_columns
|
||||
FROM data_mappings dm
|
||||
LEFT JOIN columns sc ON dm.mapping_id = sc.mapping_id AND sc.type = 'source'
|
||||
LEFT JOIN columns tc ON dm.mapping_id = tc.mapping_id AND tc.type = 'target'
|
||||
WHERE dm.source_table = $1
|
||||
AND dm.target_table = $2
|
||||
AND dm.is_active = $3
|
||||
GROUP BY dm.mapping_id`,
|
||||
[sourceTable, targetTable, true]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 동적 테이블 쿼리 (DataService)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
// Prisma로는 동적 테이블 쿼리 불가능
|
||||
// 이미 $queryRawUnsafe 사용 중일 가능성
|
||||
const data = await prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM ${tableName} WHERE ${whereClause}`,
|
||||
...params
|
||||
);
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
// SQL 인젝션 방지를 위한 테이블명 검증
|
||||
const validTableName = validateTableName(tableName);
|
||||
|
||||
const data = await query<any>(
|
||||
`SELECT * FROM ${validTableName} WHERE ${whereClause}`,
|
||||
params
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 4: 관리자 메뉴 조회 (계층 구조)
|
||||
|
||||
**변경 전**:
|
||||
|
||||
```typescript
|
||||
const menus = await prisma.admin_menus.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: { sort_order: "asc" },
|
||||
include: {
|
||||
children: {
|
||||
orderBy: { sort_order: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
|
||||
```typescript
|
||||
// 재귀 CTE를 사용한 계층 쿼리
|
||||
const menus = await query<any>(
|
||||
`WITH RECURSIVE menu_tree AS (
|
||||
SELECT *, 0 as level, ARRAY[menu_id] as path
|
||||
FROM admin_menus
|
||||
WHERE parent_id IS NULL AND is_active = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT m.*, mt.level + 1, mt.path || m.menu_id
|
||||
FROM admin_menus m
|
||||
JOIN menu_tree mt ON m.parent_id = mt.menu_id
|
||||
WHERE m.is_active = $1
|
||||
)
|
||||
SELECT * FROM menu_tree
|
||||
ORDER BY path, sort_order`,
|
||||
[true]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. JSON 필드 처리
|
||||
|
||||
```typescript
|
||||
// 복잡한 JSON 구조
|
||||
interface ValidationRules {
|
||||
required?: string[];
|
||||
min?: Record<string, number>;
|
||||
max?: Record<string, number>;
|
||||
pattern?: Record<string, string>;
|
||||
custom?: Array<{ field: string; rule: string }>;
|
||||
}
|
||||
|
||||
// 저장 시
|
||||
JSON.stringify(validationRules);
|
||||
|
||||
// 조회 후
|
||||
const parsed =
|
||||
typeof row.validation_rules === "string"
|
||||
? JSON.parse(row.validation_rules)
|
||||
: row.validation_rules;
|
||||
```
|
||||
|
||||
### 2. 동적 테이블 쿼리 보안
|
||||
|
||||
```typescript
|
||||
// 테이블명 화이트리스트
|
||||
const ALLOWED_TABLES = ["users", "products", "orders"];
|
||||
|
||||
function validateTableName(tableName: string): string {
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
throw new Error("Invalid table name");
|
||||
}
|
||||
return tableName;
|
||||
}
|
||||
|
||||
// 컬럼명 검증
|
||||
function validateColumnName(columnName: string): string {
|
||||
if (!/^[a-z_][a-z0-9_]*$/i.test(columnName)) {
|
||||
throw new Error("Invalid column name");
|
||||
}
|
||||
return columnName;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 재귀 CTE (계층 구조)
|
||||
|
||||
```sql
|
||||
WITH RECURSIVE hierarchy AS (
|
||||
-- 최상위 노드
|
||||
SELECT * FROM table WHERE parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 하위 노드
|
||||
SELECT t.* FROM table t
|
||||
JOIN hierarchy h ON t.parent_id = h.id
|
||||
)
|
||||
SELECT * FROM hierarchy
|
||||
```
|
||||
|
||||
### 4. JSON 집계 (관계 데이터)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
parent.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
jsonb_build_object('id', child.id, 'name', child.name)
|
||||
) FILTER (WHERE child.id IS NOT NULL),
|
||||
'[]'
|
||||
) as children
|
||||
FROM parent
|
||||
LEFT JOIN child ON parent.id = child.parent_id
|
||||
GROUP BY parent.id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 체크리스트
|
||||
|
||||
### EnhancedDynamicFormService (6개)
|
||||
|
||||
- [ ] `getEnhancedForms()` - 목록 조회
|
||||
- [ ] `getEnhancedForm()` - 단건 조회
|
||||
- [ ] `createEnhancedForm()` - 생성 (JSON 필드)
|
||||
- [ ] `updateEnhancedForm()` - 수정 (JSON 필드)
|
||||
- [ ] `deleteEnhancedForm()` - 삭제
|
||||
- [ ] `getFormValidationRules()` - 검증 규칙 조회
|
||||
|
||||
### DataMappingService (5개)
|
||||
|
||||
- [ ] `getDataMappings()` - 목록 조회
|
||||
- [ ] `getDataMapping()` - 단건 조회
|
||||
- [ ] `createDataMapping()` - 생성
|
||||
- [ ] `updateDataMapping()` - 수정
|
||||
- [ ] `deleteDataMapping()` - 삭제
|
||||
|
||||
### DataService (4개)
|
||||
|
||||
- [ ] `getDataByTable()` - 동적 테이블 조회
|
||||
- [ ] `getDataById()` - 단건 조회
|
||||
- [ ] `executeCustomQuery()` - 커스텀 쿼리
|
||||
- [ ] `getDataStatistics()` - 통계 조회
|
||||
|
||||
### AdminService (3개)
|
||||
|
||||
- [ ] `getAdminMenus()` - 메뉴 조회 (재귀 CTE)
|
||||
- [ ] `getSystemSettings()` - 시스템 설정 조회
|
||||
- [ ] `updateSystemSettings()` - 시스템 설정 업데이트
|
||||
|
||||
### 공통 작업
|
||||
|
||||
- [ ] import 문 수정 (모든 서비스)
|
||||
- [ ] Prisma import 완전 제거
|
||||
- [ ] JSON 필드 처리 확인
|
||||
- [ ] 보안 검증 (SQL 인젝션)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 단위 테스트 (18개)
|
||||
|
||||
- 각 Prisma 호출별 1개씩
|
||||
|
||||
### 통합 테스트 (6개)
|
||||
|
||||
- EnhancedDynamicFormService: 폼 생성 및 검증 테스트 (2개)
|
||||
- DataMappingService: 매핑 설정 및 실행 테스트 (2개)
|
||||
- DataService: 동적 쿼리 및 보안 테스트 (1개)
|
||||
- AdminService: 메뉴 계층 구조 테스트 (1개)
|
||||
|
||||
### 보안 테스트
|
||||
|
||||
- SQL 인젝션 방지 테스트
|
||||
- 테이블명 검증 테스트
|
||||
- 컬럼명 검증 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 난이도 및 소요 시간
|
||||
|
||||
- **난이도**: ⭐⭐⭐⭐ (높음)
|
||||
- JSON 필드 처리
|
||||
- 동적 쿼리 보안
|
||||
- 재귀 CTE
|
||||
- JSON 집계
|
||||
- **예상 소요 시간**: 2.5~3시간
|
||||
- Phase 1 (기본 CRUD): 1시간
|
||||
- Phase 2 (동적 쿼리): 1시간
|
||||
- Phase 3 (고급 기능): 0.5시간
|
||||
- 테스트 및 문서화: 0.5시간
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 보안 필수 체크리스트
|
||||
|
||||
1. ✅ 동적 테이블명은 반드시 화이트리스트 검증
|
||||
2. ✅ 동적 컬럼명은 정규식으로 검증
|
||||
3. ✅ WHERE 절 파라미터는 반드시 바인딩
|
||||
4. ✅ JSON 필드는 파싱 에러 처리
|
||||
5. ✅ 재귀 쿼리는 깊이 제한 설정
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- JSON 필드 인덱싱 (GIN 인덱스)
|
||||
- 재귀 쿼리 깊이 제한
|
||||
- 집계 쿼리 최적화
|
||||
- 필요시 캐싱 적용
|
||||
|
||||
---
|
||||
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: JSON 필드, 동적 쿼리, 재귀 CTE, 보안 검증 포함
|
||||
**⚠️ 주의**: 동적 쿼리는 SQL 인젝션 방지가 매우 중요!
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
# 📋 Phase 3.17: ReferenceCacheService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
ReferenceCacheService는 **0개의 Prisma 호출**이 있으며, 참조 데이터 캐싱을 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ---------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/referenceCacheService.ts` |
|
||||
| 파일 크기 | 499 라인 |
|
||||
| Prisma 호출 | 0개 (이미 전환 완료) |
|
||||
| **현재 진행률** | **3/3 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 낮음 (캐싱 로직) |
|
||||
| 우선순위 | 🟢 낮음 (Phase 3.17) |
|
||||
| **상태** | ✅ **완료** (이미 전환 완료됨) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역 (이미 완료됨)
|
||||
|
||||
ReferenceCacheService는 이미 Raw Query로 전환이 완료되었습니다.
|
||||
|
||||
### 주요 기능
|
||||
|
||||
1. **참조 데이터 캐싱**
|
||||
|
||||
- 자주 사용되는 참조 테이블 데이터를 메모리에 캐싱
|
||||
- 성능 향상을 위한 캐시 전략
|
||||
|
||||
2. **캐시 관리**
|
||||
|
||||
- 캐시 갱신 로직
|
||||
- TTL(Time To Live) 관리
|
||||
- 캐시 무효화
|
||||
|
||||
3. **데이터 조회 최적화**
|
||||
- 캐시 히트/미스 처리
|
||||
- 백그라운드 갱신
|
||||
|
||||
### 기술적 특징
|
||||
|
||||
- **메모리 캐싱**: Map/Object 기반 인메모리 캐싱
|
||||
- **성능 최적화**: 반복 DB 조회 최소화
|
||||
- **자동 갱신**: 주기적 캐시 갱신 로직
|
||||
|
||||
### 코드 상태
|
||||
|
||||
- [x] Prisma import 없음
|
||||
- [x] query 함수 사용 중
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] 캐싱 로직 정상 동작
|
||||
|
||||
---
|
||||
|
||||
## 📝 비고
|
||||
|
||||
이 서비스는 이미 Raw Query로 전환이 완료되어 있어 추가 작업이 필요하지 않습니다.
|
||||
|
||||
**상태**: ✅ **완료**
|
||||
**특이사항**: 캐싱 로직으로 성능에 중요한 서비스
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
# 📋 Phase 3.18: DDLExecutionService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DDLExecutionService는 **0개의 Prisma 호출**이 있으며, DDL 실행 및 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/ddlExecutionService.ts` |
|
||||
| 파일 크기 | 786 라인 |
|
||||
| Prisma 호출 | 0개 (이미 전환 완료) |
|
||||
| **현재 진행률** | **6/6 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 높음 (DDL 실행, 안전성 검증) |
|
||||
| 우선순위 | 🔴 높음 (Phase 3.18) |
|
||||
| **상태** | ✅ **완료** (이미 전환 완료됨) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 전환 완료 내역 (이미 완료됨)
|
||||
|
||||
DDLExecutionService는 이미 Raw Query로 전환이 완료되었습니다.
|
||||
|
||||
### 주요 기능
|
||||
|
||||
1. **테이블 생성 (CREATE TABLE)**
|
||||
|
||||
- 동적 테이블 생성
|
||||
- 컬럼 정의 및 제약조건
|
||||
- 인덱스 생성
|
||||
|
||||
2. **컬럼 추가 (ADD COLUMN)**
|
||||
|
||||
- 기존 테이블에 컬럼 추가
|
||||
- 데이터 타입 검증
|
||||
- 기본값 설정
|
||||
|
||||
3. **테이블/컬럼 삭제 (DROP)**
|
||||
|
||||
- 안전한 삭제 검증
|
||||
- 의존성 체크
|
||||
- 롤백 가능성
|
||||
|
||||
4. **DDL 안전성 검증**
|
||||
|
||||
- DDL 실행 전 검증
|
||||
- 순환 참조 방지
|
||||
- 데이터 손실 방지
|
||||
|
||||
5. **DDL 실행 이력**
|
||||
|
||||
- 모든 DDL 실행 기록
|
||||
- 성공/실패 로그
|
||||
- 롤백 정보
|
||||
|
||||
6. **트랜잭션 관리**
|
||||
- DDL 트랜잭션 처리
|
||||
- 에러 시 롤백
|
||||
- 일관성 유지
|
||||
|
||||
### 기술적 특징
|
||||
|
||||
- **동적 DDL 생성**: 파라미터 기반 DDL 쿼리 생성
|
||||
- **안전성 검증**: 실행 전 다중 검증 단계
|
||||
- **감사 로깅**: DDLAuditLogger와 연동
|
||||
- **PostgreSQL 특화**: PostgreSQL DDL 문법 활용
|
||||
|
||||
### 보안 및 안전성
|
||||
|
||||
- **SQL 인젝션 방지**: 테이블/컬럼명 화이트리스트 검증
|
||||
- **권한 검증**: 사용자 권한 확인
|
||||
- **백업 권장**: DDL 실행 전 백업 체크
|
||||
- **복구 가능성**: 실행 이력 기록
|
||||
|
||||
### 코드 상태
|
||||
|
||||
- [x] Prisma import 없음
|
||||
- [x] query 함수 사용 중
|
||||
- [x] TypeScript 컴파일 성공
|
||||
- [x] 안전성 검증 로직 유지
|
||||
- [x] DDLAuditLogger 연동
|
||||
|
||||
---
|
||||
|
||||
## 📝 비고
|
||||
|
||||
이 서비스는 이미 Raw Query로 전환이 완료되어 있어 추가 작업이 필요하지 않습니다.
|
||||
|
||||
**상태**: ✅ **완료**
|
||||
**특이사항**: DDL 실행의 핵심 서비스로 안전성이 매우 중요
|
||||
**⚠️ 주의**: 프로덕션 환경에서 DDL 실행 시 각별한 주의 필요
|
||||
|
|
@ -1,369 +0,0 @@
|
|||
# 🎨 Phase 3.7: LayoutService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
LayoutService는 **10개의 Prisma 호출**이 있으며, 레이아웃 표준 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/services/layoutService.ts` |
|
||||
| 파일 크기 | 425+ 라인 |
|
||||
| Prisma 호출 | 10개 |
|
||||
| **현재 진행률** | **0/10 (0%)** 🔄 **진행 예정** |
|
||||
| 복잡도 | 중간 (JSON 필드, 검색, 통계) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.7) |
|
||||
| **상태** | ⏳ **대기 중** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **10개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ JSON 필드 처리 (layout_config, sections)
|
||||
- ⏳ 복잡한 검색 조건 처리
|
||||
- ⏳ GROUP BY 통계 쿼리 전환
|
||||
- ⏳ 모든 단위 테스트 통과
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 Prisma 호출 (10개)
|
||||
|
||||
#### 1. **getLayouts()** - 레이아웃 목록 조회
|
||||
```typescript
|
||||
// Line 92, 102
|
||||
const total = await prisma.layout_standards.count({ where });
|
||||
const layouts = await prisma.layout_standards.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: size,
|
||||
orderBy: { updated_date: "desc" },
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. **getLayoutByCode()** - 레이아웃 단건 조회
|
||||
```typescript
|
||||
// Line 152
|
||||
const layout = await prisma.layout_standards.findFirst({
|
||||
where: { layout_code: code, company_code: companyCode },
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. **createLayout()** - 레이아웃 생성
|
||||
```typescript
|
||||
// Line 199
|
||||
const layout = await prisma.layout_standards.create({
|
||||
data: {
|
||||
layout_code,
|
||||
layout_name,
|
||||
layout_type,
|
||||
category,
|
||||
layout_config: safeJSONStringify(layout_config),
|
||||
sections: safeJSONStringify(sections),
|
||||
// ... 기타 필드
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. **updateLayout()** - 레이아웃 수정
|
||||
```typescript
|
||||
// Line 230, 267
|
||||
const existing = await prisma.layout_standards.findFirst({
|
||||
where: { layout_code: code, company_code: companyCode },
|
||||
});
|
||||
|
||||
const updated = await prisma.layout_standards.update({
|
||||
where: { id: existing.id },
|
||||
data: { ... },
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. **deleteLayout()** - 레이아웃 삭제
|
||||
```typescript
|
||||
// Line 283, 295
|
||||
const existing = await prisma.layout_standards.findFirst({
|
||||
where: { layout_code: code, company_code: companyCode },
|
||||
});
|
||||
|
||||
await prisma.layout_standards.update({
|
||||
where: { id: existing.id },
|
||||
data: { is_active: "N", updated_by, updated_date: new Date() },
|
||||
});
|
||||
```
|
||||
|
||||
#### 6. **getLayoutStatistics()** - 레이아웃 통계
|
||||
```typescript
|
||||
// Line 345
|
||||
const counts = await prisma.layout_standards.groupBy({
|
||||
by: ["category", "layout_type"],
|
||||
where: { company_code: companyCode, is_active: "Y" },
|
||||
_count: { id: true },
|
||||
});
|
||||
```
|
||||
|
||||
#### 7. **getLayoutCategories()** - 카테고리 목록
|
||||
```typescript
|
||||
// Line 373
|
||||
const existingCodes = await prisma.layout_standards.findMany({
|
||||
where: { company_code: companyCode },
|
||||
select: { category: true },
|
||||
distinct: ["category"],
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 계획
|
||||
|
||||
### 1단계: 기본 CRUD 전환 (5개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
- `getLayouts()` - 목록 조회 (count + findMany)
|
||||
- `getLayoutByCode()` - 단건 조회 (findFirst)
|
||||
- `createLayout()` - 생성 (create)
|
||||
- `updateLayout()` - 수정 (findFirst + update)
|
||||
- `deleteLayout()` - 삭제 (findFirst + update - soft delete)
|
||||
|
||||
### 2단계: 통계 및 집계 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
- `getLayoutStatistics()` - 통계 (groupBy)
|
||||
- `getLayoutCategories()` - 카테고리 목록 (findMany + distinct)
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 레이아웃 목록 조회 (동적 WHERE + 페이지네이션)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const where: any = { company_code: companyCode };
|
||||
if (category) where.category = category;
|
||||
if (layoutType) where.layout_type = layoutType;
|
||||
if (searchTerm) {
|
||||
where.OR = [
|
||||
{ layout_name: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ layout_code: { contains: searchTerm, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const total = await prisma.layout_standards.count({ where });
|
||||
const layouts = await prisma.layout_standards.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: size,
|
||||
orderBy: { updated_date: "desc" },
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
const whereConditions: string[] = ["company_code = $1"];
|
||||
const values: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (category) {
|
||||
whereConditions.push(`category = $${paramIndex++}`);
|
||||
values.push(category);
|
||||
}
|
||||
if (layoutType) {
|
||||
whereConditions.push(`layout_type = $${paramIndex++}`);
|
||||
values.push(layoutType);
|
||||
}
|
||||
if (searchTerm) {
|
||||
whereConditions.push(
|
||||
`(layout_name ILIKE $${paramIndex} OR layout_code ILIKE $${paramIndex})`
|
||||
);
|
||||
values.push(`%${searchTerm}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||
|
||||
// 총 개수 조회
|
||||
const countResult = await queryOne<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM layout_standards ${whereClause}`,
|
||||
values
|
||||
);
|
||||
const total = parseInt(countResult?.count || "0");
|
||||
|
||||
// 데이터 조회
|
||||
const layouts = await query<any>(
|
||||
`SELECT * FROM layout_standards
|
||||
${whereClause}
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...values, size, skip]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: JSON 필드 처리 (레이아웃 생성)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const layout = await prisma.layout_standards.create({
|
||||
data: {
|
||||
layout_code,
|
||||
layout_name,
|
||||
layout_config: safeJSONStringify(layout_config), // JSON 필드
|
||||
sections: safeJSONStringify(sections), // JSON 필드
|
||||
company_code: companyCode,
|
||||
created_by: createdBy,
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const layout = await queryOne<any>(
|
||||
`INSERT INTO layout_standards
|
||||
(layout_code, layout_name, layout_type, category, layout_config, sections,
|
||||
company_code, is_active, created_by, updated_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
layout_code,
|
||||
layout_name,
|
||||
layout_type,
|
||||
category,
|
||||
safeJSONStringify(layout_config), // JSON 필드는 문자열로 변환
|
||||
safeJSONStringify(sections),
|
||||
companyCode,
|
||||
"Y",
|
||||
createdBy,
|
||||
updatedBy,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: GROUP BY 통계 쿼리
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const counts = await prisma.layout_standards.groupBy({
|
||||
by: ["category", "layout_type"],
|
||||
where: { company_code: companyCode, is_active: "Y" },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const counts = await query<{
|
||||
category: string;
|
||||
layout_type: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT category, layout_type, COUNT(*) as count
|
||||
FROM layout_standards
|
||||
WHERE company_code = $1 AND is_active = $2
|
||||
GROUP BY category, layout_type`,
|
||||
[companyCode, "Y"]
|
||||
);
|
||||
|
||||
// 결과 포맷팅
|
||||
const formattedCounts = counts.map((row) => ({
|
||||
category: row.category,
|
||||
layout_type: row.layout_type,
|
||||
_count: { id: parseInt(row.count) },
|
||||
}));
|
||||
```
|
||||
|
||||
### 예시 4: DISTINCT 쿼리 (카테고리 목록)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const existingCodes = await prisma.layout_standards.findMany({
|
||||
where: { company_code: companyCode },
|
||||
select: { category: true },
|
||||
distinct: ["category"],
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const existingCodes = await query<{ category: string }>(
|
||||
`SELECT DISTINCT category
|
||||
FROM layout_standards
|
||||
WHERE company_code = $1
|
||||
ORDER BY category`,
|
||||
[companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 기준
|
||||
|
||||
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||
- [ ] **동적 WHERE 조건 생성 (ILIKE, OR)**
|
||||
- [ ] **JSON 필드 처리 (layout_config, sections)**
|
||||
- [ ] **GROUP BY 집계 쿼리 전환**
|
||||
- [ ] **DISTINCT 쿼리 전환**
|
||||
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||
- [ ] **`import prisma` 완전 제거**
|
||||
- [ ] **모든 단위 테스트 통과 (10개)**
|
||||
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 주요 기술적 과제
|
||||
|
||||
### 1. JSON 필드 처리
|
||||
- `layout_config`, `sections` 필드는 JSON 타입
|
||||
- INSERT/UPDATE 시 `JSON.stringify()` 또는 `safeJSONStringify()` 사용
|
||||
- SELECT 시 PostgreSQL이 자동으로 JSON 객체로 반환
|
||||
|
||||
### 2. 동적 검색 조건
|
||||
- category, layoutType, searchTerm에 따른 동적 WHERE 절
|
||||
- OR 조건 처리 (layout_name OR layout_code)
|
||||
|
||||
### 3. Soft Delete
|
||||
- `deleteLayout()`는 실제 삭제가 아닌 `is_active = 'N'` 업데이트
|
||||
- UPDATE 쿼리 사용
|
||||
|
||||
### 4. 통계 쿼리
|
||||
- `groupBy` → `GROUP BY` + `COUNT(*)` 전환
|
||||
- 결과 포맷팅 필요 (`_count.id` 형태로 변환)
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 코드 전환
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] getLayouts() - count + findMany → query + queryOne
|
||||
- [ ] getLayoutByCode() - findFirst → queryOne
|
||||
- [ ] createLayout() - create → queryOne (INSERT)
|
||||
- [ ] updateLayout() - findFirst + update → queryOne (동적 UPDATE)
|
||||
- [ ] deleteLayout() - findFirst + update → queryOne (UPDATE is_active)
|
||||
- [ ] getLayoutStatistics() - groupBy → query (GROUP BY)
|
||||
- [ ] getLayoutCategories() - findMany + distinct → query (DISTINCT)
|
||||
- [ ] JSON 필드 처리 확인 (safeJSONStringify)
|
||||
- [ ] Prisma import 완전 제거
|
||||
|
||||
### 테스트
|
||||
- [ ] 단위 테스트 작성 (10개)
|
||||
- [ ] 통합 테스트 작성 (3개)
|
||||
- [ ] TypeScript 컴파일 성공
|
||||
- [ ] 성능 벤치마크 테스트
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### JSON 필드 헬퍼 함수
|
||||
이 서비스는 `safeJSONParse()`, `safeJSONStringify()` 헬퍼 함수를 사용하여 JSON 필드를 안전하게 처리합니다. Raw Query 전환 후에도 이 함수들을 계속 사용해야 합니다.
|
||||
|
||||
### Soft Delete 패턴
|
||||
레이아웃 삭제는 실제 DELETE가 아닌 `is_active = 'N'` 업데이트로 처리되므로, UPDATE 쿼리를 사용해야 합니다.
|
||||
|
||||
### 통계 쿼리 결과 포맷
|
||||
Prisma의 `groupBy`는 `_count: { id: number }` 형태로 반환하지만, Raw Query는 `count: string`으로 반환하므로 포맷팅이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-01
|
||||
**예상 소요 시간**: 1시간
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟡 중간 (Phase 3.7)
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: JSON 필드 처리, GROUP BY, DISTINCT 쿼리 포함
|
||||
|
||||
|
|
@ -1,484 +0,0 @@
|
|||
# 🗂️ Phase 3.8: DbTypeCategoryService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DbTypeCategoryService는 **10개의 Prisma 호출**이 있으며, 데이터베이스 타입 카테고리 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| 파일 위치 | `backend-node/src/services/dbTypeCategoryService.ts` |
|
||||
| 파일 크기 | 320+ 라인 |
|
||||
| Prisma 호출 | 10개 |
|
||||
| **현재 진행률** | **0/10 (0%)** 🔄 **진행 예정** |
|
||||
| 복잡도 | 중간 (CRUD, 통계, UPSERT) |
|
||||
| 우선순위 | 🟡 중간 (Phase 3.8) |
|
||||
| **상태** | ⏳ **대기 중** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ⏳ **10개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ⏳ ApiResponse 래퍼 패턴 유지
|
||||
- ⏳ GROUP BY 통계 쿼리 전환
|
||||
- ⏳ UPSERT 로직 전환 (ON CONFLICT)
|
||||
- ⏳ 모든 단위 테스트 통과
|
||||
- ⏳ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 Prisma 호출 (10개)
|
||||
|
||||
#### 1. **getAllCategories()** - 카테고리 목록 조회
|
||||
```typescript
|
||||
// Line 45
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: [
|
||||
{ sort_order: 'asc' },
|
||||
{ display_name: 'asc' }
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. **getCategoryByTypeCode()** - 카테고리 단건 조회
|
||||
```typescript
|
||||
// Line 73
|
||||
const category = await prisma.db_type_categories.findUnique({
|
||||
where: { type_code: typeCode }
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. **createCategory()** - 카테고리 생성
|
||||
```typescript
|
||||
// Line 105, 116
|
||||
const existing = await prisma.db_type_categories.findUnique({
|
||||
where: { type_code: data.type_code }
|
||||
});
|
||||
|
||||
const category = await prisma.db_type_categories.create({
|
||||
data: {
|
||||
type_code: data.type_code,
|
||||
display_name: data.display_name,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
sort_order: data.sort_order ?? 0,
|
||||
is_active: true,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. **updateCategory()** - 카테고리 수정
|
||||
```typescript
|
||||
// Line 146
|
||||
const category = await prisma.db_type_categories.update({
|
||||
where: { type_code: typeCode },
|
||||
data: updateData
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. **deleteCategory()** - 카테고리 삭제 (연결 확인)
|
||||
```typescript
|
||||
// Line 179, 193
|
||||
const connectionsCount = await prisma.external_db_connections.count({
|
||||
where: { db_type: typeCode }
|
||||
});
|
||||
|
||||
await prisma.db_type_categories.update({
|
||||
where: { type_code: typeCode },
|
||||
data: { is_active: false }
|
||||
});
|
||||
```
|
||||
|
||||
#### 6. **getCategoryStatistics()** - 카테고리별 통계
|
||||
```typescript
|
||||
// Line 220, 229
|
||||
const stats = await prisma.external_db_connections.groupBy({
|
||||
by: ['db_type'],
|
||||
_count: { id: true }
|
||||
});
|
||||
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true }
|
||||
});
|
||||
```
|
||||
|
||||
#### 7. **syncPredefinedCategories()** - 사전 정의 카테고리 동기화
|
||||
```typescript
|
||||
// Line 300
|
||||
await prisma.db_type_categories.upsert({
|
||||
where: { type_code: category.type_code },
|
||||
update: {
|
||||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order,
|
||||
},
|
||||
create: {
|
||||
type_code: category.type_code,
|
||||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 계획
|
||||
|
||||
### 1단계: 기본 CRUD 전환 (5개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
- `getAllCategories()` - 목록 조회 (findMany)
|
||||
- `getCategoryByTypeCode()` - 단건 조회 (findUnique)
|
||||
- `createCategory()` - 생성 (findUnique + create)
|
||||
- `updateCategory()` - 수정 (update)
|
||||
- `deleteCategory()` - 삭제 (count + update - soft delete)
|
||||
|
||||
### 2단계: 통계 및 UPSERT 전환 (2개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
- `getCategoryStatistics()` - 통계 (groupBy + findMany)
|
||||
- `syncPredefinedCategories()` - 동기화 (upsert)
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 카테고리 목록 조회 (정렬)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: [
|
||||
{ sort_order: 'asc' },
|
||||
{ display_name: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
|
||||
const categories = await query<DbTypeCategory>(
|
||||
`SELECT * FROM db_type_categories
|
||||
WHERE is_active = $1
|
||||
ORDER BY sort_order ASC, display_name ASC`,
|
||||
[true]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 카테고리 생성 (중복 확인)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const existing = await prisma.db_type_categories.findUnique({
|
||||
where: { type_code: data.type_code }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
message: "이미 존재하는 타입 코드입니다."
|
||||
};
|
||||
}
|
||||
|
||||
const category = await prisma.db_type_categories.create({
|
||||
data: {
|
||||
type_code: data.type_code,
|
||||
display_name: data.display_name,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
sort_order: data.sort_order ?? 0,
|
||||
is_active: true,
|
||||
}
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
const existing = await queryOne<DbTypeCategory>(
|
||||
`SELECT * FROM db_type_categories WHERE type_code = $1`,
|
||||
[data.type_code]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
message: "이미 존재하는 타입 코드입니다."
|
||||
};
|
||||
}
|
||||
|
||||
const category = await queryOne<DbTypeCategory>(
|
||||
`INSERT INTO db_type_categories
|
||||
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
data.type_code,
|
||||
data.display_name,
|
||||
data.icon || null,
|
||||
data.color || null,
|
||||
data.sort_order ?? 0,
|
||||
true,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 동적 UPDATE (변경된 필드만)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const updateData: any = {};
|
||||
if (data.display_name !== undefined) updateData.display_name = data.display_name;
|
||||
if (data.icon !== undefined) updateData.icon = data.icon;
|
||||
if (data.color !== undefined) updateData.color = data.color;
|
||||
if (data.sort_order !== undefined) updateData.sort_order = data.sort_order;
|
||||
if (data.is_active !== undefined) updateData.is_active = data.is_active;
|
||||
|
||||
const category = await prisma.db_type_categories.update({
|
||||
where: { type_code: typeCode },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const updateFields: string[] = ["updated_at = NOW()"];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.display_name !== undefined) {
|
||||
updateFields.push(`display_name = $${paramIndex++}`);
|
||||
values.push(data.display_name);
|
||||
}
|
||||
if (data.icon !== undefined) {
|
||||
updateFields.push(`icon = $${paramIndex++}`);
|
||||
values.push(data.icon);
|
||||
}
|
||||
if (data.color !== undefined) {
|
||||
updateFields.push(`color = $${paramIndex++}`);
|
||||
values.push(data.color);
|
||||
}
|
||||
if (data.sort_order !== undefined) {
|
||||
updateFields.push(`sort_order = $${paramIndex++}`);
|
||||
values.push(data.sort_order);
|
||||
}
|
||||
if (data.is_active !== undefined) {
|
||||
updateFields.push(`is_active = $${paramIndex++}`);
|
||||
values.push(data.is_active);
|
||||
}
|
||||
|
||||
const category = await queryOne<DbTypeCategory>(
|
||||
`UPDATE db_type_categories
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE type_code = $${paramIndex}
|
||||
RETURNING *`,
|
||||
[...values, typeCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 4: 삭제 전 연결 확인
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const connectionsCount = await prisma.external_db_connections.count({
|
||||
where: { db_type: typeCode }
|
||||
});
|
||||
|
||||
if (connectionsCount > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.db_type_categories.update({
|
||||
where: { type_code: typeCode },
|
||||
data: { is_active: false }
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const countResult = await queryOne<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM external_db_connections WHERE db_type = $1`,
|
||||
[typeCode]
|
||||
);
|
||||
const connectionsCount = parseInt(countResult?.count || "0");
|
||||
|
||||
if (connectionsCount > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
|
||||
};
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE db_type_categories SET is_active = $1, updated_at = NOW() WHERE type_code = $2`,
|
||||
[false, typeCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 5: GROUP BY 통계 + JOIN
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const stats = await prisma.external_db_connections.groupBy({
|
||||
by: ['db_type'],
|
||||
_count: { id: true }
|
||||
});
|
||||
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true }
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const stats = await query<{
|
||||
type_code: string;
|
||||
display_name: string;
|
||||
connection_count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
c.type_code,
|
||||
c.display_name,
|
||||
COUNT(e.id) as connection_count
|
||||
FROM db_type_categories c
|
||||
LEFT JOIN external_db_connections e ON c.type_code = e.db_type
|
||||
WHERE c.is_active = $1
|
||||
GROUP BY c.type_code, c.display_name
|
||||
ORDER BY c.sort_order ASC`,
|
||||
[true]
|
||||
);
|
||||
|
||||
// 결과 포맷팅
|
||||
const result = stats.map(row => ({
|
||||
type_code: row.type_code,
|
||||
display_name: row.display_name,
|
||||
connection_count: parseInt(row.connection_count),
|
||||
}));
|
||||
```
|
||||
|
||||
### 예시 6: UPSERT (ON CONFLICT)
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
await prisma.db_type_categories.upsert({
|
||||
where: { type_code: category.type_code },
|
||||
update: {
|
||||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order,
|
||||
},
|
||||
create: {
|
||||
type_code: category.type_code,
|
||||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
await query(
|
||||
`INSERT INTO db_type_categories
|
||||
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (type_code)
|
||||
DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
icon = EXCLUDED.icon,
|
||||
color = EXCLUDED.color,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
category.type_code,
|
||||
category.display_name,
|
||||
category.icon || null,
|
||||
category.color || null,
|
||||
category.sort_order || 0,
|
||||
true,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 기준
|
||||
|
||||
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||
- [ ] **동적 UPDATE 쿼리 생성**
|
||||
- [ ] **GROUP BY + LEFT JOIN 통계 쿼리**
|
||||
- [ ] **ON CONFLICT를 사용한 UPSERT**
|
||||
- [ ] **ApiResponse 래퍼 패턴 유지**
|
||||
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||
- [ ] **`import prisma` 완전 제거**
|
||||
- [ ] **모든 단위 테스트 통과 (10개)**
|
||||
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 주요 기술적 과제
|
||||
|
||||
### 1. ApiResponse 래퍼 패턴
|
||||
모든 함수가 `ApiResponse<T>` 타입을 반환하므로, 에러 처리를 try-catch로 감싸고 일관된 응답 형식을 유지해야 합니다.
|
||||
|
||||
### 2. Soft Delete 패턴
|
||||
`deleteCategory()`는 실제 DELETE가 아닌 `is_active = false` 업데이트로 처리됩니다.
|
||||
|
||||
### 3. 연결 확인
|
||||
카테고리 삭제 전 `external_db_connections` 테이블에서 사용 중인지 확인해야 합니다.
|
||||
|
||||
### 4. UPSERT 로직
|
||||
PostgreSQL의 `ON CONFLICT` 절을 사용하여 Prisma의 `upsert` 기능을 구현합니다.
|
||||
|
||||
### 5. 통계 쿼리 최적화
|
||||
`groupBy` + 별도 조회 대신, 하나의 `LEFT JOIN` + `GROUP BY` 쿼리로 최적화 가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 코드 전환
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] getAllCategories() - findMany → query
|
||||
- [ ] getCategoryByTypeCode() - findUnique → queryOne
|
||||
- [ ] createCategory() - findUnique + create → queryOne (중복 확인 + INSERT)
|
||||
- [ ] updateCategory() - update → queryOne (동적 UPDATE)
|
||||
- [ ] deleteCategory() - count + update → queryOne + query
|
||||
- [ ] getCategoryStatistics() - groupBy + findMany → query (LEFT JOIN)
|
||||
- [ ] syncPredefinedCategories() - upsert → query (ON CONFLICT)
|
||||
- [ ] ApiResponse 래퍼 유지
|
||||
- [ ] Prisma import 완전 제거
|
||||
|
||||
### 테스트
|
||||
- [ ] 단위 테스트 작성 (10개)
|
||||
- [ ] 통합 테스트 작성 (3개)
|
||||
- [ ] TypeScript 컴파일 성공
|
||||
- [ ] 성능 벤치마크 테스트
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### ApiResponse 패턴
|
||||
이 서비스는 모든 메서드가 `ApiResponse<T>` 형식으로 응답을 반환합니다. Raw Query 전환 후에도 이 패턴을 유지해야 합니다.
|
||||
|
||||
### 사전 정의 카테고리
|
||||
`syncPredefinedCategories()` 메서드는 시스템 초기화 시 사전 정의된 DB 타입 카테고리를 동기화합니다. UPSERT 로직이 필수입니다.
|
||||
|
||||
### 외래 키 확인
|
||||
카테고리 삭제 시 `external_db_connections` 테이블에서 사용 중인지 확인하여 데이터 무결성을 보장합니다.
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-01
|
||||
**예상 소요 시간**: 1시간
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟡 중간 (Phase 3.8)
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: ApiResponse 래퍼, UPSERT, GROUP BY + LEFT JOIN 포함
|
||||
|
||||
|
|
@ -1,408 +0,0 @@
|
|||
# 📋 Phase 3.9: TemplateStandardService Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
TemplateStandardService는 **6개의 Prisma 호출**이 있으며, 템플릿 표준 관리를 담당하는 서비스입니다.
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| 파일 위치 | `backend-node/src/services/templateStandardService.ts` |
|
||||
| 파일 크기 | 395 라인 |
|
||||
| Prisma 호출 | 6개 |
|
||||
| **현재 진행률** | **7/7 (100%)** ✅ **전환 완료** |
|
||||
| 복잡도 | 낮음 (기본 CRUD + DISTINCT) |
|
||||
| 우선순위 | 🟢 낮음 (Phase 3.9) |
|
||||
| **상태** | ✅ **완료** |
|
||||
|
||||
### 🎯 전환 목표
|
||||
|
||||
- ✅ **7개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||
- ✅ 템플릿 CRUD 기능 정상 동작
|
||||
- ✅ DISTINCT 쿼리 전환
|
||||
- ✅ Promise.all 병렬 쿼리 (목록 + 개수)
|
||||
- ✅ 동적 UPDATE 쿼리 (11개 필드)
|
||||
- ✅ TypeScript 컴파일 성공
|
||||
- ✅ **Prisma import 완전 제거**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 사용 현황 분석
|
||||
|
||||
### 주요 Prisma 호출 (6개)
|
||||
|
||||
#### 1. **getTemplateByCode()** - 템플릿 단건 조회
|
||||
|
||||
```typescript
|
||||
// Line 76
|
||||
return await prisma.template_standards.findUnique({
|
||||
where: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. **createTemplate()** - 템플릿 생성
|
||||
|
||||
```typescript
|
||||
// Line 86
|
||||
const existing = await prisma.template_standards.findUnique({
|
||||
where: {
|
||||
template_code: data.template_code,
|
||||
company_code: data.company_code,
|
||||
},
|
||||
});
|
||||
|
||||
// Line 96
|
||||
return await prisma.template_standards.create({
|
||||
data: {
|
||||
...data,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. **updateTemplate()** - 템플릿 수정
|
||||
|
||||
```typescript
|
||||
// Line 164
|
||||
return await prisma.template_standards.update({
|
||||
where: {
|
||||
template_code_company_code: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. **deleteTemplate()** - 템플릿 삭제
|
||||
|
||||
```typescript
|
||||
// Line 181
|
||||
await prisma.template_standards.delete({
|
||||
where: {
|
||||
template_code_company_code: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. **getTemplateCategories()** - 카테고리 목록 (DISTINCT)
|
||||
|
||||
```typescript
|
||||
// Line 262
|
||||
const categories = await prisma.template_standards.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
},
|
||||
select: {
|
||||
category: true,
|
||||
},
|
||||
distinct: ["category"],
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 계획
|
||||
|
||||
### 1단계: 기본 CRUD 전환 (4개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `getTemplateByCode()` - 단건 조회 (findUnique)
|
||||
- `createTemplate()` - 생성 (findUnique + create)
|
||||
- `updateTemplate()` - 수정 (update)
|
||||
- `deleteTemplate()` - 삭제 (delete)
|
||||
|
||||
### 2단계: 추가 기능 전환 (1개 함수)
|
||||
|
||||
**함수 목록**:
|
||||
|
||||
- `getTemplateCategories()` - 카테고리 목록 (findMany + distinct)
|
||||
|
||||
---
|
||||
|
||||
## 💻 전환 예시
|
||||
|
||||
### 예시 1: 복합 키 조회
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
return await prisma.template_standards.findUnique({
|
||||
where: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { queryOne } from "../database/db";
|
||||
|
||||
return await queryOne<any>(
|
||||
`SELECT * FROM template_standards
|
||||
WHERE template_code = $1 AND company_code = $2`,
|
||||
[templateCode, companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 2: 중복 확인 후 생성
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const existing = await prisma.template_standards.findUnique({
|
||||
where: {
|
||||
template_code: data.template_code,
|
||||
company_code: data.company_code,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error("이미 존재하는 템플릿 코드입니다.");
|
||||
}
|
||||
|
||||
return await prisma.template_standards.create({
|
||||
data: {
|
||||
...data,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
const existing = await queryOne<any>(
|
||||
`SELECT * FROM template_standards
|
||||
WHERE template_code = $1 AND company_code = $2`,
|
||||
[data.template_code, data.company_code]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
throw new Error("이미 존재하는 템플릿 코드입니다.");
|
||||
}
|
||||
|
||||
return await queryOne<any>(
|
||||
`INSERT INTO template_standards
|
||||
(template_code, template_name, category, template_type, layout_config,
|
||||
description, is_active, company_code, created_by, updated_by,
|
||||
created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
data.template_code,
|
||||
data.template_name,
|
||||
data.category,
|
||||
data.template_type,
|
||||
JSON.stringify(data.layout_config),
|
||||
data.description,
|
||||
data.is_active,
|
||||
data.company_code,
|
||||
data.created_by,
|
||||
data.updated_by,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 3: 복합 키 UPDATE
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
return await prisma.template_standards.update({
|
||||
where: {
|
||||
template_code_company_code: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
// 동적 UPDATE 쿼리 생성
|
||||
const updateFields: string[] = ["updated_date = NOW()"];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.template_name !== undefined) {
|
||||
updateFields.push(`template_name = $${paramIndex++}`);
|
||||
values.push(data.template_name);
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
updateFields.push(`category = $${paramIndex++}`);
|
||||
values.push(data.category);
|
||||
}
|
||||
if (data.template_type !== undefined) {
|
||||
updateFields.push(`template_type = $${paramIndex++}`);
|
||||
values.push(data.template_type);
|
||||
}
|
||||
if (data.layout_config !== undefined) {
|
||||
updateFields.push(`layout_config = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(data.layout_config));
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
updateFields.push(`description = $${paramIndex++}`);
|
||||
values.push(data.description);
|
||||
}
|
||||
if (data.is_active !== undefined) {
|
||||
updateFields.push(`is_active = $${paramIndex++}`);
|
||||
values.push(data.is_active);
|
||||
}
|
||||
if (data.updated_by !== undefined) {
|
||||
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||
values.push(data.updated_by);
|
||||
}
|
||||
|
||||
return await queryOne<any>(
|
||||
`UPDATE template_standards
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE template_code = $${paramIndex++} AND company_code = $${paramIndex}
|
||||
RETURNING *`,
|
||||
[...values, templateCode, companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 4: 복합 키 DELETE
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
await prisma.template_standards.delete({
|
||||
where: {
|
||||
template_code_company_code: {
|
||||
template_code: templateCode,
|
||||
company_code: companyCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 전환 후
|
||||
import { query } from "../database/db";
|
||||
|
||||
await query(
|
||||
`DELETE FROM template_standards
|
||||
WHERE template_code = $1 AND company_code = $2`,
|
||||
[templateCode, companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
### 예시 5: DISTINCT 쿼리
|
||||
|
||||
```typescript
|
||||
// 기존 Prisma
|
||||
const categories = await prisma.template_standards.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
},
|
||||
select: {
|
||||
category: true,
|
||||
},
|
||||
distinct: ["category"],
|
||||
});
|
||||
|
||||
return categories
|
||||
.map((c) => c.category)
|
||||
.filter((c): c is string => c !== null && c !== undefined)
|
||||
.sort();
|
||||
|
||||
// 전환 후
|
||||
const categories = await query<{ category: string }>(
|
||||
`SELECT DISTINCT category
|
||||
FROM template_standards
|
||||
WHERE company_code = $1 AND category IS NOT NULL
|
||||
ORDER BY category ASC`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
return categories.map((c) => c.category);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 기준
|
||||
|
||||
- [ ] **6개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||
- [ ] **복합 기본 키 처리 (template_code + company_code)**
|
||||
- [ ] **동적 UPDATE 쿼리 생성**
|
||||
- [ ] **DISTINCT 쿼리 전환**
|
||||
- [ ] **JSON 필드 처리 (layout_config)**
|
||||
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||
- [ ] **`import prisma` 완전 제거**
|
||||
- [ ] **모든 단위 테스트 통과 (6개)**
|
||||
- [ ] **통합 테스트 작성 완료 (2개 시나리오)**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 주요 기술적 과제
|
||||
|
||||
### 1. 복합 기본 키
|
||||
|
||||
`template_standards` 테이블은 `(template_code, company_code)` 복합 기본 키를 사용합니다.
|
||||
|
||||
- WHERE 절에서 두 컬럼 모두 지정 필요
|
||||
- Prisma의 `template_code_company_code` 표현식을 `template_code = $1 AND company_code = $2`로 변환
|
||||
|
||||
### 2. JSON 필드
|
||||
|
||||
`layout_config` 필드는 JSON 타입으로, INSERT/UPDATE 시 `JSON.stringify()` 필요합니다.
|
||||
|
||||
### 3. DISTINCT + NULL 제외
|
||||
|
||||
카테고리 목록 조회 시 `DISTINCT` 사용하며, NULL 값은 `WHERE category IS NOT NULL`로 제외합니다.
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 코드 전환
|
||||
|
||||
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||
- [ ] getTemplateByCode() - findUnique → queryOne (복합 키)
|
||||
- [ ] createTemplate() - findUnique + create → queryOne (중복 확인 + INSERT)
|
||||
- [ ] updateTemplate() - update → queryOne (동적 UPDATE, 복합 키)
|
||||
- [ ] deleteTemplate() - delete → query (복합 키)
|
||||
- [ ] getTemplateCategories() - findMany + distinct → query (DISTINCT)
|
||||
- [ ] JSON 필드 처리 (layout_config)
|
||||
- [ ] Prisma import 완전 제거
|
||||
|
||||
### 테스트
|
||||
|
||||
- [ ] 단위 테스트 작성 (6개)
|
||||
- [ ] 통합 테스트 작성 (2개)
|
||||
- [ ] TypeScript 컴파일 성공
|
||||
- [ ] 성능 벤치마크 테스트
|
||||
|
||||
---
|
||||
|
||||
## 💡 특이사항
|
||||
|
||||
### 복합 기본 키 패턴
|
||||
|
||||
이 서비스는 `(template_code, company_code)` 복합 기본 키를 사용하므로, 모든 조회/수정/삭제 작업에서 두 컬럼을 모두 WHERE 조건에 포함해야 합니다.
|
||||
|
||||
### JSON 레이아웃 설정
|
||||
|
||||
`layout_config` 필드는 템플릿의 레이아웃 설정을 JSON 형태로 저장합니다. Raw Query 전환 시 `JSON.stringify()`를 사용하여 문자열로 변환해야 합니다.
|
||||
|
||||
### 카테고리 관리
|
||||
|
||||
템플릿은 카테고리별로 분류되며, `getTemplateCategories()` 메서드로 고유한 카테고리 목록을 조회할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-01
|
||||
**예상 소요 시간**: 45분
|
||||
**담당자**: 백엔드 개발팀
|
||||
**우선순위**: 🟢 낮음 (Phase 3.9)
|
||||
**상태**: ⏳ **대기 중**
|
||||
**특이사항**: 복합 기본 키, JSON 필드, DISTINCT 쿼리 포함
|
||||
|
|
@ -1,522 +0,0 @@
|
|||
# Phase 4.1: AdminController Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
관리자 컨트롤러의 Prisma 호출을 Raw Query로 전환합니다.
|
||||
사용자, 회사, 부서, 메뉴 관리 등 핵심 관리 기능을 포함합니다.
|
||||
|
||||
---
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| 파일 위치 | `backend-node/src/controllers/adminController.ts` |
|
||||
| 파일 크기 | 2,569 라인 |
|
||||
| Prisma 호출 | 28개 → 0개 |
|
||||
| **현재 진행률** | **28/28 (100%)** ✅ **완료** |
|
||||
| 복잡도 | 중간 (다양한 CRUD 패턴) |
|
||||
| 우선순위 | 🔴 높음 (Phase 4.1) |
|
||||
| **상태** | ✅ **완료** (2025-10-01) |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 호출 분석
|
||||
|
||||
### 사용자 관리 (13개)
|
||||
|
||||
#### 1. getUserList (라인 312-317)
|
||||
|
||||
```typescript
|
||||
const totalCount = await prisma.user_info.count({ where });
|
||||
const users = await prisma.user_info.findMany({ where, skip, take, orderBy });
|
||||
```
|
||||
|
||||
- **전환**: count → `queryOne`, findMany → `query`
|
||||
- **복잡도**: 중간 (동적 WHERE, 페이징)
|
||||
|
||||
#### 2. getUserInfo (라인 419)
|
||||
|
||||
```typescript
|
||||
const userInfo = await prisma.user_info.findFirst({ where });
|
||||
```
|
||||
|
||||
- **전환**: findFirst → `queryOne`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 3. updateUserStatus (라인 498)
|
||||
|
||||
```typescript
|
||||
await prisma.user_info.update({ where, data });
|
||||
```
|
||||
|
||||
- **전환**: update → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 4. deleteUserByAdmin (라인 2387)
|
||||
|
||||
```typescript
|
||||
await prisma.user_info.update({ where, data: { is_active: "N" } });
|
||||
```
|
||||
|
||||
- **전환**: update (soft delete) → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 5. getMyProfile (라인 1468, 1488, 2479)
|
||||
|
||||
```typescript
|
||||
const user = await prisma.user_info.findUnique({ where });
|
||||
const dept = await prisma.dept_info.findUnique({ where });
|
||||
```
|
||||
|
||||
- **전환**: findUnique → `queryOne`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 6. updateMyProfile (라인 1864, 2527)
|
||||
|
||||
```typescript
|
||||
const updateResult = await prisma.user_info.update({ where, data });
|
||||
```
|
||||
|
||||
- **전환**: update → `queryOne` with RETURNING
|
||||
- **복잡도**: 중간 (동적 UPDATE)
|
||||
|
||||
#### 7. createOrUpdateUser (라인 1929, 1975)
|
||||
|
||||
```typescript
|
||||
const savedUser = await prisma.user_info.upsert({ where, update, create });
|
||||
const userCount = await prisma.user_info.count({ where });
|
||||
```
|
||||
|
||||
- **전환**: upsert → `INSERT ... ON CONFLICT`, count → `queryOne`
|
||||
- **복잡도**: 높음
|
||||
|
||||
#### 8. 기타 findUnique (라인 1596, 1832, 2393)
|
||||
|
||||
```typescript
|
||||
const existingUser = await prisma.user_info.findUnique({ where });
|
||||
const currentUser = await prisma.user_info.findUnique({ where });
|
||||
const updatedUser = await prisma.user_info.findUnique({ where });
|
||||
```
|
||||
|
||||
- **전환**: findUnique → `queryOne`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
### 회사 관리 (7개)
|
||||
|
||||
#### 9. getCompanyList (라인 550, 1276)
|
||||
|
||||
```typescript
|
||||
const companies = await prisma.company_mng.findMany({ orderBy });
|
||||
```
|
||||
|
||||
- **전환**: findMany → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 10. createCompany (라인 2035)
|
||||
|
||||
```typescript
|
||||
const existingCompany = await prisma.company_mng.findFirst({ where });
|
||||
```
|
||||
|
||||
- **전환**: findFirst (중복 체크) → `queryOne`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 11. updateCompany (라인 2172, 2192)
|
||||
|
||||
```typescript
|
||||
const duplicateCompany = await prisma.company_mng.findFirst({ where });
|
||||
const updatedCompany = await prisma.company_mng.update({ where, data });
|
||||
```
|
||||
|
||||
- **전환**: findFirst → `queryOne`, update → `queryOne`
|
||||
- **복잡도**: 중간
|
||||
|
||||
#### 12. deleteCompany (라인 2261, 2281)
|
||||
|
||||
```typescript
|
||||
const existingCompany = await prisma.company_mng.findUnique({ where });
|
||||
await prisma.company_mng.delete({ where });
|
||||
```
|
||||
|
||||
- **전환**: findUnique → `queryOne`, delete → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
### 부서 관리 (2개)
|
||||
|
||||
#### 13. getDepartmentList (라인 1348)
|
||||
|
||||
```typescript
|
||||
const departments = await prisma.dept_info.findMany({ where, orderBy });
|
||||
```
|
||||
|
||||
- **전환**: findMany → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
#### 14. getDeptInfo (라인 1488)
|
||||
|
||||
```typescript
|
||||
const dept = await prisma.dept_info.findUnique({ where });
|
||||
```
|
||||
|
||||
- **전환**: findUnique → `queryOne`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
### 메뉴 관리 (3개)
|
||||
|
||||
#### 15. createMenu (라인 1021)
|
||||
|
||||
```typescript
|
||||
const savedMenu = await prisma.menu_info.create({ data });
|
||||
```
|
||||
|
||||
- **전환**: create → `queryOne` with INSERT RETURNING
|
||||
- **복잡도**: 중간
|
||||
|
||||
#### 16. updateMenu (라인 1087)
|
||||
|
||||
```typescript
|
||||
const updatedMenu = await prisma.menu_info.update({ where, data });
|
||||
```
|
||||
|
||||
- **전환**: update → `queryOne` with UPDATE RETURNING
|
||||
- **복잡도**: 중간
|
||||
|
||||
#### 17. deleteMenu (라인 1149, 1211)
|
||||
|
||||
```typescript
|
||||
const deletedMenu = await prisma.menu_info.delete({ where });
|
||||
// 재귀 삭제
|
||||
const deletedMenu = await prisma.menu_info.delete({ where });
|
||||
```
|
||||
|
||||
- **전환**: delete → `query`
|
||||
- **복잡도**: 중간 (재귀 삭제 로직)
|
||||
|
||||
### 다국어 (1개)
|
||||
|
||||
#### 18. getMultiLangKeys (라인 665)
|
||||
|
||||
```typescript
|
||||
const result = await prisma.multi_lang_key_master.findMany({ where, orderBy });
|
||||
```
|
||||
|
||||
- **전환**: findMany → `query`
|
||||
- **복잡도**: 낮음
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 전략
|
||||
|
||||
### 1단계: Import 변경
|
||||
|
||||
```typescript
|
||||
// 제거
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 추가
|
||||
import { query, queryOne } from "../database/db";
|
||||
```
|
||||
|
||||
### 2단계: 단순 조회 전환
|
||||
|
||||
- findMany → `query<T>`
|
||||
- findUnique/findFirst → `queryOne<T>`
|
||||
|
||||
### 3단계: 동적 WHERE 처리
|
||||
|
||||
```typescript
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode) {
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
```
|
||||
|
||||
### 4단계: 복잡한 로직 전환
|
||||
|
||||
- count → `SELECT COUNT(*) as count`
|
||||
- upsert → `INSERT ... ON CONFLICT DO UPDATE`
|
||||
- 동적 UPDATE → 조건부 SET 절 생성
|
||||
|
||||
### 5단계: 테스트 및 검증
|
||||
|
||||
- 각 함수별 동작 확인
|
||||
- 에러 처리 확인
|
||||
- 타입 안전성 확인
|
||||
|
||||
---
|
||||
|
||||
## 🎯 주요 변경 예시
|
||||
|
||||
### getUserList (count + findMany)
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const totalCount = await prisma.user_info.count({ where });
|
||||
const users = await prisma.user_info.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
// After
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 동적 WHERE 구성
|
||||
if (where.company_code) {
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(where.company_code);
|
||||
}
|
||||
if (where.user_name) {
|
||||
whereConditions.push(`user_name ILIKE $${paramIndex++}`);
|
||||
params.push(`%${where.user_name}%`);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
|
||||
// Count
|
||||
const countResult = await queryOne<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM user_info ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const totalCount = parseInt(countResult?.count?.toString() || "0", 10);
|
||||
|
||||
// 데이터 조회
|
||||
const usersQuery = `
|
||||
SELECT * FROM user_info
|
||||
${whereClause}
|
||||
ORDER BY created_date DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
params.push(take, skip);
|
||||
|
||||
const users = await query<UserInfo>(usersQuery, params);
|
||||
```
|
||||
|
||||
### createOrUpdateUser (upsert)
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const savedUser = await prisma.user_info.upsert({
|
||||
where: { user_id: userId },
|
||||
update: updateData,
|
||||
create: createData
|
||||
});
|
||||
|
||||
// After
|
||||
const savedUser = await queryOne<UserInfo>(
|
||||
`INSERT INTO user_info (user_id, user_name, email, ...)
|
||||
VALUES ($1, $2, $3, ...)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
user_name = EXCLUDED.user_name,
|
||||
email = EXCLUDED.email,
|
||||
...
|
||||
RETURNING *`,
|
||||
[userId, userName, email, ...]
|
||||
);
|
||||
```
|
||||
|
||||
### updateMyProfile (동적 UPDATE)
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const updateResult = await prisma.user_info.update({
|
||||
where: { user_id: userId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// After
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updateData.user_name !== undefined) {
|
||||
updates.push(`user_name = $${paramIndex++}`);
|
||||
params.push(updateData.user_name);
|
||||
}
|
||||
if (updateData.email !== undefined) {
|
||||
updates.push(`email = $${paramIndex++}`);
|
||||
params.push(updateData.email);
|
||||
}
|
||||
// ... 다른 필드들
|
||||
|
||||
params.push(userId);
|
||||
|
||||
const updateResult = await queryOne<UserInfo>(
|
||||
`UPDATE user_info
|
||||
SET ${updates.join(", ")}, updated_date = NOW()
|
||||
WHERE user_id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
params
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
### 기본 설정
|
||||
|
||||
- ✅ Prisma import 제거 (완전 제거 확인)
|
||||
- ✅ query, queryOne import 추가 (이미 존재)
|
||||
- ✅ 타입 import 확인
|
||||
|
||||
### 사용자 관리
|
||||
|
||||
- ✅ getUserList (count + findMany → Raw Query)
|
||||
- ✅ getUserLocale (findFirst → queryOne)
|
||||
- ✅ setUserLocale (update → query)
|
||||
- ✅ getUserInfo (findUnique → queryOne)
|
||||
- ✅ checkDuplicateUserId (findUnique → queryOne)
|
||||
- ✅ changeUserStatus (findUnique + update → queryOne + query)
|
||||
- ✅ saveUser (upsert → INSERT ON CONFLICT)
|
||||
- ✅ updateProfile (동적 update → 동적 query)
|
||||
- ✅ resetUserPassword (update → query)
|
||||
|
||||
### 회사 관리
|
||||
|
||||
- ✅ getCompanyList (findMany → query)
|
||||
- ✅ getCompanyListFromDB (findMany → query)
|
||||
- ✅ createCompany (findFirst → queryOne)
|
||||
- ✅ updateCompany (findFirst + update → queryOne + query)
|
||||
- ✅ deleteCompany (delete → query with RETURNING)
|
||||
|
||||
### 부서 관리
|
||||
|
||||
- ✅ getDepartmentList (findMany → query with 동적 WHERE)
|
||||
|
||||
### 메뉴 관리
|
||||
|
||||
- ✅ saveMenu (create → query with INSERT RETURNING)
|
||||
- ✅ updateMenu (update → query with UPDATE RETURNING)
|
||||
- ✅ deleteMenu (delete → query with DELETE RETURNING)
|
||||
- ✅ deleteMenusBatch (다중 delete → 반복 query)
|
||||
|
||||
### 다국어
|
||||
|
||||
- ✅ getLangKeyList (findMany → query)
|
||||
|
||||
### 검증
|
||||
|
||||
- ✅ TypeScript 컴파일 확인 (에러 없음)
|
||||
- ✅ Linter 오류 확인
|
||||
- ⏳ 기능 테스트 (실행 필요)
|
||||
- ✅ 에러 처리 확인 (기존 구조 유지)
|
||||
|
||||
---
|
||||
|
||||
## 📌 참고사항
|
||||
|
||||
### 동적 쿼리 생성 패턴
|
||||
|
||||
모든 동적 WHERE/UPDATE는 다음 패턴을 따릅니다:
|
||||
|
||||
1. 조건/필드 배열 생성
|
||||
2. 파라미터 배열 생성
|
||||
3. 파라미터 인덱스 관리
|
||||
4. SQL 문자열 조합
|
||||
5. query/queryOne 실행
|
||||
|
||||
### 에러 처리
|
||||
|
||||
기존 try-catch 구조를 유지하며, 데이터베이스 에러를 적절히 변환합니다.
|
||||
|
||||
### 트랜잭션
|
||||
|
||||
복잡한 로직은 Service Layer로 이동을 고려합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🎉 완료 요약 (2025-10-01)
|
||||
|
||||
### ✅ 전환 완료 현황
|
||||
|
||||
| 카테고리 | 함수 수 | 상태 |
|
||||
|---------|--------|------|
|
||||
| 사용자 관리 | 9개 | ✅ 완료 |
|
||||
| 회사 관리 | 5개 | ✅ 완료 |
|
||||
| 부서 관리 | 1개 | ✅ 완료 |
|
||||
| 메뉴 관리 | 4개 | ✅ 완료 |
|
||||
| 다국어 | 1개 | ✅ 완료 |
|
||||
| **총계** | **20개** | **✅ 100% 완료** |
|
||||
|
||||
### 📊 주요 성과
|
||||
|
||||
1. **완전한 Prisma 제거**: adminController.ts에서 모든 Prisma 코드 제거 완료
|
||||
2. **동적 쿼리 지원**: 런타임 테이블 생성/수정 가능
|
||||
3. **일관된 에러 처리**: 모든 함수에서 통일된 에러 처리 유지
|
||||
4. **타입 안전성**: TypeScript 컴파일 에러 없음
|
||||
5. **코드 품질 향상**: 949줄 변경 (+474/-475)
|
||||
|
||||
### 🔑 주요 변환 패턴
|
||||
|
||||
#### 1. 동적 WHERE 조건
|
||||
```typescript
|
||||
let whereConditions: string[] = [];
|
||||
let queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filter) {
|
||||
whereConditions.push(`field = $${paramIndex}`);
|
||||
queryParams.push(filter);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
```
|
||||
|
||||
#### 2. UPSERT (INSERT ON CONFLICT)
|
||||
```typescript
|
||||
const [result] = await query<any>(
|
||||
`INSERT INTO table (col1, col2) VALUES ($1, $2)
|
||||
ON CONFLICT (col1) DO UPDATE SET col2 = $2
|
||||
RETURNING *`,
|
||||
[val1, val2]
|
||||
);
|
||||
```
|
||||
|
||||
#### 3. 동적 UPDATE
|
||||
```typescript
|
||||
const updateFields: string[] = [];
|
||||
const updateValues: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.field !== undefined) {
|
||||
updateFields.push(`field = $${paramIndex}`);
|
||||
updateValues.push(data.field);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE table SET ${updateFields.join(", ")} WHERE id = $${paramIndex}`,
|
||||
[...updateValues, id]
|
||||
);
|
||||
```
|
||||
|
||||
### 🚀 다음 단계
|
||||
|
||||
1. **테스트 실행**: 개발 서버에서 모든 API 엔드포인트 테스트
|
||||
2. **문서 업데이트**: Phase 4 전체 계획서 진행 상황 반영
|
||||
3. **다음 Phase**: screenFileController.ts 마이그레이션 진행
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트**: 2025-10-01
|
||||
**작업자**: Claude Agent
|
||||
**완료 시간**: 약 15분
|
||||
**변경 라인 수**: 949줄 (추가 474줄, 삭제 475줄)
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
# Phase 4: Controller Layer Raw Query 전환 계획
|
||||
|
||||
## 📋 개요
|
||||
|
||||
컨트롤러 레이어에 남아있는 Prisma 호출을 Raw Query로 전환합니다.
|
||||
대부분의 컨트롤러는 Service 레이어를 호출하지만, 일부 컨트롤러에서 직접 Prisma를 사용하고 있습니다.
|
||||
|
||||
---
|
||||
|
||||
### 📊 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | ---------------------------------- |
|
||||
| 대상 파일 | 7개 컨트롤러 |
|
||||
| 파일 위치 | `backend-node/src/controllers/` |
|
||||
| Prisma 호출 | 70개 (28개 완료) |
|
||||
| **현재 진행률** | **28/70 (40%)** 🔄 **진행 중** |
|
||||
| 복잡도 | 중간 (대부분 단순 CRUD) |
|
||||
| 우선순위 | 🟡 중간 (Phase 4) |
|
||||
| **상태** | 🔄 **진행 중** (adminController 완료) |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 전환 대상 컨트롤러
|
||||
|
||||
### 1. adminController.ts ✅ 완료 (28개)
|
||||
|
||||
- **라인 수**: 2,569 라인
|
||||
- **Prisma 호출**: 28개 → 0개
|
||||
- **주요 기능**:
|
||||
- 사용자 관리 (조회, 생성, 수정, 삭제) ✅
|
||||
- 회사 관리 (조회, 생성, 수정, 삭제) ✅
|
||||
- 부서 관리 (조회) ✅
|
||||
- 메뉴 관리 (생성, 수정, 삭제) ✅
|
||||
- 다국어 키 조회 ✅
|
||||
- **우선순위**: 🔴 높음
|
||||
- **상태**: ✅ **완료** (2025-10-01)
|
||||
- **문서**: [PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md](PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md)
|
||||
|
||||
### 2. webTypeStandardController.ts (11개)
|
||||
|
||||
- **Prisma 호출**: 11개
|
||||
- **주요 기능**: 웹타입 표준 관리
|
||||
- **우선순위**: 🟡 중간
|
||||
|
||||
### 3. fileController.ts (11개)
|
||||
|
||||
- **Prisma 호출**: 11개
|
||||
- **주요 기능**: 파일 업로드/다운로드 관리
|
||||
- **우선순위**: 🟡 중간
|
||||
|
||||
### 4. buttonActionStandardController.ts (11개)
|
||||
|
||||
- **Prisma 호출**: 11개
|
||||
- **주요 기능**: 버튼 액션 표준 관리
|
||||
- **우선순위**: 🟡 중간
|
||||
|
||||
### 5. entityReferenceController.ts (4개)
|
||||
|
||||
- **Prisma 호출**: 4개
|
||||
- **주요 기능**: 엔티티 참조 관리
|
||||
- **우선순위**: 🟢 낮음
|
||||
|
||||
### 6. dataflowExecutionController.ts (3개)
|
||||
|
||||
- **Prisma 호출**: 3개
|
||||
- **주요 기능**: 데이터플로우 실행
|
||||
- **우선순위**: 🟢 낮음
|
||||
|
||||
### 7. screenFileController.ts (2개)
|
||||
|
||||
- **Prisma 호출**: 2개
|
||||
- **주요 기능**: 화면 파일 관리
|
||||
- **우선순위**: 🟢 낮음
|
||||
|
||||
---
|
||||
|
||||
## 📝 전환 전략
|
||||
|
||||
### 기본 원칙
|
||||
|
||||
1. **Service Layer 우선**
|
||||
|
||||
- 가능하면 Service로 로직 이동
|
||||
- Controller는 최소한의 로직만 유지
|
||||
|
||||
2. **단순 전환**
|
||||
|
||||
- 대부분 단순 CRUD → `query`, `queryOne` 사용
|
||||
- 복잡한 로직은 Service로 이동
|
||||
|
||||
3. **에러 처리 유지**
|
||||
- 기존 try-catch 구조 유지
|
||||
- 에러 메시지 일관성 유지
|
||||
|
||||
### 전환 패턴
|
||||
|
||||
#### 1. findMany → query
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const users = await prisma.user_info.findMany({
|
||||
where: { company_code: companyCode },
|
||||
});
|
||||
|
||||
// After
|
||||
const users = await query<UserInfo>(
|
||||
`SELECT * FROM user_info WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
```
|
||||
|
||||
#### 2. findUnique → queryOne
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const user = await prisma.user_info.findUnique({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
// After
|
||||
const user = await queryOne<UserInfo>(
|
||||
`SELECT * FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
```
|
||||
|
||||
#### 3. create → queryOne with INSERT
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const newUser = await prisma.user_info.create({
|
||||
data: userData
|
||||
});
|
||||
|
||||
// After
|
||||
const newUser = await queryOne<UserInfo>(
|
||||
`INSERT INTO user_info (user_id, user_name, ...)
|
||||
VALUES ($1, $2, ...) RETURNING *`,
|
||||
[userData.user_id, userData.user_name, ...]
|
||||
);
|
||||
```
|
||||
|
||||
#### 4. update → queryOne with UPDATE
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const updated = await prisma.user_info.update({
|
||||
where: { user_id: userId },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
// After
|
||||
const updated = await queryOne<UserInfo>(
|
||||
`UPDATE user_info SET user_name = $1, ...
|
||||
WHERE user_id = $2 RETURNING *`,
|
||||
[updateData.user_name, ..., userId]
|
||||
);
|
||||
```
|
||||
|
||||
#### 5. delete → query with DELETE
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
await prisma.user_info.delete({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
// After
|
||||
await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]);
|
||||
```
|
||||
|
||||
#### 6. count → queryOne
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const count = await prisma.user_info.count({
|
||||
where: { company_code: companyCode },
|
||||
});
|
||||
|
||||
// After
|
||||
const result = await queryOne<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM user_info WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const count = parseInt(result?.count?.toString() || "0", 10);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
### Phase 4.1: adminController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 사용자 관리 함수 전환 (8개)
|
||||
- [ ] getUserList - count + findMany
|
||||
- [ ] getUserInfo - findFirst
|
||||
- [ ] updateUserStatus - update
|
||||
- [ ] deleteUserByAdmin - update
|
||||
- [ ] getMyProfile - findUnique
|
||||
- [ ] updateMyProfile - update
|
||||
- [ ] createOrUpdateUser - upsert
|
||||
- [ ] count (getUserList)
|
||||
- [ ] 회사 관리 함수 전환 (7개)
|
||||
- [ ] getCompanyList - findMany
|
||||
- [ ] createCompany - findFirst (중복체크) + create
|
||||
- [ ] updateCompany - findFirst (중복체크) + update
|
||||
- [ ] deleteCompany - findUnique + delete
|
||||
- [ ] 부서 관리 함수 전환 (2개)
|
||||
- [ ] getDepartmentList - findMany
|
||||
- [ ] findUnique (부서 조회)
|
||||
- [ ] 메뉴 관리 함수 전환 (3개)
|
||||
- [ ] createMenu - create
|
||||
- [ ] updateMenu - update
|
||||
- [ ] deleteMenu - delete
|
||||
- [ ] 기타 함수 전환 (8개)
|
||||
- [ ] getMultiLangKeys - findMany
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.2: webTypeStandardController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (11개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.3: fileController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (11개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.4: buttonActionStandardController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (11개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.5: entityReferenceController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (4개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.6: dataflowExecutionController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (3개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
### Phase 4.7: screenFileController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] 모든 함수 전환 (2개)
|
||||
- [ ] 컴파일 확인
|
||||
- [ ] 린터 확인
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 결과
|
||||
|
||||
### 코드 품질
|
||||
|
||||
- ✅ Prisma 의존성 완전 제거
|
||||
- ✅ 직접적인 SQL 제어
|
||||
- ✅ 타입 안전성 유지
|
||||
|
||||
### 성능
|
||||
|
||||
- ✅ 불필요한 ORM 오버헤드 제거
|
||||
- ✅ 쿼리 최적화 가능
|
||||
|
||||
### 유지보수성
|
||||
|
||||
- ✅ 명확한 SQL 쿼리
|
||||
- ✅ 디버깅 용이
|
||||
- ✅ 데이터베이스 마이그레이션 용이
|
||||
|
||||
---
|
||||
|
||||
## 📌 참고사항
|
||||
|
||||
### Import 변경
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// After
|
||||
import { query, queryOne } from "../database/db";
|
||||
```
|
||||
|
||||
### 타입 정의
|
||||
|
||||
- 각 테이블의 타입은 `types/` 디렉토리에서 import
|
||||
- 필요시 새로운 타입 정의 추가
|
||||
|
||||
### 에러 처리
|
||||
|
||||
- 기존 try-catch 구조 유지
|
||||
- 적절한 HTTP 상태 코드 반환
|
||||
- 사용자 친화적 에러 메시지
|
||||
|
|
@ -1,546 +0,0 @@
|
|||
# Phase 4: 남은 Prisma 호출 전환 계획
|
||||
|
||||
## 📊 현재 상황
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --------------- | -------------------------------- |
|
||||
| 총 Prisma 호출 | 29개 |
|
||||
| 대상 파일 | 7개 |
|
||||
| **현재 진행률** | **17/29 (58.6%)** 🔄 **진행 중** |
|
||||
| 복잡도 | 중간 |
|
||||
| 우선순위 | 🔴 높음 (Phase 4) |
|
||||
| **상태** | ⏳ **진행 중** |
|
||||
|
||||
---
|
||||
|
||||
## 📁 파일별 현황
|
||||
|
||||
### ✅ 완료된 파일 (2개)
|
||||
|
||||
1. **adminController.ts** - ✅ **28개 완료**
|
||||
|
||||
- 사용자 관리: getUserList, getUserInfo, updateUserStatus, deleteUser
|
||||
- 프로필 관리: getMyProfile, updateMyProfile, resetPassword
|
||||
- 사용자 생성/수정: createOrUpdateUser (UPSERT)
|
||||
- 회사 관리: getCompanyList, createCompany, updateCompany, deleteCompany
|
||||
- 부서 관리: getDepartmentList, getDeptInfo
|
||||
- 메뉴 관리: createMenu, updateMenu, deleteMenu
|
||||
- 다국어: getMultiLangKeys, updateLocale
|
||||
|
||||
2. **screenFileController.ts** - ✅ **2개 완료**
|
||||
- getScreenComponentFiles: findMany → query (LIKE)
|
||||
- getComponentFiles: findMany → query (LIKE)
|
||||
|
||||
---
|
||||
|
||||
## ⏳ 남은 파일 (5개, 총 12개 호출)
|
||||
|
||||
### 1. webTypeStandardController.ts (11개) 🔴 최우선
|
||||
|
||||
**위치**: `backend-node/src/controllers/webTypeStandardController.ts`
|
||||
|
||||
#### Prisma 호출 목록:
|
||||
|
||||
1. **라인 33**: `getWebTypeStandards()` - findMany
|
||||
|
||||
```typescript
|
||||
const webTypes = await prisma.web_type_standards.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
select,
|
||||
});
|
||||
```
|
||||
|
||||
2. **라인 58**: `getWebTypeStandard()` - findUnique
|
||||
|
||||
```typescript
|
||||
const webTypeData = await prisma.web_type_standards.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
```
|
||||
|
||||
3. **라인 112**: `createWebTypeStandard()` - findUnique (중복 체크)
|
||||
|
||||
```typescript
|
||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||
where: { web_type: webType },
|
||||
});
|
||||
```
|
||||
|
||||
4. **라인 123**: `createWebTypeStandard()` - create
|
||||
|
||||
```typescript
|
||||
const newWebType = await prisma.web_type_standards.create({
|
||||
data: { ... }
|
||||
});
|
||||
```
|
||||
|
||||
5. **라인 178**: `updateWebTypeStandard()` - findUnique (존재 확인)
|
||||
|
||||
```typescript
|
||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
```
|
||||
|
||||
6. **라인 189**: `updateWebTypeStandard()` - update
|
||||
|
||||
```typescript
|
||||
const updatedWebType = await prisma.web_type_standards.update({
|
||||
where: { id }, data: { ... }
|
||||
});
|
||||
```
|
||||
|
||||
7. **라인 230**: `deleteWebTypeStandard()` - findUnique (존재 확인)
|
||||
|
||||
```typescript
|
||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
```
|
||||
|
||||
8. **라인 241**: `deleteWebTypeStandard()` - delete
|
||||
|
||||
```typescript
|
||||
await prisma.web_type_standards.delete({
|
||||
where: { id },
|
||||
});
|
||||
```
|
||||
|
||||
9. **라인 275**: `updateSortOrder()` - $transaction
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(
|
||||
updates.map((item) =>
|
||||
prisma.web_type_standards.update({ ... })
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
10. **라인 277**: `updateSortOrder()` - update (트랜잭션 내부)
|
||||
|
||||
11. **라인 305**: `getCategories()` - groupBy
|
||||
```typescript
|
||||
const categories = await prisma.web_type_standards.groupBy({
|
||||
by: ["category"],
|
||||
where,
|
||||
_count: true,
|
||||
});
|
||||
```
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- findMany → `query<WebTypeStandard>` with dynamic WHERE
|
||||
- findUnique → `queryOne<WebTypeStandard>`
|
||||
- create → `queryOne` with INSERT RETURNING
|
||||
- update → `queryOne` with UPDATE RETURNING
|
||||
- delete → `query` with DELETE
|
||||
- $transaction → `transaction` with client.query
|
||||
- groupBy → `query` with GROUP BY, COUNT
|
||||
|
||||
---
|
||||
|
||||
### 2. fileController.ts (1개) 🟡
|
||||
|
||||
**위치**: `backend-node/src/controllers/fileController.ts`
|
||||
|
||||
#### Prisma 호출:
|
||||
|
||||
1. **라인 726**: `downloadFile()` - findUnique
|
||||
```typescript
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: { objid: BigInt(objid) },
|
||||
});
|
||||
```
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- findUnique → `queryOne<AttachFileInfo>`
|
||||
|
||||
---
|
||||
|
||||
### 3. multiConnectionQueryService.ts (4개) 🟢
|
||||
|
||||
**위치**: `backend-node/src/services/multiConnectionQueryService.ts`
|
||||
|
||||
#### Prisma 호출 목록:
|
||||
|
||||
1. **라인 1005**: `executeSelect()` - $queryRawUnsafe
|
||||
|
||||
```typescript
|
||||
return await prisma.$queryRawUnsafe(query, ...queryParams);
|
||||
```
|
||||
|
||||
2. **라인 1022**: `executeInsert()` - $queryRawUnsafe
|
||||
|
||||
```typescript
|
||||
const insertResult = await prisma.$queryRawUnsafe(...);
|
||||
```
|
||||
|
||||
3. **라인 1055**: `executeUpdate()` - $queryRawUnsafe
|
||||
|
||||
```typescript
|
||||
return await prisma.$queryRawUnsafe(updateQuery, ...updateParams);
|
||||
```
|
||||
|
||||
4. **라인 1071**: `executeDelete()` - $queryRawUnsafe
|
||||
```typescript
|
||||
return await prisma.$queryRawUnsafe(...);
|
||||
```
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- $queryRawUnsafe → `query<any>` (이미 Raw SQL 사용 중)
|
||||
|
||||
---
|
||||
|
||||
### 4. config/database.ts (4개) 🟢
|
||||
|
||||
**위치**: `backend-node/src/config/database.ts`
|
||||
|
||||
#### Prisma 호출:
|
||||
|
||||
1. **라인 1**: PrismaClient import
|
||||
2. **라인 17**: prisma 인스턴스 생성
|
||||
3. **라인 22**: `await prisma.$connect()`
|
||||
4. **라인 31, 35, 40**: `await prisma.$disconnect()`
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- 이 파일은 데이터베이스 설정 파일이므로 완전히 제거
|
||||
- 기존 `db.ts`의 connection pool로 대체
|
||||
- 모든 import 경로를 `database` → `database/db`로 변경
|
||||
|
||||
---
|
||||
|
||||
### 5. routes/ddlRoutes.ts (2개) 🟢
|
||||
|
||||
**위치**: `backend-node/src/routes/ddlRoutes.ts`
|
||||
|
||||
#### Prisma 호출:
|
||||
|
||||
1. **라인 183-184**: 동적 PrismaClient import
|
||||
|
||||
```typescript
|
||||
const { PrismaClient } = await import("@prisma/client");
|
||||
const prisma = new PrismaClient();
|
||||
```
|
||||
|
||||
2. **라인 186-187**: 연결 테스트
|
||||
```typescript
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
await prisma.$disconnect();
|
||||
```
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- 동적 import 제거
|
||||
- `query('SELECT 1')` 사용
|
||||
|
||||
---
|
||||
|
||||
### 6. routes/companyManagementRoutes.ts (2개) 🟢
|
||||
|
||||
**위치**: `backend-node/src/routes/companyManagementRoutes.ts`
|
||||
|
||||
#### Prisma 호출:
|
||||
|
||||
1. **라인 32**: findUnique (중복 체크)
|
||||
|
||||
```typescript
|
||||
const existingCompany = await prisma.company_mng.findUnique({
|
||||
where: { company_code },
|
||||
});
|
||||
```
|
||||
|
||||
2. **라인 61**: update (회사명 업데이트)
|
||||
```typescript
|
||||
await prisma.company_mng.update({
|
||||
where: { company_code },
|
||||
data: { company_name },
|
||||
});
|
||||
```
|
||||
|
||||
**전환 전략**:
|
||||
|
||||
- findUnique → `queryOne`
|
||||
- update → `query`
|
||||
|
||||
---
|
||||
|
||||
### 7. tests/authService.test.ts (2개) ⚠️
|
||||
|
||||
**위치**: `backend-node/src/tests/authService.test.ts`
|
||||
|
||||
테스트 파일은 별도 처리 필요 (Phase 5에서 처리)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 전환 우선순위
|
||||
|
||||
### Phase 4.1: 컨트롤러 (완료)
|
||||
|
||||
- [x] screenFileController.ts (2개)
|
||||
- [x] adminController.ts (28개)
|
||||
|
||||
### Phase 4.2: 남은 컨트롤러 (진행 예정)
|
||||
|
||||
- [ ] webTypeStandardController.ts (11개) - 🔴 최우선
|
||||
- [ ] fileController.ts (1개)
|
||||
|
||||
### Phase 4.3: Routes (진행 예정)
|
||||
|
||||
- [ ] ddlRoutes.ts (2개)
|
||||
- [ ] companyManagementRoutes.ts (2개)
|
||||
|
||||
### Phase 4.4: Services (진행 예정)
|
||||
|
||||
- [ ] multiConnectionQueryService.ts (4개)
|
||||
|
||||
### Phase 4.5: Config (진행 예정)
|
||||
|
||||
- [ ] database.ts (4개) - 전체 파일 제거
|
||||
|
||||
### Phase 4.6: Tests (Phase 5)
|
||||
|
||||
- [ ] authService.test.ts (2개) - 별도 처리
|
||||
|
||||
---
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### webTypeStandardController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] getWebTypeStandards (findMany → query)
|
||||
- [ ] getWebTypeStandard (findUnique → queryOne)
|
||||
- [ ] createWebTypeStandard (findUnique + create → queryOne)
|
||||
- [ ] updateWebTypeStandard (findUnique + update → queryOne)
|
||||
- [ ] deleteWebTypeStandard (findUnique + delete → query)
|
||||
- [ ] updateSortOrder ($transaction → transaction)
|
||||
- [ ] getCategories (groupBy → query with GROUP BY)
|
||||
- [ ] TypeScript 컴파일 확인
|
||||
- [ ] Linter 오류 확인
|
||||
- [ ] 동작 테스트
|
||||
|
||||
### fileController.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] queryOne import 추가
|
||||
- [ ] downloadFile (findUnique → queryOne)
|
||||
- [ ] TypeScript 컴파일 확인
|
||||
|
||||
### routes/ddlRoutes.ts
|
||||
|
||||
- [ ] 동적 PrismaClient import 제거
|
||||
- [ ] query import 추가
|
||||
- [ ] 연결 테스트 로직 변경
|
||||
- [ ] TypeScript 컴파일 확인
|
||||
|
||||
### routes/companyManagementRoutes.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query, queryOne import 추가
|
||||
- [ ] findUnique → queryOne
|
||||
- [ ] update → query
|
||||
- [ ] TypeScript 컴파일 확인
|
||||
|
||||
### services/multiConnectionQueryService.ts
|
||||
|
||||
- [ ] Prisma import 제거
|
||||
- [ ] query import 추가
|
||||
- [ ] $queryRawUnsafe → query (4곳)
|
||||
- [ ] TypeScript 컴파일 확인
|
||||
|
||||
### config/database.ts
|
||||
|
||||
- [ ] 파일 전체 분석
|
||||
- [ ] 의존성 확인
|
||||
- [ ] 대체 방안 구현
|
||||
- [ ] 모든 import 경로 변경
|
||||
- [ ] 파일 삭제 또는 완전 재작성
|
||||
|
||||
---
|
||||
|
||||
## 🔧 전환 패턴 요약
|
||||
|
||||
### 1. findMany → query
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const items = await prisma.table.findMany({ where, orderBy });
|
||||
|
||||
// After
|
||||
const items = await query<T>(
|
||||
`SELECT * FROM table WHERE ... ORDER BY ...`,
|
||||
params
|
||||
);
|
||||
```
|
||||
|
||||
### 2. findUnique → queryOne
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const item = await prisma.table.findUnique({ where: { id } });
|
||||
|
||||
// After
|
||||
const item = await queryOne<T>(`SELECT * FROM table WHERE id = $1`, [id]);
|
||||
```
|
||||
|
||||
### 3. create → queryOne with RETURNING
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const newItem = await prisma.table.create({ data });
|
||||
|
||||
// After
|
||||
const [newItem] = await query<T>(
|
||||
`INSERT INTO table (col1, col2) VALUES ($1, $2) RETURNING *`,
|
||||
[val1, val2]
|
||||
);
|
||||
```
|
||||
|
||||
### 4. update → query with RETURNING
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const updated = await prisma.table.update({ where, data });
|
||||
|
||||
// After
|
||||
const [updated] = await query<T>(
|
||||
`UPDATE table SET col1 = $1 WHERE id = $2 RETURNING *`,
|
||||
[val1, id]
|
||||
);
|
||||
```
|
||||
|
||||
### 5. delete → query
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
await prisma.table.delete({ where: { id } });
|
||||
|
||||
// After
|
||||
await query(`DELETE FROM table WHERE id = $1`, [id]);
|
||||
```
|
||||
|
||||
### 6. $transaction → transaction
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
await prisma.$transaction([
|
||||
prisma.table.update({ ... }),
|
||||
prisma.table.update({ ... })
|
||||
]);
|
||||
|
||||
// After
|
||||
await transaction(async (client) => {
|
||||
await client.query(`UPDATE table SET ...`, params1);
|
||||
await client.query(`UPDATE table SET ...`, params2);
|
||||
});
|
||||
```
|
||||
|
||||
### 7. groupBy → query with GROUP BY
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const result = await prisma.table.groupBy({
|
||||
by: ["category"],
|
||||
_count: true,
|
||||
});
|
||||
|
||||
// After
|
||||
const result = await query<T>(
|
||||
`SELECT category, COUNT(*) as count FROM table GROUP BY category`,
|
||||
[]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 진행 상황
|
||||
|
||||
### 전체 진행률: 17/29 (58.6%)
|
||||
|
||||
```
|
||||
Phase 1-3: Service Layer ████████████████████████████ 100% (415/415)
|
||||
Phase 4.1: Controllers ████████████████████████████ 100% (30/30)
|
||||
Phase 4.2: 남은 파일 ███████░░░░░░░░░░░░░░░░░░░░ 58% (17/29)
|
||||
```
|
||||
|
||||
### 상세 진행 상황
|
||||
|
||||
| 카테고리 | 완료 | 남음 | 진행률 |
|
||||
| ----------- | ---- | ---- | ------ |
|
||||
| Services | 415 | 0 | 100% |
|
||||
| Controllers | 30 | 11 | 73% |
|
||||
| Routes | 0 | 4 | 0% |
|
||||
| Config | 0 | 4 | 0% |
|
||||
| **총계** | 445 | 19 | 95.9% |
|
||||
|
||||
---
|
||||
|
||||
## 🎬 다음 단계
|
||||
|
||||
1. **webTypeStandardController.ts 전환** (11개)
|
||||
|
||||
- 가장 많은 Prisma 호출을 가진 남은 컨트롤러
|
||||
- 웹 타입 표준 관리 핵심 기능
|
||||
|
||||
2. **fileController.ts 전환** (1개)
|
||||
|
||||
- 단순 findUnique만 있어 빠르게 처리 가능
|
||||
|
||||
3. **Routes 전환** (4개)
|
||||
|
||||
- ddlRoutes.ts
|
||||
- companyManagementRoutes.ts
|
||||
|
||||
4. **Service 전환** (4개)
|
||||
|
||||
- multiConnectionQueryService.ts
|
||||
|
||||
5. **Config 제거** (4개)
|
||||
- database.ts 완전 제거 또는 재작성
|
||||
- 모든 의존성 제거
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **database.ts 처리**
|
||||
|
||||
- 현재 많은 파일이 `import prisma from '../config/database'` 사용
|
||||
- 모든 import를 `import { query, queryOne } from '../database/db'`로 변경 필요
|
||||
- 단계적으로 진행하여 빌드 오류 방지
|
||||
|
||||
2. **BigInt 처리**
|
||||
|
||||
- fileController의 `objid: BigInt(objid)` → `objid::bigint` 또는 `CAST(objid AS BIGINT)`
|
||||
|
||||
3. **트랜잭션 처리**
|
||||
|
||||
- webTypeStandardController의 `updateSortOrder`는 복잡한 트랜잭션
|
||||
- `transaction` 함수 사용 필요
|
||||
|
||||
4. **타입 안전성**
|
||||
- 모든 Raw Query에 명시적 타입 지정 필요
|
||||
- `query<WebTypeStandard>`, `queryOne<AttachFileInfo>` 등
|
||||
|
||||
---
|
||||
|
||||
## 📝 완료 후 작업
|
||||
|
||||
- [ ] 전체 컴파일 확인
|
||||
- [ ] Linter 오류 해결
|
||||
- [ ] 통합 테스트 실행
|
||||
- [ ] Prisma 관련 의존성 완전 제거 (package.json)
|
||||
- [ ] `prisma/` 디렉토리 정리
|
||||
- [ ] 문서 업데이트
|
||||
- [ ] 커밋 및 Push
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-01
|
||||
**최종 업데이트**: 2025-10-01
|
||||
**상태**: 🔄 진행 중 (58.6% 완료)
|
||||
|
|
@ -1,759 +0,0 @@
|
|||
# 외부 커넥션 관리 REST API 지원 확장 계획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 목적
|
||||
|
||||
현재 외부 데이터베이스 연결만 관리하는 `/admin/external-connections` 페이지에 REST API 연결 관리 기능을 추가하여, DB와 REST API 커넥션을 통합 관리할 수 있도록 확장합니다.
|
||||
|
||||
### 현재 상황
|
||||
|
||||
- **기존 기능**: 외부 데이터베이스 연결 정보만 관리 (MySQL, PostgreSQL, Oracle, SQL Server, SQLite)
|
||||
- **기존 테이블**: `external_db_connections` - DB 연결 정보 저장
|
||||
- **기존 UI**: 단일 화면에서 DB 연결 목록 표시 및 CRUD 작업
|
||||
|
||||
### 요구사항
|
||||
|
||||
1. **탭 전환**: DB 연결 관리 ↔ REST API 연결 관리 간 탭 전환 UI
|
||||
2. **REST API 관리**: 요청 주소별 헤더(키-값 쌍) 관리
|
||||
3. **연결 테스트**: REST API 호출이 정상 작동하는지 테스트 기능
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 데이터베이스 설계
|
||||
|
||||
### 신규 테이블: `external_rest_api_connections`
|
||||
|
||||
```sql
|
||||
CREATE TABLE external_rest_api_connections (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- 기본 정보
|
||||
connection_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
|
||||
-- REST API 연결 정보
|
||||
base_url VARCHAR(500) NOT NULL, -- 기본 URL (예: https://api.example.com)
|
||||
default_headers JSONB DEFAULT '{}', -- 기본 헤더 정보 (키-값 쌍)
|
||||
|
||||
-- 인증 설정
|
||||
auth_type VARCHAR(20) DEFAULT 'none', -- none, api-key, bearer, basic, oauth2
|
||||
auth_config JSONB, -- 인증 관련 설정
|
||||
|
||||
-- 고급 설정
|
||||
timeout INTEGER DEFAULT 30000, -- 요청 타임아웃 (ms)
|
||||
retry_count INTEGER DEFAULT 0, -- 재시도 횟수
|
||||
retry_delay INTEGER DEFAULT 1000, -- 재시도 간격 (ms)
|
||||
|
||||
-- 관리 정보
|
||||
company_code VARCHAR(20) DEFAULT '*',
|
||||
is_active CHAR(1) DEFAULT 'Y',
|
||||
created_date TIMESTAMP DEFAULT NOW(),
|
||||
created_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT NOW(),
|
||||
updated_by VARCHAR(50),
|
||||
|
||||
-- 테스트 정보
|
||||
last_test_date TIMESTAMP,
|
||||
last_test_result CHAR(1), -- Y: 성공, N: 실패
|
||||
last_test_message TEXT
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_rest_api_connections_company ON external_rest_api_connections(company_code);
|
||||
CREATE INDEX idx_rest_api_connections_active ON external_rest_api_connections(is_active);
|
||||
CREATE INDEX idx_rest_api_connections_name ON external_rest_api_connections(connection_name);
|
||||
```
|
||||
|
||||
### 샘플 데이터
|
||||
|
||||
```sql
|
||||
INSERT INTO external_rest_api_connections (
|
||||
connection_name, description, base_url, default_headers, auth_type, auth_config
|
||||
) VALUES
|
||||
(
|
||||
'기상청 API',
|
||||
'기상청 공공데이터 API',
|
||||
'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0',
|
||||
'{"Content-Type": "application/json", "Accept": "application/json"}',
|
||||
'api-key',
|
||||
'{"keyLocation": "query", "keyName": "serviceKey", "keyValue": "your-api-key-here"}'
|
||||
),
|
||||
(
|
||||
'사내 인사 시스템 API',
|
||||
'인사정보 조회용 내부 API',
|
||||
'https://hr.company.com/api/v1',
|
||||
'{"Content-Type": "application/json"}',
|
||||
'bearer',
|
||||
'{"token": "your-bearer-token-here"}'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 백엔드 구현
|
||||
|
||||
### 1. 타입 정의
|
||||
|
||||
```typescript
|
||||
// backend-node/src/types/externalRestApiTypes.ts
|
||||
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
|
||||
|
||||
export interface ExternalRestApiConnection {
|
||||
id?: number;
|
||||
connection_name: string;
|
||||
description?: string;
|
||||
base_url: string;
|
||||
default_headers: Record<string, string>;
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
// API Key
|
||||
keyLocation?: "header" | "query";
|
||||
keyName?: string;
|
||||
keyValue?: string;
|
||||
|
||||
// Bearer Token
|
||||
token?: string;
|
||||
|
||||
// Basic Auth
|
||||
username?: string;
|
||||
password?: string;
|
||||
|
||||
// OAuth2
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
accessToken?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
retry_delay?: number;
|
||||
company_code: string;
|
||||
is_active: string;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
updated_by?: string;
|
||||
last_test_date?: Date;
|
||||
last_test_result?: string;
|
||||
last_test_message?: string;
|
||||
}
|
||||
|
||||
export interface ExternalRestApiConnectionFilter {
|
||||
auth_type?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface RestApiTestRequest {
|
||||
id?: number;
|
||||
base_url: string;
|
||||
endpoint?: string; // 테스트할 엔드포인트 (선택)
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
auth_type?: AuthType;
|
||||
auth_config?: any;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface RestApiTestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
response_time?: number;
|
||||
status_code?: number;
|
||||
response_data?: any;
|
||||
error_details?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 서비스 계층
|
||||
|
||||
```typescript
|
||||
// backend-node/src/services/externalRestApiConnectionService.ts
|
||||
|
||||
export class ExternalRestApiConnectionService {
|
||||
// CRUD 메서드
|
||||
static async getConnections(filter: ExternalRestApiConnectionFilter);
|
||||
static async getConnectionById(id: number);
|
||||
static async createConnection(data: ExternalRestApiConnection);
|
||||
static async updateConnection(
|
||||
id: number,
|
||||
data: Partial<ExternalRestApiConnection>
|
||||
);
|
||||
static async deleteConnection(id: number);
|
||||
|
||||
// 테스트 메서드
|
||||
static async testConnection(
|
||||
testRequest: RestApiTestRequest
|
||||
): Promise<RestApiTestResult>;
|
||||
static async testConnectionById(
|
||||
id: number,
|
||||
endpoint?: string
|
||||
): Promise<RestApiTestResult>;
|
||||
|
||||
// 헬퍼 메서드
|
||||
private static buildHeaders(
|
||||
connection: ExternalRestApiConnection
|
||||
): Record<string, string>;
|
||||
private static validateConnectionData(data: ExternalRestApiConnection): void;
|
||||
private static encryptSensitiveData(authConfig: any): any;
|
||||
private static decryptSensitiveData(authConfig: any): any;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. API 라우트
|
||||
|
||||
```typescript
|
||||
// backend-node/src/routes/externalRestApiConnectionRoutes.ts
|
||||
|
||||
// GET /api/external-rest-api-connections - 목록 조회
|
||||
// GET /api/external-rest-api-connections/:id - 상세 조회
|
||||
// POST /api/external-rest-api-connections - 새 연결 생성
|
||||
// PUT /api/external-rest-api-connections/:id - 연결 수정
|
||||
// DELETE /api/external-rest-api-connections/:id - 연결 삭제
|
||||
// POST /api/external-rest-api-connections/test - 연결 테스트 (신규)
|
||||
// POST /api/external-rest-api-connections/:id/test - ID로 테스트 (기존 연결)
|
||||
```
|
||||
|
||||
### 4. 연결 테스트 구현
|
||||
|
||||
```typescript
|
||||
// REST API 연결 테스트 로직
|
||||
static async testConnection(testRequest: RestApiTestRequest): Promise<RestApiTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 헤더 구성
|
||||
const headers = { ...testRequest.headers };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (testRequest.auth_type === 'bearer' && testRequest.auth_config?.token) {
|
||||
headers['Authorization'] = `Bearer ${testRequest.auth_config.token}`;
|
||||
} else if (testRequest.auth_type === 'basic') {
|
||||
const credentials = Buffer.from(
|
||||
`${testRequest.auth_config.username}:${testRequest.auth_config.password}`
|
||||
).toString('base64');
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
} else if (testRequest.auth_type === 'api-key') {
|
||||
if (testRequest.auth_config.keyLocation === 'header') {
|
||||
headers[testRequest.auth_config.keyName] = testRequest.auth_config.keyValue;
|
||||
}
|
||||
}
|
||||
|
||||
// URL 구성
|
||||
let url = testRequest.base_url;
|
||||
if (testRequest.endpoint) {
|
||||
url = `${testRequest.base_url}${testRequest.endpoint}`;
|
||||
}
|
||||
|
||||
// API Key가 쿼리에 있는 경우
|
||||
if (testRequest.auth_type === 'api-key' &&
|
||||
testRequest.auth_config.keyLocation === 'query') {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
url = `${url}${separator}${testRequest.auth_config.keyName}=${testRequest.auth_config.keyValue}`;
|
||||
}
|
||||
|
||||
// HTTP 요청 실행
|
||||
const response = await fetch(url, {
|
||||
method: testRequest.method || 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(testRequest.timeout || 30000),
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
const responseData = await response.json().catch(() => null);
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
message: response.ok ? '연결 성공' : `연결 실패 (${response.status})`,
|
||||
response_time: responseTime,
|
||||
status_code: response.status,
|
||||
response_data: responseData,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: '연결 실패',
|
||||
error_details: error instanceof Error ? error.message : '알 수 없는 오류',
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 프론트엔드 구현
|
||||
|
||||
### 1. 탭 구조 설계
|
||||
|
||||
```typescript
|
||||
// frontend/app/(main)/admin/external-connections/page.tsx
|
||||
|
||||
type ConnectionTabType = "database" | "rest-api";
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ConnectionTabType>("database");
|
||||
```
|
||||
|
||||
### 2. 메인 페이지 구조 개선
|
||||
|
||||
```tsx
|
||||
// 탭 헤더
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as ConnectionTabType)}
|
||||
>
|
||||
<TabsList className="grid w-[400px] grid-cols-2">
|
||||
<TabsTrigger value="database" className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
데이터베이스 연결
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rest-api" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
REST API 연결
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 데이터베이스 연결 탭 */}
|
||||
<TabsContent value="database">
|
||||
<DatabaseConnectionList />
|
||||
</TabsContent>
|
||||
|
||||
{/* REST API 연결 탭 */}
|
||||
<TabsContent value="rest-api">
|
||||
<RestApiConnectionList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
### 3. REST API 연결 목록 컴포넌트
|
||||
|
||||
```typescript
|
||||
// frontend/components/admin/RestApiConnectionList.tsx
|
||||
|
||||
export function RestApiConnectionList() {
|
||||
const [connections, setConnections] = useState<ExternalRestApiConnection[]>(
|
||||
[]
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [authTypeFilter, setAuthTypeFilter] = useState("ALL");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<
|
||||
ExternalRestApiConnection | undefined
|
||||
>();
|
||||
|
||||
// 테이블 컬럼:
|
||||
// - 연결명
|
||||
// - 기본 URL
|
||||
// - 인증 타입
|
||||
// - 헤더 수 (default_headers 개수)
|
||||
// - 상태 (활성/비활성)
|
||||
// - 마지막 테스트 (날짜 + 결과)
|
||||
// - 작업 (테스트/편집/삭제)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. REST API 연결 설정 모달
|
||||
|
||||
```typescript
|
||||
// frontend/components/admin/RestApiConnectionModal.tsx
|
||||
|
||||
export function RestApiConnectionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
connection,
|
||||
}: RestApiConnectionModalProps) {
|
||||
// 섹션 구성:
|
||||
// 1. 기본 정보
|
||||
// - 연결명 (필수)
|
||||
// - 설명
|
||||
// - 기본 URL (필수)
|
||||
// 2. 헤더 관리 (키-값 추가/삭제)
|
||||
// - 동적 입력 필드
|
||||
// - + 버튼으로 추가
|
||||
// - 각 행에 삭제 버튼
|
||||
// 3. 인증 설정
|
||||
// - 인증 타입 선택 (none/api-key/bearer/basic/oauth2)
|
||||
// - 선택된 타입별 설정 필드 표시
|
||||
// 4. 고급 설정 (접기/펼치기)
|
||||
// - 타임아웃
|
||||
// - 재시도 설정
|
||||
// 5. 테스트 섹션
|
||||
// - 테스트 엔드포인트 입력 (선택)
|
||||
// - 테스트 실행 버튼
|
||||
// - 테스트 결과 표시
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 헤더 관리 컴포넌트
|
||||
|
||||
```typescript
|
||||
// frontend/components/admin/HeadersManager.tsx
|
||||
|
||||
interface HeadersManagerProps {
|
||||
headers: Record<string, string>;
|
||||
onChange: (headers: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export function HeadersManager({ headers, onChange }: HeadersManagerProps) {
|
||||
const [headersList, setHeadersList] = useState<
|
||||
Array<{ key: string; value: string }>
|
||||
>(Object.entries(headers).map(([key, value]) => ({ key, value })));
|
||||
|
||||
const addHeader = () => {
|
||||
setHeadersList([...headersList, { key: "", value: "" }]);
|
||||
};
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
const newList = headersList.filter((_, i) => i !== index);
|
||||
setHeadersList(newList);
|
||||
updateParent(newList);
|
||||
};
|
||||
|
||||
const updateHeader = (
|
||||
index: number,
|
||||
field: "key" | "value",
|
||||
value: string
|
||||
) => {
|
||||
const newList = [...headersList];
|
||||
newList[index][field] = value;
|
||||
setHeadersList(newList);
|
||||
updateParent(newList);
|
||||
};
|
||||
|
||||
const updateParent = (list: Array<{ key: string; value: string }>) => {
|
||||
const headersObject = list.reduce((acc, { key, value }) => {
|
||||
if (key.trim()) acc[key] = value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
onChange(headersObject);
|
||||
};
|
||||
|
||||
// UI: 테이블 형태로 키-값 입력 필드 표시
|
||||
// 각 행: [키 입력] [값 입력] [삭제 버튼]
|
||||
// 하단: [+ 헤더 추가] 버튼
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 인증 설정 컴포넌트
|
||||
|
||||
```typescript
|
||||
// frontend/components/admin/AuthenticationConfig.tsx
|
||||
|
||||
export function AuthenticationConfig({
|
||||
authType,
|
||||
authConfig,
|
||||
onChange,
|
||||
}: AuthenticationConfigProps) {
|
||||
// authType에 따라 다른 입력 필드 표시
|
||||
// none: 추가 필드 없음
|
||||
// api-key:
|
||||
// - 키 위치 (header/query)
|
||||
// - 키 이름
|
||||
// - 키 값
|
||||
// bearer:
|
||||
// - 토큰 값
|
||||
// basic:
|
||||
// - 사용자명
|
||||
// - 비밀번호
|
||||
// oauth2:
|
||||
// - Client ID
|
||||
// - Client Secret
|
||||
// - Token URL
|
||||
// - Access Token (읽기전용, 자동 갱신)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. API 클라이언트
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/externalRestApiConnection.ts
|
||||
|
||||
export class ExternalRestApiConnectionAPI {
|
||||
private static readonly BASE_URL = "/api/external-rest-api-connections";
|
||||
|
||||
static async getConnections(filter?: ExternalRestApiConnectionFilter) {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.search) params.append("search", filter.search);
|
||||
if (filter?.auth_type && filter.auth_type !== "ALL") {
|
||||
params.append("auth_type", filter.auth_type);
|
||||
}
|
||||
if (filter?.is_active && filter.is_active !== "ALL") {
|
||||
params.append("is_active", filter.is_active);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.BASE_URL}?${params}`);
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async getConnectionById(id: number) {
|
||||
const response = await fetch(`${this.BASE_URL}/${id}`);
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async createConnection(data: ExternalRestApiConnection) {
|
||||
const response = await fetch(this.BASE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async updateConnection(
|
||||
id: number,
|
||||
data: Partial<ExternalRestApiConnection>
|
||||
) {
|
||||
const response = await fetch(`${this.BASE_URL}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async deleteConnection(id: number) {
|
||||
const response = await fetch(`${this.BASE_URL}/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async testConnection(
|
||||
testRequest: RestApiTestRequest
|
||||
): Promise<RestApiTestResult> {
|
||||
const response = await fetch(`${this.BASE_URL}/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(testRequest),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async testConnectionById(
|
||||
id: number,
|
||||
endpoint?: string
|
||||
): Promise<RestApiTestResult> {
|
||||
const response = await fetch(`${this.BASE_URL}/${id}/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
private static async handleResponse(response: Response) {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.message || "요청 실패");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 구현 순서
|
||||
|
||||
### Phase 1: 데이터베이스 및 백엔드 기본 구조 (1일)
|
||||
|
||||
- [x] 데이터베이스 테이블 생성 (`external_rest_api_connections`)
|
||||
- [ ] 타입 정의 작성 (`externalRestApiTypes.ts`)
|
||||
- [ ] 서비스 계층 기본 CRUD 구현
|
||||
- [ ] API 라우트 기본 구현
|
||||
|
||||
### Phase 2: 연결 테스트 기능 (1일)
|
||||
|
||||
- [ ] 연결 테스트 로직 구현
|
||||
- [ ] 인증 타입별 헤더 구성 로직
|
||||
- [ ] 에러 처리 및 타임아웃 관리
|
||||
- [ ] 테스트 결과 저장 (last_test_date, last_test_result)
|
||||
|
||||
### Phase 3: 프론트엔드 기본 UI (1-2일)
|
||||
|
||||
- [ ] 탭 구조 추가 (Database / REST API)
|
||||
- [ ] REST API 연결 목록 컴포넌트
|
||||
- [ ] API 클라이언트 작성
|
||||
- [ ] 기본 CRUD UI 구현
|
||||
|
||||
### Phase 4: 모달 및 상세 기능 (1-2일)
|
||||
|
||||
- [ ] REST API 연결 설정 모달
|
||||
- [ ] 헤더 관리 컴포넌트 (키-값 동적 추가/삭제)
|
||||
- [ ] 인증 설정 컴포넌트 (타입별 입력 필드)
|
||||
- [ ] 고급 설정 섹션
|
||||
|
||||
### Phase 5: 테스트 및 통합 (1일)
|
||||
|
||||
- [ ] 연결 테스트 UI
|
||||
- [ ] 테스트 결과 표시
|
||||
- [ ] 에러 처리 및 사용자 피드백
|
||||
- [ ] 전체 기능 통합 테스트
|
||||
|
||||
### Phase 6: 최적화 및 마무리 (0.5일)
|
||||
|
||||
- [ ] 민감 정보 암호화 (API 키, 토큰, 비밀번호)
|
||||
- [ ] UI/UX 개선
|
||||
- [ ] 문서화
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 시나리오
|
||||
|
||||
### 1. REST API 연결 등록 테스트
|
||||
|
||||
- [ ] 기본 정보 입력 (연결명, URL)
|
||||
- [ ] 헤더 추가/삭제
|
||||
- [ ] 각 인증 타입별 설정
|
||||
- [ ] 유효성 검증 (필수 필드, URL 형식)
|
||||
|
||||
### 2. 연결 테스트
|
||||
|
||||
- [ ] 인증 없는 API 테스트
|
||||
- [ ] API Key (header/query) 테스트
|
||||
- [ ] Bearer Token 테스트
|
||||
- [ ] Basic Auth 테스트
|
||||
- [ ] 타임아웃 시나리오
|
||||
- [ ] 네트워크 오류 시나리오
|
||||
|
||||
### 3. 데이터 관리
|
||||
|
||||
- [ ] 목록 조회 및 필터링
|
||||
- [ ] 연결 수정
|
||||
- [ ] 연결 삭제
|
||||
- [ ] 활성/비활성 전환
|
||||
|
||||
### 4. 통합 시나리오
|
||||
|
||||
- [ ] DB 연결 탭 ↔ REST API 탭 전환
|
||||
- [ ] 여러 연결 등록 및 관리
|
||||
- [ ] 동시 테스트 실행
|
||||
|
||||
---
|
||||
|
||||
## 🔒 보안 고려사항
|
||||
|
||||
### 1. 민감 정보 암호화
|
||||
|
||||
```typescript
|
||||
// API 키, 토큰, 비밀번호 암호화
|
||||
private static encryptSensitiveData(authConfig: any): any {
|
||||
if (!authConfig) return null;
|
||||
|
||||
const encrypted = { ...authConfig };
|
||||
|
||||
// 암호화 대상 필드
|
||||
if (encrypted.keyValue) {
|
||||
encrypted.keyValue = encrypt(encrypted.keyValue);
|
||||
}
|
||||
if (encrypted.token) {
|
||||
encrypted.token = encrypt(encrypted.token);
|
||||
}
|
||||
if (encrypted.password) {
|
||||
encrypted.password = encrypt(encrypted.password);
|
||||
}
|
||||
if (encrypted.clientSecret) {
|
||||
encrypted.clientSecret = encrypt(encrypted.clientSecret);
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 접근 권한 제어
|
||||
|
||||
- 관리자 권한만 접근
|
||||
- 회사별 데이터 분리
|
||||
- API 호출 시 인증 토큰 검증
|
||||
|
||||
### 3. 테스트 요청 제한
|
||||
|
||||
- Rate Limiting (1분에 최대 10회)
|
||||
- 타임아웃 설정 (최대 30초)
|
||||
- 동시 테스트 제한
|
||||
|
||||
---
|
||||
|
||||
## 📊 성능 최적화
|
||||
|
||||
### 1. 헤더 데이터 구조
|
||||
|
||||
```typescript
|
||||
// JSONB 필드 인덱싱 (PostgreSQL)
|
||||
CREATE INDEX idx_rest_api_headers ON external_rest_api_connections
|
||||
USING GIN (default_headers);
|
||||
|
||||
CREATE INDEX idx_rest_api_auth_config ON external_rest_api_connections
|
||||
USING GIN (auth_config);
|
||||
```
|
||||
|
||||
### 2. 캐싱 전략
|
||||
|
||||
- 자주 사용되는 연결 정보 캐싱
|
||||
- 테스트 결과 임시 캐싱 (5분)
|
||||
|
||||
---
|
||||
|
||||
## 📚 향후 확장 가능성
|
||||
|
||||
### 1. 엔드포인트 관리
|
||||
|
||||
각 REST API 연결에 대해 자주 사용하는 엔드포인트를 사전 등록하여 빠른 호출 가능
|
||||
|
||||
### 2. 요청 템플릿
|
||||
|
||||
HTTP 메서드별 요청 바디 템플릿 관리
|
||||
|
||||
### 3. 응답 매핑
|
||||
|
||||
REST API 응답을 내부 데이터 구조로 변환하는 매핑 룰 설정
|
||||
|
||||
### 4. 로그 및 모니터링
|
||||
|
||||
- API 호출 이력 기록
|
||||
- 응답 시간 모니터링
|
||||
- 오류율 추적
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 체크리스트
|
||||
|
||||
### 백엔드
|
||||
|
||||
- [ ] 데이터베이스 테이블 생성
|
||||
- [ ] 타입 정의
|
||||
- [ ] 서비스 계층 CRUD
|
||||
- [ ] 연결 테스트 로직
|
||||
- [ ] API 라우트
|
||||
- [ ] 민감 정보 암호화
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
- [ ] 탭 구조
|
||||
- [ ] REST API 연결 목록
|
||||
- [ ] 연결 설정 모달
|
||||
- [ ] 헤더 관리 컴포넌트
|
||||
- [ ] 인증 설정 컴포넌트
|
||||
- [ ] API 클라이언트
|
||||
- [ ] 연결 테스트 UI
|
||||
|
||||
### 테스트
|
||||
|
||||
- [ ] 단위 테스트
|
||||
- [ ] 통합 테스트
|
||||
- [ ] 사용자 시나리오 테스트
|
||||
|
||||
### 문서
|
||||
|
||||
- [ ] API 문서
|
||||
- [ ] 사용자 가이드
|
||||
- [ ] 배포 가이드
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-10-20
|
||||
**버전**: 1.0
|
||||
**담당**: AI Assistant
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
# REST API 연결 관리 기능 구현 완료
|
||||
|
||||
## 구현 개요
|
||||
|
||||
외부 커넥션 관리 페이지(`/admin/external-connections`)에 REST API 연결 관리 기능이 추가되었습니다.
|
||||
기존의 데이터베이스 연결 관리와 함께 REST API 연결도 관리할 수 있도록 탭 기반 UI가 구현되었습니다.
|
||||
|
||||
## 구현 완료 사항
|
||||
|
||||
### 1. 데이터베이스 (✅ 완료)
|
||||
|
||||
**파일**: `/db/create_external_rest_api_connections.sql`
|
||||
|
||||
- `external_rest_api_connections` 테이블 생성
|
||||
- 연결 정보, 인증 설정, 테스트 결과 저장
|
||||
- JSONB 타입으로 헤더 및 인증 설정 유연하게 관리
|
||||
- 인덱스 최적화 (company_code, is_active, auth_type, JSONB GIN 인덱스)
|
||||
|
||||
**실행 방법**:
|
||||
|
||||
```bash
|
||||
# PostgreSQL 컨테이너에 접속하여 SQL 실행
|
||||
docker exec -i esgrin-mes-db psql -U postgres -d ilshin < db/create_external_rest_api_connections.sql
|
||||
```
|
||||
|
||||
### 2. 백엔드 구현 (✅ 완료)
|
||||
|
||||
#### 2.1 타입 정의
|
||||
|
||||
**파일**: `backend-node/src/types/externalRestApiTypes.ts`
|
||||
|
||||
- `ExternalRestApiConnection`: REST API 연결 정보 인터페이스
|
||||
- `RestApiTestRequest`: 연결 테스트 요청 인터페이스
|
||||
- `RestApiTestResult`: 테스트 결과 인터페이스
|
||||
- `AuthType`: 인증 타입 (none, api-key, bearer, basic, oauth2)
|
||||
- 각 인증 타입별 세부 설정 인터페이스
|
||||
|
||||
#### 2.2 서비스 레이어
|
||||
|
||||
**파일**: `backend-node/src/services/externalRestApiConnectionService.ts`
|
||||
|
||||
- CRUD 작업 구현 (생성, 조회, 수정, 삭제)
|
||||
- 민감 정보 암호화/복호화 (AES-256-GCM)
|
||||
- REST API 연결 테스트 기능
|
||||
- 필터링 및 검색 기능
|
||||
- 유효성 검증
|
||||
|
||||
#### 2.3 API 라우트
|
||||
|
||||
**파일**: `backend-node/src/routes/externalRestApiConnectionRoutes.ts`
|
||||
|
||||
- `GET /api/external-rest-api-connections` - 목록 조회
|
||||
- `GET /api/external-rest-api-connections/:id` - 상세 조회
|
||||
- `POST /api/external-rest-api-connections` - 생성
|
||||
- `PUT /api/external-rest-api-connections/:id` - 수정
|
||||
- `DELETE /api/external-rest-api-connections/:id` - 삭제
|
||||
- `POST /api/external-rest-api-connections/test` - 연결 테스트
|
||||
- `POST /api/external-rest-api-connections/:id/test` - ID 기반 테스트
|
||||
|
||||
#### 2.4 앱 통합
|
||||
|
||||
**파일**: `backend-node/src/app.ts`
|
||||
|
||||
- 새로운 라우트 등록 완료
|
||||
|
||||
### 3. 프론트엔드 구현 (✅ 완료)
|
||||
|
||||
#### 3.1 API 클라이언트
|
||||
|
||||
**파일**: `frontend/lib/api/externalRestApiConnection.ts`
|
||||
|
||||
- 백엔드 API와 통신하는 클라이언트 구현
|
||||
- 타입 안전한 API 호출
|
||||
- 에러 처리
|
||||
|
||||
#### 3.2 공통 컴포넌트
|
||||
|
||||
**파일**: `frontend/components/admin/HeadersManager.tsx`
|
||||
|
||||
- HTTP 헤더 key-value 관리 컴포넌트
|
||||
- 동적 추가/삭제 기능
|
||||
|
||||
**파일**: `frontend/components/admin/AuthenticationConfig.tsx`
|
||||
|
||||
- 인증 타입별 설정 컴포넌트
|
||||
- 5가지 인증 방식 지원 (none, api-key, bearer, basic, oauth2)
|
||||
|
||||
#### 3.3 모달 컴포넌트
|
||||
|
||||
**파일**: `frontend/components/admin/RestApiConnectionModal.tsx`
|
||||
|
||||
- 연결 추가/수정 모달
|
||||
- 헤더 관리 및 인증 설정 통합
|
||||
- 연결 테스트 기능
|
||||
|
||||
#### 3.4 목록 관리 컴포넌트
|
||||
|
||||
**파일**: `frontend/components/admin/RestApiConnectionList.tsx`
|
||||
|
||||
- REST API 연결 목록 표시
|
||||
- 검색 및 필터링
|
||||
- CRUD 작업
|
||||
- 연결 테스트
|
||||
|
||||
#### 3.5 메인 페이지
|
||||
|
||||
**파일**: `frontend/app/(main)/admin/external-connections/page.tsx`
|
||||
|
||||
- 탭 기반 UI 구현 (데이터베이스 ↔ REST API)
|
||||
- 기존 DB 연결 관리와 통합
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 1. 연결 관리
|
||||
|
||||
- REST API 연결 정보 생성/수정/삭제
|
||||
- 연결명, 설명, Base URL 관리
|
||||
- Timeout, Retry 설정
|
||||
- 활성화 상태 관리
|
||||
|
||||
### 2. 인증 관리
|
||||
|
||||
- **None**: 인증 없음
|
||||
- **API Key**: 헤더 또는 쿼리 파라미터
|
||||
- **Bearer Token**: Authorization: Bearer {token}
|
||||
- **Basic Auth**: username/password
|
||||
- **OAuth2**: client_id, client_secret, token_url 등
|
||||
|
||||
### 3. 헤더 관리
|
||||
|
||||
- 기본 HTTP 헤더 설정
|
||||
- Key-Value 형식으로 동적 관리
|
||||
- Content-Type, Accept 등 자유롭게 설정
|
||||
|
||||
### 4. 연결 테스트
|
||||
|
||||
- 실시간 연결 테스트
|
||||
- HTTP 응답 상태 코드 확인
|
||||
- 응답 시간 측정
|
||||
- 테스트 결과 저장
|
||||
|
||||
### 5. 보안
|
||||
|
||||
- 민감 정보 자동 암호화 (AES-256-GCM)
|
||||
- API Key
|
||||
- Bearer Token
|
||||
- 비밀번호
|
||||
- OAuth2 Client Secret
|
||||
- 암호화된 데이터는 데이터베이스에 안전하게 저장
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 1. SQL 스크립트 실행
|
||||
|
||||
```bash
|
||||
# PostgreSQL 컨테이너에 접속
|
||||
docker exec -it esgrin-mes-db psql -U postgres -d ilshin
|
||||
|
||||
# 또는 파일 직접 실행
|
||||
docker exec -i esgrin-mes-db psql -U postgres -d ilshin < db/create_external_rest_api_connections.sql
|
||||
```
|
||||
|
||||
### 2. 백엔드 재시작
|
||||
|
||||
백엔드 서버가 자동으로 새로운 라우트를 인식합니다. (이미 재시작 완료)
|
||||
|
||||
### 3. 웹 UI 접속
|
||||
|
||||
1. `/admin/external-connections` 페이지 접속
|
||||
2. "REST API 연결" 탭 선택
|
||||
3. "새 연결 추가" 버튼 클릭
|
||||
4. 필요한 정보 입력
|
||||
- 연결명, 설명, Base URL
|
||||
- 기본 헤더 설정
|
||||
- 인증 타입 선택 및 인증 정보 입력
|
||||
- Timeout, Retry 설정
|
||||
5. "연결 테스트" 버튼으로 즉시 테스트 가능
|
||||
6. 저장
|
||||
|
||||
### 4. 연결 관리
|
||||
|
||||
- **목록 조회**: 모든 REST API 연결 정보 확인
|
||||
- **검색**: 연결명, 설명, URL로 검색
|
||||
- **필터링**: 인증 타입, 활성화 상태로 필터링
|
||||
- **수정**: 연필 아이콘 클릭하여 수정
|
||||
- **삭제**: 휴지통 아이콘 클릭하여 삭제
|
||||
- **테스트**: Play 아이콘 클릭하여 연결 테스트
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **Backend**: Node.js, Express, TypeScript, PostgreSQL
|
||||
- **Frontend**: Next.js, React, TypeScript, Shadcn UI
|
||||
- **보안**: AES-256-GCM 암호화
|
||||
- **데이터**: JSONB (PostgreSQL)
|
||||
|
||||
## 테스트 완료
|
||||
|
||||
- ✅ 백엔드 컴파일 성공
|
||||
- ✅ 서버 정상 실행 확인
|
||||
- ✅ 타입 에러 수정 완료
|
||||
- ✅ 모든 라우트 등록 완료
|
||||
- ✅ 인증 토큰 자동 포함 구현 (apiClient 사용)
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. SQL 스크립트 실행
|
||||
2. 프론트엔드 빌드 및 테스트
|
||||
3. UI에서 연결 추가/수정/삭제/테스트 기능 확인
|
||||
|
||||
## 참고 문서
|
||||
|
||||
- 전체 계획: `PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md`
|
||||
- 기존 외부 DB 연결: `제어관리_외부커넥션_통합_기능_가이드.md`
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,998 +0,0 @@
|
|||
# 반응형 레이아웃 시스템 구현 계획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 목표
|
||||
|
||||
화면 디자이너는 절대 위치 기반으로 유지하되, 실제 화면 표시는 반응형으로 동작하도록 전환
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
- ✅ 화면 디자이너의 절대 위치 기반 드래그앤드롭은 그대로 유지
|
||||
- ✅ 실제 화면 표시만 반응형으로 전환
|
||||
- ✅ 데이터 마이그레이션 불필요 (신규 화면부터 적용)
|
||||
- ✅ 기존 화면은 불러올 때 스마트 기본값 자동 생성
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Phase 1: 기본 반응형 시스템 구축 (2-3일)
|
||||
|
||||
### 1.1 타입 정의 (2시간)
|
||||
|
||||
#### 파일: `frontend/types/responsive.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 브레이크포인트 타입 정의
|
||||
*/
|
||||
export type Breakpoint = "desktop" | "tablet" | "mobile";
|
||||
|
||||
/**
|
||||
* 브레이크포인트별 설정
|
||||
*/
|
||||
export interface BreakpointConfig {
|
||||
minWidth: number; // 최소 너비 (px)
|
||||
maxWidth?: number; // 최대 너비 (px)
|
||||
columns: number; // 그리드 컬럼 수
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 브레이크포인트 설정
|
||||
*/
|
||||
export const BREAKPOINTS: Record<Breakpoint, BreakpointConfig> = {
|
||||
desktop: {
|
||||
minWidth: 1200,
|
||||
columns: 12,
|
||||
},
|
||||
tablet: {
|
||||
minWidth: 768,
|
||||
maxWidth: 1199,
|
||||
columns: 8,
|
||||
},
|
||||
mobile: {
|
||||
minWidth: 0,
|
||||
maxWidth: 767,
|
||||
columns: 4,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 브레이크포인트별 반응형 설정
|
||||
*/
|
||||
export interface ResponsiveBreakpointConfig {
|
||||
gridColumns?: number; // 차지할 컬럼 수 (1-12)
|
||||
order?: number; // 정렬 순서
|
||||
hide?: boolean; // 숨김 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 반응형 설정
|
||||
*/
|
||||
export interface ResponsiveComponentConfig {
|
||||
// 기본값 (디자이너에서 설정한 절대 위치)
|
||||
designerPosition: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// 반응형 설정 (선택적)
|
||||
responsive?: {
|
||||
desktop?: ResponsiveBreakpointConfig;
|
||||
tablet?: ResponsiveBreakpointConfig;
|
||||
mobile?: ResponsiveBreakpointConfig;
|
||||
};
|
||||
|
||||
// 스마트 기본값 사용 여부
|
||||
useSmartDefaults?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 스마트 기본값 생성기 (3시간)
|
||||
|
||||
#### 파일: `frontend/lib/utils/responsiveDefaults.ts`
|
||||
|
||||
```typescript
|
||||
import { ComponentData } from "@/types/screen-management";
|
||||
import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive";
|
||||
|
||||
/**
|
||||
* 컴포넌트 크기에 따른 스마트 기본값 생성
|
||||
*
|
||||
* 로직:
|
||||
* - 작은 컴포넌트 (너비 25% 이하): 모바일에서도 같은 너비 유지
|
||||
* - 중간 컴포넌트 (너비 25-50%): 모바일에서 전체 너비로 확장
|
||||
* - 큰 컴포넌트 (너비 50% 이상): 모든 디바이스에서 전체 너비
|
||||
*/
|
||||
export function generateSmartDefaults(
|
||||
component: ComponentData,
|
||||
screenWidth: number = 1920
|
||||
): ResponsiveComponentConfig["responsive"] {
|
||||
const componentWidthPercent = (component.size.width / screenWidth) * 100;
|
||||
|
||||
// 작은 컴포넌트 (25% 이하)
|
||||
if (componentWidthPercent <= 25) {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 3, // 12컬럼 중 3개 (25%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 2, // 8컬럼 중 2개 (25%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 1, // 4컬럼 중 1개 (25%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
// 중간 컴포넌트 (25-50%)
|
||||
else if (componentWidthPercent <= 50) {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 6, // 12컬럼 중 6개 (50%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 4, // 8컬럼 중 4개 (50%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 4, // 4컬럼 전체 (100%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
// 큰 컴포넌트 (50% 이상)
|
||||
else {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 12, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 8, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 4, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트에 반응형 설정이 없을 경우 자동 생성
|
||||
*/
|
||||
export function ensureResponsiveConfig(
|
||||
component: ComponentData,
|
||||
screenWidth?: number
|
||||
): ComponentData {
|
||||
if (component.responsiveConfig) {
|
||||
return component;
|
||||
}
|
||||
|
||||
return {
|
||||
...component,
|
||||
responsiveConfig: {
|
||||
designerPosition: {
|
||||
x: component.position.x,
|
||||
y: component.position.y,
|
||||
width: component.size.width,
|
||||
height: component.size.height,
|
||||
},
|
||||
useSmartDefaults: true,
|
||||
responsive: generateSmartDefaults(component, screenWidth),
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 브레이크포인트 감지 훅 (1시간)
|
||||
|
||||
#### 파일: `frontend/hooks/useBreakpoint.ts`
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from "react";
|
||||
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
||||
|
||||
/**
|
||||
* 현재 윈도우 크기에 따른 브레이크포인트 반환
|
||||
*/
|
||||
export function useBreakpoint(): Breakpoint {
|
||||
const [breakpoint, setBreakpoint] = useState<Breakpoint>("desktop");
|
||||
|
||||
useEffect(() => {
|
||||
const updateBreakpoint = () => {
|
||||
const width = window.innerWidth;
|
||||
|
||||
if (width >= BREAKPOINTS.desktop.minWidth) {
|
||||
setBreakpoint("desktop");
|
||||
} else if (width >= BREAKPOINTS.tablet.minWidth) {
|
||||
setBreakpoint("tablet");
|
||||
} else {
|
||||
setBreakpoint("mobile");
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 실행
|
||||
updateBreakpoint();
|
||||
|
||||
// 리사이즈 이벤트 리스너 등록
|
||||
window.addEventListener("resize", updateBreakpoint);
|
||||
|
||||
return () => window.removeEventListener("resize", updateBreakpoint);
|
||||
}, []);
|
||||
|
||||
return breakpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 브레이크포인트의 컬럼 수 반환
|
||||
*/
|
||||
export function useGridColumns(): number {
|
||||
const breakpoint = useBreakpoint();
|
||||
return BREAKPOINTS[breakpoint].columns;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 반응형 레이아웃 엔진 (6시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/ResponsiveLayoutEngine.tsx`
|
||||
|
||||
```typescript
|
||||
import React, { useMemo } from "react";
|
||||
import { ComponentData } from "@/types/screen-management";
|
||||
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
||||
import {
|
||||
generateSmartDefaults,
|
||||
ensureResponsiveConfig,
|
||||
} from "@/lib/utils/responsiveDefaults";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
|
||||
interface ResponsiveLayoutEngineProps {
|
||||
components: ComponentData[];
|
||||
breakpoint: Breakpoint;
|
||||
containerWidth: number;
|
||||
screenWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 반응형 레이아웃 엔진
|
||||
*
|
||||
* 절대 위치로 배치된 컴포넌트들을 반응형 그리드로 변환
|
||||
*
|
||||
* 변환 로직:
|
||||
* 1. Y 위치 기준으로 행(row)으로 그룹화
|
||||
* 2. 각 행 내에서 X 위치 기준으로 정렬
|
||||
* 3. 반응형 설정 적용 (order, gridColumns, hide)
|
||||
* 4. CSS Grid로 렌더링
|
||||
*/
|
||||
export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
|
||||
components,
|
||||
breakpoint,
|
||||
containerWidth,
|
||||
screenWidth = 1920,
|
||||
}) => {
|
||||
// 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화
|
||||
const rows = useMemo(() => {
|
||||
const sortedComponents = [...components].sort(
|
||||
(a, b) => a.position.y - b.position.y
|
||||
);
|
||||
|
||||
const rows: ComponentData[][] = [];
|
||||
let currentRow: ComponentData[] = [];
|
||||
let currentRowY = 0;
|
||||
const ROW_THRESHOLD = 50; // 같은 행으로 간주할 Y 오차 범위 (px)
|
||||
|
||||
sortedComponents.forEach((comp) => {
|
||||
if (currentRow.length === 0) {
|
||||
currentRow.push(comp);
|
||||
currentRowY = comp.position.y;
|
||||
} else if (Math.abs(comp.position.y - currentRowY) < ROW_THRESHOLD) {
|
||||
currentRow.push(comp);
|
||||
} else {
|
||||
rows.push(currentRow);
|
||||
currentRow = [comp];
|
||||
currentRowY = comp.position.y;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [components]);
|
||||
|
||||
// 2단계: 각 행 내에서 X 위치 기준으로 정렬
|
||||
const sortedRows = useMemo(() => {
|
||||
return rows.map((row) =>
|
||||
[...row].sort((a, b) => a.position.x - b.position.x)
|
||||
);
|
||||
}, [rows]);
|
||||
|
||||
// 3단계: 반응형 설정 적용
|
||||
const responsiveComponents = useMemo(() => {
|
||||
return sortedRows.flatMap((row) =>
|
||||
row.map((comp) => {
|
||||
// 반응형 설정이 없으면 자동 생성
|
||||
const compWithConfig = ensureResponsiveConfig(comp, screenWidth);
|
||||
|
||||
// 현재 브레이크포인트의 설정 가져오기
|
||||
const config = compWithConfig.responsiveConfig!.useSmartDefaults
|
||||
? generateSmartDefaults(comp, screenWidth)[breakpoint]
|
||||
: compWithConfig.responsiveConfig!.responsive?.[breakpoint];
|
||||
|
||||
return {
|
||||
...compWithConfig,
|
||||
responsiveDisplay:
|
||||
config || generateSmartDefaults(comp, screenWidth)[breakpoint],
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [sortedRows, breakpoint, screenWidth]);
|
||||
|
||||
// 4단계: 필터링 및 정렬
|
||||
const visibleComponents = useMemo(() => {
|
||||
return responsiveComponents
|
||||
.filter((comp) => !comp.responsiveDisplay?.hide)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.responsiveDisplay?.order || 0) - (b.responsiveDisplay?.order || 0)
|
||||
);
|
||||
}, [responsiveComponents]);
|
||||
|
||||
const gridColumns = BREAKPOINTS[breakpoint].columns;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="responsive-grid w-full"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
||||
gap: "16px",
|
||||
padding: "16px",
|
||||
}}
|
||||
>
|
||||
{visibleComponents.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="responsive-grid-item"
|
||||
style={{
|
||||
gridColumn: `span ${
|
||||
comp.responsiveDisplay?.gridColumns || gridColumns
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer component={comp} isPreview={true} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 1.5 화면 표시 페이지 수정 (4시간)
|
||||
|
||||
#### 파일: `frontend/app/(main)/screens/[screenId]/page.tsx`
|
||||
|
||||
```typescript
|
||||
// 기존 import 유지
|
||||
import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine";
|
||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||
|
||||
export default function ScreenViewPage({
|
||||
params,
|
||||
}: {
|
||||
params: { screenId: string };
|
||||
}) {
|
||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
// 반응형 모드 토글 (사용자 설정 또는 화면 설정에 따라)
|
||||
const [useResponsive, setUseResponsive] = useState(true);
|
||||
|
||||
// 기존 로직 유지...
|
||||
|
||||
if (!layout) {
|
||||
return <div>로딩 중...</div>;
|
||||
}
|
||||
|
||||
const screenWidth = layout.screenResolution?.width || 1920;
|
||||
const screenHeight = layout.screenResolution?.height || 1080;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-white">
|
||||
{useResponsive ? (
|
||||
// 반응형 모드
|
||||
<ResponsiveLayoutEngine
|
||||
components={layout.components || []}
|
||||
breakpoint={breakpoint}
|
||||
containerWidth={window.innerWidth}
|
||||
screenWidth={screenWidth}
|
||||
/>
|
||||
) : (
|
||||
// 기존 스케일 모드 (하위 호환성)
|
||||
<div className="overflow-auto" style={{ padding: "16px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${screenWidth * scale}px`,
|
||||
minHeight: `${screenHeight * scale}px`,
|
||||
marginLeft: "16px",
|
||||
marginRight: "16px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
minHeight: `${screenHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
{layout.components?.map((component) => (
|
||||
<div
|
||||
key={component.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width:
|
||||
component.style?.width || `${component.size.width}px`,
|
||||
minHeight:
|
||||
component.style?.height || `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isPreview={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Phase 2: 디자이너 통합 (1-2일)
|
||||
|
||||
### 2.1 반응형 설정 패널 (5시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/panels/ResponsiveConfigPanel.tsx`
|
||||
|
||||
```typescript
|
||||
import React, { useState } from "react";
|
||||
import { ComponentData } from "@/types/screen-management";
|
||||
import {
|
||||
Breakpoint,
|
||||
BREAKPOINTS,
|
||||
ResponsiveComponentConfig,
|
||||
} from "@/types/responsive";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
interface ResponsiveConfigPanelProps {
|
||||
component: ComponentData;
|
||||
onUpdate: (config: ResponsiveComponentConfig) => void;
|
||||
}
|
||||
|
||||
export const ResponsiveConfigPanel: React.FC<ResponsiveConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<Breakpoint>("desktop");
|
||||
|
||||
const config = component.responsiveConfig || {
|
||||
designerPosition: {
|
||||
x: component.position.x,
|
||||
y: component.position.y,
|
||||
width: component.size.width,
|
||||
height: component.size.height,
|
||||
},
|
||||
useSmartDefaults: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>반응형 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 스마트 기본값 토글 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="smartDefaults"
|
||||
checked={config.useSmartDefaults}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
useSmartDefaults: checked as boolean,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="smartDefaults">스마트 기본값 사용 (권장)</Label>
|
||||
</div>
|
||||
|
||||
{/* 수동 설정 */}
|
||||
{!config.useSmartDefaults && (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as Breakpoint)}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="desktop">데스크톱</TabsTrigger>
|
||||
<TabsTrigger value="tablet">태블릿</TabsTrigger>
|
||||
<TabsTrigger value="mobile">모바일</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab} className="space-y-4">
|
||||
{/* 그리드 컬럼 수 */}
|
||||
<div className="space-y-2">
|
||||
<Label>너비 (그리드 컬럼)</Label>
|
||||
<Select
|
||||
value={config.responsive?.[
|
||||
activeTab
|
||||
]?.gridColumns?.toString()}
|
||||
onValueChange={(v) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
responsive: {
|
||||
...config.responsive,
|
||||
[activeTab]: {
|
||||
...config.responsive?.[activeTab],
|
||||
gridColumns: parseInt(v),
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="컬럼 수 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[...Array(BREAKPOINTS[activeTab].columns)].map((_, i) => {
|
||||
const cols = i + 1;
|
||||
const percent = (
|
||||
(cols / BREAKPOINTS[activeTab].columns) *
|
||||
100
|
||||
).toFixed(0);
|
||||
return (
|
||||
<SelectItem key={cols} value={cols.toString()}>
|
||||
{cols} / {BREAKPOINTS[activeTab].columns} ({percent}%)
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 표시 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label>표시 순서</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.responsive?.[activeTab]?.order || 1}
|
||||
onChange={(e) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
responsive: {
|
||||
...config.responsive,
|
||||
[activeTab]: {
|
||||
...config.responsive?.[activeTab],
|
||||
order: parseInt(e.target.value),
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 숨김 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`hide-${activeTab}`}
|
||||
checked={config.responsive?.[activeTab]?.hide || false}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
responsive: {
|
||||
...config.responsive,
|
||||
[activeTab]: {
|
||||
...config.responsive?.[activeTab],
|
||||
hide: checked as boolean,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`hide-${activeTab}`}>
|
||||
{activeTab === "desktop"
|
||||
? "데스크톱"
|
||||
: activeTab === "tablet"
|
||||
? "태블릿"
|
||||
: "모바일"}
|
||||
에서 숨김
|
||||
</Label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 속성 패널 통합 (1시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정
|
||||
|
||||
```typescript
|
||||
// 기존 import에 추가
|
||||
import { ResponsiveConfigPanel } from './ResponsiveConfigPanel';
|
||||
|
||||
// 컴포넌트 내부에 추가
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 기존 패널들 */}
|
||||
<PropertiesPanel ... />
|
||||
<StyleEditor ... />
|
||||
|
||||
{/* 반응형 설정 패널 추가 */}
|
||||
<ResponsiveConfigPanel
|
||||
component={selectedComponent}
|
||||
onUpdate={(config) => {
|
||||
onUpdateComponent({
|
||||
...selectedComponent,
|
||||
responsiveConfig: config
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 기존 세부 설정 패널 */}
|
||||
<DetailSettingsPanel ... />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### 2.3 미리보기 모드 (3시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||
|
||||
```typescript
|
||||
// 추가 import
|
||||
import { Breakpoint } from '@/types/responsive';
|
||||
import { ResponsiveLayoutEngine } from './ResponsiveLayoutEngine';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export const ScreenDesigner: React.FC = () => {
|
||||
// 미리보기 모드: 'design' | 'desktop' | 'tablet' | 'mobile'
|
||||
const [previewMode, setPreviewMode] = useState<'design' | Breakpoint>('design');
|
||||
const currentBreakpoint = useBreakpoint();
|
||||
|
||||
// ... 기존 로직 ...
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 상단 툴바 */}
|
||||
<div className="flex gap-2 p-2 border-b bg-white">
|
||||
<Button
|
||||
variant={previewMode === 'design' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('design')}
|
||||
>
|
||||
디자인 모드
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewMode === 'desktop' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('desktop')}
|
||||
>
|
||||
데스크톱 미리보기
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewMode === 'tablet' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('tablet')}
|
||||
>
|
||||
태블릿 미리보기
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewMode === 'mobile' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('mobile')}
|
||||
>
|
||||
모바일 미리보기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 캔버스 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{previewMode === 'design' ? (
|
||||
// 기존 절대 위치 기반 디자이너
|
||||
<Canvas ... />
|
||||
) : (
|
||||
// 반응형 미리보기
|
||||
<div
|
||||
className="mx-auto border border-gray-300"
|
||||
style={{
|
||||
width: previewMode === 'desktop' ? '100%' :
|
||||
previewMode === 'tablet' ? '768px' :
|
||||
'375px',
|
||||
minHeight: '100%'
|
||||
}}
|
||||
>
|
||||
<ResponsiveLayoutEngine
|
||||
components={components}
|
||||
breakpoint={previewMode}
|
||||
containerWidth={
|
||||
previewMode === 'desktop' ? window.innerWidth :
|
||||
previewMode === 'tablet' ? 768 :
|
||||
375
|
||||
}
|
||||
screenWidth={selectedScreen?.screenResolution?.width || 1920}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 Phase 3: 저장/불러오기 (1일)
|
||||
|
||||
### 3.1 타입 업데이트 (2시간)
|
||||
|
||||
#### 파일: `frontend/types/screen-management.ts` 수정
|
||||
|
||||
```typescript
|
||||
import { ResponsiveComponentConfig } from "./responsive";
|
||||
|
||||
export interface ComponentData {
|
||||
// ... 기존 필드들 ...
|
||||
|
||||
// 반응형 설정 추가
|
||||
responsiveConfig?: ResponsiveComponentConfig;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 저장 로직 (2시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||
|
||||
```typescript
|
||||
// 저장 함수 수정
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const layoutData: LayoutData = {
|
||||
screenResolution: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
components: components.map((comp) => ({
|
||||
...comp,
|
||||
// 반응형 설정이 없으면 자동 생성
|
||||
responsiveConfig: comp.responsiveConfig || {
|
||||
designerPosition: {
|
||||
x: comp.position.x,
|
||||
y: comp.position.y,
|
||||
width: comp.size.width,
|
||||
height: comp.size.height,
|
||||
},
|
||||
useSmartDefaults: true,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
await screenApi.updateLayout(selectedScreen.id, layoutData);
|
||||
// ... 기존 로직 ...
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3 불러오기 로직 (2시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||
|
||||
```typescript
|
||||
import { ensureResponsiveConfig } from "@/lib/utils/responsiveDefaults";
|
||||
|
||||
// 화면 불러오기
|
||||
useEffect(() => {
|
||||
const loadScreen = async () => {
|
||||
if (!selectedScreenId) return;
|
||||
|
||||
const screen = await screenApi.getScreenById(selectedScreenId);
|
||||
const layout = await screenApi.getLayout(selectedScreenId);
|
||||
|
||||
// 반응형 설정이 없는 컴포넌트에 자동 생성
|
||||
const componentsWithResponsive = layout.components.map((comp) =>
|
||||
ensureResponsiveConfig(comp, layout.screenResolution?.width)
|
||||
);
|
||||
|
||||
setSelectedScreen(screen);
|
||||
setComponents(componentsWithResponsive);
|
||||
};
|
||||
|
||||
loadScreen();
|
||||
}, [selectedScreenId]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Phase 4: 테스트 및 최적화 (1일)
|
||||
|
||||
### 4.1 기능 테스트 체크리스트 (3시간)
|
||||
|
||||
- [ ] 브레이크포인트 전환 테스트
|
||||
- [ ] 윈도우 크기 변경 시 자동 전환
|
||||
- [ ] desktop → tablet → mobile 순차 테스트
|
||||
- [ ] 스마트 기본값 생성 테스트
|
||||
- [ ] 작은 컴포넌트 (25% 이하)
|
||||
- [ ] 중간 컴포넌트 (25-50%)
|
||||
- [ ] 큰 컴포넌트 (50% 이상)
|
||||
- [ ] 수동 설정 적용 테스트
|
||||
- [ ] 그리드 컬럼 변경
|
||||
- [ ] 표시 순서 변경
|
||||
- [ ] 디바이스별 숨김
|
||||
- [ ] 미리보기 모드 테스트
|
||||
- [ ] 디자인 모드 ↔ 미리보기 모드 전환
|
||||
- [ ] 각 브레이크포인트 미리보기
|
||||
- [ ] 저장/불러오기 테스트
|
||||
- [ ] 반응형 설정 저장
|
||||
- [ ] 기존 화면 불러오기 시 자동 변환
|
||||
|
||||
### 4.2 성능 최적화 (3시간)
|
||||
|
||||
#### 레이아웃 계산 메모이제이션
|
||||
|
||||
```typescript
|
||||
// ResponsiveLayoutEngine.tsx
|
||||
const memoizedLayout = useMemo(() => {
|
||||
// 레이아웃 계산 로직
|
||||
}, [components, breakpoint, screenWidth]);
|
||||
```
|
||||
|
||||
#### ResizeObserver 최적화
|
||||
|
||||
```typescript
|
||||
// useBreakpoint.ts
|
||||
// debounce 적용
|
||||
const debouncedResize = debounce(updateBreakpoint, 150);
|
||||
window.addEventListener("resize", debouncedResize);
|
||||
```
|
||||
|
||||
#### 불필요한 리렌더링 방지
|
||||
|
||||
```typescript
|
||||
// React.memo 적용
|
||||
export const ResponsiveLayoutEngine = React.memo<ResponsiveLayoutEngineProps>(({...}) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### 4.3 UI/UX 개선 (2시간)
|
||||
|
||||
- [ ] 반응형 설정 패널 툴팁 추가
|
||||
- [ ] 미리보기 모드 전환 애니메이션
|
||||
- [ ] 로딩 상태 표시
|
||||
- [ ] 에러 처리 및 사용자 피드백
|
||||
|
||||
---
|
||||
|
||||
## 📅 최종 타임라인
|
||||
|
||||
| Phase | 작업 내용 | 소요 시간 | 누적 시간 |
|
||||
| ------- | --------------------- | --------- | ------------ |
|
||||
| Phase 1 | 타입 정의 및 유틸리티 | 6시간 | 6시간 |
|
||||
| Phase 1 | 반응형 레이아웃 엔진 | 6시간 | 12시간 |
|
||||
| Phase 1 | 화면 표시 페이지 수정 | 4시간 | 16시간 (2일) |
|
||||
| Phase 2 | 반응형 설정 패널 | 5시간 | 21시간 |
|
||||
| Phase 2 | 디자이너 통합 | 4시간 | 25시간 (3일) |
|
||||
| Phase 3 | 저장/불러오기 | 6시간 | 31시간 (4일) |
|
||||
| Phase 4 | 테스트 및 최적화 | 8시간 | 39시간 (5일) |
|
||||
|
||||
**총 예상 시간: 39시간 (약 5일)**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 구현 우선순위
|
||||
|
||||
### 1단계: 핵심 기능 (필수)
|
||||
|
||||
1. ✅ 타입 정의
|
||||
2. ✅ 스마트 기본값 생성기
|
||||
3. ✅ 브레이크포인트 훅
|
||||
4. ✅ 반응형 레이아웃 엔진
|
||||
5. ✅ 화면 표시 페이지 수정
|
||||
|
||||
### 2단계: 디자이너 UI (중요)
|
||||
|
||||
6. ✅ 반응형 설정 패널
|
||||
7. ✅ 속성 패널 통합
|
||||
8. ✅ 미리보기 모드
|
||||
|
||||
### 3단계: 데이터 처리 (중요)
|
||||
|
||||
9. ✅ 타입 업데이트
|
||||
10. ✅ 저장/불러오기 로직
|
||||
|
||||
### 4단계: 완성도 (선택)
|
||||
|
||||
11. 테스트
|
||||
12. 최적화
|
||||
13. UI/UX 개선
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 체크리스트
|
||||
|
||||
### Phase 1: 기본 시스템
|
||||
|
||||
- [ ] `frontend/types/responsive.ts` 생성
|
||||
- [ ] `frontend/lib/utils/responsiveDefaults.ts` 생성
|
||||
- [ ] `frontend/hooks/useBreakpoint.ts` 생성
|
||||
- [ ] `frontend/components/screen/ResponsiveLayoutEngine.tsx` 생성
|
||||
- [ ] `frontend/app/(main)/screens/[screenId]/page.tsx` 수정
|
||||
|
||||
### Phase 2: 디자이너 통합
|
||||
|
||||
- [ ] `frontend/components/screen/panels/ResponsiveConfigPanel.tsx` 생성
|
||||
- [ ] `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정
|
||||
- [ ] `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||
|
||||
### Phase 3: 데이터 처리
|
||||
|
||||
- [ ] `frontend/types/screen-management.ts` 수정
|
||||
- [ ] 저장 로직 수정
|
||||
- [ ] 불러오기 로직 수정
|
||||
|
||||
### Phase 4: 테스트
|
||||
|
||||
- [ ] 기능 테스트 완료
|
||||
- [ ] 성능 최적화 완료
|
||||
- [ ] UI/UX 개선 완료
|
||||
|
||||
---
|
||||
|
||||
## 🚀 시작 준비 완료
|
||||
|
||||
이제 Phase 1부터 순차적으로 구현을 시작합니다.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,311 +0,0 @@
|
|||
# 🎨 제어관리 - 데이터 연결 설정 UI 재설계 계획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 목표
|
||||
|
||||
- 기존 모달 기반 필드 매핑을 메인 화면으로 통합
|
||||
- 중복된 테이블 선택 과정 제거
|
||||
- 시각적 필드 연결 매핑 구현
|
||||
- 좌우 분할 레이아웃으로 정보 가시성 향상
|
||||
|
||||
### 현재 문제점
|
||||
|
||||
- ❌ **이중 작업**: 테이블을 3번 선택해야 함 (더블클릭 → 모달 → 재선택)
|
||||
- ❌ **혼란스러운 UX**: 사전 선택의 의미가 없어짐
|
||||
- ❌ **불필요한 모달**: 연결 설정이 메인 기능인데 숨겨져 있음
|
||||
- ❌ **시각적 피드백 부족**: 필드 매핑 관계가 명확하지 않음
|
||||
|
||||
## 🎯 새로운 UI 구조
|
||||
|
||||
### 레이아웃 구성
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 제어관리 - 데이터 연결 설정 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 좌측 패널 (30%) │ 우측 패널 (70%) │
|
||||
│ - 연결 타입 선택 │ - 단계별 설정 UI │
|
||||
│ - 매핑 정보 모니터링 │ - 시각적 필드 매핑 │
|
||||
│ - 상세 설정 목록 │ - 실시간 연결선 표시 │
|
||||
│ - 액션 버튼 │ - 드래그 앤 드롭 지원 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔧 구현 단계
|
||||
|
||||
### Phase 1: 기본 구조 구축
|
||||
|
||||
- [ ] 좌우 분할 레이아웃 컴포넌트 생성
|
||||
- [ ] 기존 모달 컴포넌트들을 메인 화면용으로 리팩토링
|
||||
- [ ] 연결 타입 선택 컴포넌트 구현
|
||||
|
||||
### Phase 2: 좌측 패널 구현
|
||||
|
||||
- [ ] 연결 타입 선택 (데이터 저장 / 외부 호출)
|
||||
- [ ] 실시간 매핑 정보 표시
|
||||
- [ ] 매핑 상세 목록 컴포넌트
|
||||
- [ ] 고급 설정 패널
|
||||
|
||||
### Phase 3: 우측 패널 구현
|
||||
|
||||
- [ ] 단계별 진행 UI (연결 → 테이블 → 매핑)
|
||||
- [ ] 시각적 필드 매핑 영역
|
||||
- [ ] SVG 기반 연결선 시스템
|
||||
- [ ] 드래그 앤 드롭 매핑 기능
|
||||
|
||||
### Phase 4: 고급 기능
|
||||
|
||||
- [ ] 실시간 검증 및 피드백
|
||||
- [ ] 매핑 미리보기 기능
|
||||
- [ ] 설정 저장/불러오기
|
||||
- [ ] 테스트 실행 기능
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
### 새로 생성할 컴포넌트
|
||||
|
||||
```
|
||||
frontend/components/dataflow/connection/redesigned/
|
||||
├── DataConnectionDesigner.tsx # 메인 컨테이너
|
||||
├── LeftPanel/
|
||||
│ ├── ConnectionTypeSelector.tsx # 연결 타입 선택
|
||||
│ ├── MappingInfoPanel.tsx # 매핑 정보 표시
|
||||
│ ├── MappingDetailList.tsx # 매핑 상세 목록
|
||||
│ ├── AdvancedSettings.tsx # 고급 설정
|
||||
│ └── ActionButtons.tsx # 액션 버튼들
|
||||
├── RightPanel/
|
||||
│ ├── StepProgress.tsx # 단계 진행 표시
|
||||
│ ├── ConnectionStep.tsx # 1단계: 연결 선택
|
||||
│ ├── TableStep.tsx # 2단계: 테이블 선택
|
||||
│ ├── FieldMappingStep.tsx # 3단계: 필드 매핑
|
||||
│ └── VisualMapping/
|
||||
│ ├── FieldMappingCanvas.tsx # 시각적 매핑 캔버스
|
||||
│ ├── FieldColumn.tsx # 필드 컬럼 컴포넌트
|
||||
│ ├── ConnectionLine.tsx # SVG 연결선
|
||||
│ └── MappingControls.tsx # 매핑 제어 도구
|
||||
└── types/
|
||||
└── redesigned.ts # 타입 정의
|
||||
```
|
||||
|
||||
### 수정할 기존 파일
|
||||
|
||||
```
|
||||
frontend/components/dataflow/connection/
|
||||
├── DataSaveSettings.tsx # 새 UI로 교체
|
||||
├── ConnectionSelectionPanel.tsx # 재사용을 위한 리팩토링
|
||||
├── TableSelectionPanel.tsx # 재사용을 위한 리팩토링
|
||||
└── ActionFieldMappings.tsx # 레거시 처리
|
||||
```
|
||||
|
||||
## 🎨 UI 컴포넌트 상세
|
||||
|
||||
### 1. 연결 타입 선택 (ConnectionTypeSelector)
|
||||
|
||||
```typescript
|
||||
interface ConnectionType {
|
||||
id: "data_save" | "external_call";
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const connectionTypes: ConnectionType[] = [
|
||||
{
|
||||
id: "data_save",
|
||||
label: "데이터 저장",
|
||||
description: "INSERT/UPDATE/DELETE 작업",
|
||||
icon: <Database />,
|
||||
},
|
||||
{
|
||||
id: "external_call",
|
||||
label: "외부 호출",
|
||||
description: "API/Webhook 호출",
|
||||
icon: <Globe />,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 2. 시각적 필드 매핑 (FieldMappingCanvas)
|
||||
|
||||
```typescript
|
||||
interface FieldMapping {
|
||||
id: string;
|
||||
fromField: ColumnInfo;
|
||||
toField: ColumnInfo;
|
||||
transformRule?: string;
|
||||
isValid: boolean;
|
||||
validationMessage?: string;
|
||||
}
|
||||
|
||||
interface MappingLine {
|
||||
id: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
isValid: boolean;
|
||||
isHovered: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 매핑 정보 패널 (MappingInfoPanel)
|
||||
|
||||
```typescript
|
||||
interface MappingStats {
|
||||
totalMappings: number;
|
||||
validMappings: number;
|
||||
invalidMappings: number;
|
||||
missingRequiredFields: number;
|
||||
estimatedRows: number;
|
||||
actionType: "INSERT" | "UPDATE" | "DELETE";
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 데이터 플로우
|
||||
|
||||
### 상태 관리
|
||||
|
||||
```typescript
|
||||
interface DataConnectionState {
|
||||
// 기본 설정
|
||||
connectionType: "data_save" | "external_call";
|
||||
currentStep: 1 | 2 | 3;
|
||||
|
||||
// 연결 정보
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
|
||||
// 매핑 정보
|
||||
fieldMappings: FieldMapping[];
|
||||
mappingStats: MappingStats;
|
||||
|
||||
// UI 상태
|
||||
selectedMapping?: string;
|
||||
isLoading: boolean;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
```
|
||||
|
||||
### 이벤트 핸들링
|
||||
|
||||
```typescript
|
||||
interface DataConnectionActions {
|
||||
// 연결 타입
|
||||
setConnectionType: (type: "data_save" | "external_call") => void;
|
||||
|
||||
// 단계 진행
|
||||
goToStep: (step: 1 | 2 | 3) => void;
|
||||
|
||||
// 연결/테이블 선택
|
||||
selectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||
selectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||
|
||||
// 필드 매핑
|
||||
createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
updateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||
deleteMapping: (mappingId: string) => void;
|
||||
|
||||
// 검증 및 저장
|
||||
validateMappings: () => Promise<ValidationResult>;
|
||||
saveMappings: () => Promise<void>;
|
||||
testExecution: () => Promise<TestResult>;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 사용자 경험 (UX) 개선점
|
||||
|
||||
### Before (기존)
|
||||
|
||||
1. 테이블 더블클릭 → 화면에 표시
|
||||
2. 모달 열기 → 다시 테이블 선택
|
||||
3. 외부 커넥션 설정 → 또 다시 테이블 선택
|
||||
4. 필드 매핑 → 텍스트 기반 매핑
|
||||
|
||||
### After (개선)
|
||||
|
||||
1. **연결 타입 선택** → 목적 명확화
|
||||
2. **연결 선택** → 한 번에 FROM/TO 설정
|
||||
3. **테이블 선택** → 즉시 필드 정보 로드
|
||||
4. **시각적 매핑** → 드래그 앤 드롭으로 직관적 연결
|
||||
|
||||
## 🚀 구현 우선순위
|
||||
|
||||
### 🔥 High Priority
|
||||
|
||||
1. **기본 레이아웃** - 좌우 분할 구조
|
||||
2. **연결 타입 선택** - 데이터 저장/외부 호출
|
||||
3. **단계별 진행** - 연결 → 테이블 → 매핑
|
||||
4. **기본 필드 매핑** - 드래그 앤 드롭 없이 클릭 기반
|
||||
|
||||
### 🔶 Medium Priority
|
||||
|
||||
1. **시각적 연결선** - SVG 기반 라인 표시
|
||||
2. **실시간 검증** - 타입 호환성 체크
|
||||
3. **매핑 정보 패널** - 통계 및 상태 표시
|
||||
4. **드래그 앤 드롭** - 고급 매핑 기능
|
||||
|
||||
### 🔵 Low Priority
|
||||
|
||||
1. **고급 설정** - 트랜잭션, 배치 설정
|
||||
2. **미리보기 기능** - 데이터 변환 미리보기
|
||||
3. **설정 템플릿** - 자주 사용하는 매핑 저장
|
||||
4. **성능 최적화** - 대용량 테이블 처리
|
||||
|
||||
## 📅 개발 일정
|
||||
|
||||
### Week 1: 기본 구조
|
||||
|
||||
- [ ] 레이아웃 컴포넌트 생성
|
||||
- [ ] 연결 타입 선택 구현
|
||||
- [ ] 기존 컴포넌트 리팩토링
|
||||
|
||||
### Week 2: 핵심 기능
|
||||
|
||||
- [ ] 단계별 진행 UI
|
||||
- [ ] 연결/테이블 선택 통합
|
||||
- [ ] 기본 필드 매핑 구현
|
||||
|
||||
### Week 3: 시각적 개선
|
||||
|
||||
- [ ] SVG 연결선 시스템
|
||||
- [ ] 드래그 앤 드롭 매핑
|
||||
- [ ] 실시간 검증 기능
|
||||
|
||||
### Week 4: 완성 및 테스트
|
||||
|
||||
- [ ] 고급 기능 구현
|
||||
- [ ] 통합 테스트
|
||||
- [ ] 사용자 테스트 및 피드백 반영
|
||||
|
||||
## 🔍 기술적 고려사항
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- **가상화**: 대용량 필드 목록 처리
|
||||
- **메모이제이션**: 불필요한 리렌더링 방지
|
||||
- **지연 로딩**: 필요한 시점에만 데이터 로드
|
||||
|
||||
### 접근성
|
||||
|
||||
- **키보드 네비게이션**: 모든 기능을 키보드로 접근 가능
|
||||
- **스크린 리더**: 시각적 매핑의 대체 텍스트 제공
|
||||
- **색상 대비**: 연결선과 상태 표시의 명확한 구분
|
||||
|
||||
### 확장성
|
||||
|
||||
- **플러그인 구조**: 새로운 연결 타입 쉽게 추가
|
||||
- **커스텀 변환**: 사용자 정의 데이터 변환 규칙
|
||||
- **API 확장**: 외부 시스템과의 연동 지원
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계
|
||||
|
||||
이 계획서를 바탕으로 **Phase 1부터 순차적으로 구현**을 시작하겠습니다.
|
||||
|
||||
**첫 번째 작업**: 좌우 분할 레이아웃과 연결 타입 선택 컴포넌트 구현
|
||||
|
||||
구현을 시작하시겠어요? 🚀
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
# 작업 이력 관리 시스템 설치 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
작업 이력 관리 시스템이 추가되었습니다. 입고/출고/이송/정비 작업을 관리하고 통계를 확인할 수 있습니다.
|
||||
|
||||
## 🚀 설치 방법
|
||||
|
||||
### 1. 데이터베이스 마이그레이션 실행
|
||||
|
||||
PostgreSQL 데이터베이스에 작업 이력 테이블을 생성해야 합니다.
|
||||
|
||||
```bash
|
||||
# 방법 1: psql 명령어 사용 (로컬 PostgreSQL)
|
||||
psql -U postgres -d plm -f db/migrations/20241020_create_work_history.sql
|
||||
|
||||
# 방법 2: Docker 컨테이너 사용
|
||||
docker exec -i <DB_CONTAINER_NAME> psql -U postgres -d plm < db/migrations/20241020_create_work_history.sql
|
||||
|
||||
# 방법 3: pgAdmin 또는 DBeaver 사용
|
||||
# db/migrations/20241020_create_work_history.sql 파일을 열어서 실행
|
||||
```
|
||||
|
||||
### 2. 백엔드 재시작
|
||||
|
||||
```bash
|
||||
cd backend-node
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. 프론트엔드 확인
|
||||
|
||||
대시보드 편집 화면에서 다음 위젯들을 추가할 수 있습니다:
|
||||
|
||||
- **작업 이력**: 작업 목록을 테이블 형식으로 표시
|
||||
- **운송 통계**: 오늘 작업, 총 운송량, 정시 도착률 등 통계 표시
|
||||
|
||||
## 📊 주요 기능
|
||||
|
||||
### 작업 이력 위젯
|
||||
|
||||
- 작업 번호, 일시, 유형, 차량, 경로, 화물, 중량, 상태 표시
|
||||
- 유형별 필터링 (입고/출고/이송/정비)
|
||||
- 상태별 필터링 (대기/진행중/완료/취소)
|
||||
- 실시간 자동 새로고침
|
||||
|
||||
### 운송 통계 위젯
|
||||
|
||||
- 오늘 작업 건수 및 완료율
|
||||
- 총 운송량 (톤)
|
||||
- 누적 거리 (km)
|
||||
- 정시 도착률 (%)
|
||||
- 작업 유형별 분포 차트
|
||||
|
||||
## 🔧 API 엔드포인트
|
||||
|
||||
### 작업 이력 관리
|
||||
|
||||
- `GET /api/work-history` - 작업 이력 목록 조회
|
||||
- `GET /api/work-history/:id` - 작업 이력 단건 조회
|
||||
- `POST /api/work-history` - 작업 이력 생성
|
||||
- `PUT /api/work-history/:id` - 작업 이력 수정
|
||||
- `DELETE /api/work-history/:id` - 작업 이력 삭제
|
||||
|
||||
### 통계 및 분석
|
||||
|
||||
- `GET /api/work-history/stats` - 작업 이력 통계
|
||||
- `GET /api/work-history/trend?months=6` - 월별 추이
|
||||
- `GET /api/work-history/routes?limit=5` - 주요 운송 경로
|
||||
|
||||
## 📝 샘플 데이터
|
||||
|
||||
마이그레이션 실행 시 자동으로 4건의 샘플 데이터가 생성됩니다:
|
||||
|
||||
1. 입고 작업 (완료)
|
||||
2. 출고 작업 (진행중)
|
||||
3. 이송 작업 (대기)
|
||||
4. 정비 작업 (완료)
|
||||
|
||||
## 🎯 사용 방법
|
||||
|
||||
### 1. 대시보드에 위젯 추가
|
||||
|
||||
1. 대시보드 편집 모드로 이동
|
||||
2. 상단 메뉴에서 "위젯 추가" 선택
|
||||
3. "작업 이력" 또는 "운송 통계" 선택
|
||||
4. 원하는 위치에 배치
|
||||
5. 저장
|
||||
|
||||
### 2. 작업 이력 필터링
|
||||
|
||||
- 유형 선택: 전체/입고/출고/이송/정비
|
||||
- 상태 선택: 전체/대기/진행중/완료/취소
|
||||
- 새로고침 버튼으로 수동 갱신
|
||||
|
||||
### 3. 통계 확인
|
||||
|
||||
운송 통계 위젯에서 다음 정보를 확인할 수 있습니다:
|
||||
|
||||
- 오늘 작업 건수
|
||||
- 완료율
|
||||
- 총 운송량
|
||||
- 정시 도착률
|
||||
- 작업 유형별 분포
|
||||
|
||||
## 🔍 문제 해결
|
||||
|
||||
### 데이터가 표시되지 않는 경우
|
||||
|
||||
1. 데이터베이스 마이그레이션이 실행되었는지 확인
|
||||
2. 백엔드 서버가 실행 중인지 확인
|
||||
3. 브라우저 콘솔에서 API 에러 확인
|
||||
|
||||
### API 에러가 발생하는 경우
|
||||
|
||||
```bash
|
||||
# 백엔드 로그 확인
|
||||
cd backend-node
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 위젯이 표시되지 않는 경우
|
||||
|
||||
1. 프론트엔드 재시작
|
||||
2. 브라우저 캐시 삭제
|
||||
3. 페이지 새로고침
|
||||
|
||||
## 📚 관련 파일
|
||||
|
||||
### 백엔드
|
||||
|
||||
- `backend-node/src/types/workHistory.ts` - 타입 정의
|
||||
- `backend-node/src/services/workHistoryService.ts` - 비즈니스 로직
|
||||
- `backend-node/src/controllers/workHistoryController.ts` - API 컨트롤러
|
||||
- `backend-node/src/routes/workHistoryRoutes.ts` - 라우트 정의
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
- `frontend/types/workHistory.ts` - 타입 정의
|
||||
- `frontend/components/dashboard/widgets/WorkHistoryWidget.tsx` - 작업 이력 위젯
|
||||
- `frontend/components/dashboard/widgets/TransportStatsWidget.tsx` - 운송 통계 위젯
|
||||
|
||||
### 데이터베이스
|
||||
|
||||
- `db/migrations/20241020_create_work_history.sql` - 테이블 생성 스크립트
|
||||
|
||||
## 🎉 완료!
|
||||
|
||||
작업 이력 관리 시스템이 성공적으로 설치되었습니다!
|
||||
|
||||
|
|
@ -1,426 +0,0 @@
|
|||
# 야드 관리 3D - 데이터 바인딩 시스템 재설계
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 현재 방식의 문제점
|
||||
|
||||
- 고정된 임시 자재 마스터(`temp_material_master`) 테이블에 의존
|
||||
- 실제 외부 시스템의 자재 데이터와 연동 불가
|
||||
- 자재 목록이 제한적이고 유연성 부족
|
||||
- 사용자가 직접 데이터를 선택하거나 입력할 수 없음
|
||||
|
||||
### 새로운 방식의 목표
|
||||
|
||||
- 차트/리스트 위젯과 동일한 데이터 소스 선택 방식 적용
|
||||
- DB 커넥션 또는 REST API를 통해 실제 자재 데이터 연동
|
||||
- 사용자가 자재명, 수량 등을 직접 매핑 및 입력 가능
|
||||
- 설정되지 않은 요소는 뷰어에서 명확히 표시
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 변경사항
|
||||
|
||||
### 2.1 요소(Element) 개념 도입
|
||||
|
||||
- 기존: 자재 목록에서 클릭 → 즉시 배치
|
||||
- 변경: [+ 요소 추가] 버튼 클릭 → 3D 캔버스에 즉시 빈 요소 배치 → 우측 패널이 데이터 바인딩 설정 화면으로 전환
|
||||
|
||||
### 2.2 데이터 소스 선택
|
||||
|
||||
- 현재 DB (내부 PostgreSQL)
|
||||
- 외부 DB 커넥션
|
||||
- REST API
|
||||
|
||||
### 2.3 데이터 매핑
|
||||
|
||||
- 자재명 필드 선택 (데이터 소스에서)
|
||||
- 수량 필드 선택 (데이터 소스에서)
|
||||
- 단위 직접 입력 (예: EA, BOX, KG 등)
|
||||
- 색상 선택
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터베이스 스키마 변경
|
||||
|
||||
### 3.1 기존 테이블 수정: `yard_material_placement`
|
||||
|
||||
```sql
|
||||
-- 기존 컬럼 변경
|
||||
ALTER TABLE yard_material_placement
|
||||
-- 기존 컬럼 제거 (외부 자재 ID 관련)
|
||||
DROP COLUMN IF EXISTS external_material_id,
|
||||
|
||||
-- 데이터 소스 정보 추가
|
||||
ADD COLUMN data_source_type VARCHAR(20), -- 'database', 'external_db', 'rest_api'
|
||||
ADD COLUMN data_source_config JSONB, -- 데이터 소스 설정
|
||||
|
||||
-- 데이터 바인딩 정보 추가
|
||||
ADD COLUMN data_binding JSONB, -- 필드 매핑 정보
|
||||
|
||||
-- 자재 정보를 NULL 허용으로 변경 (설정 전에는 NULL)
|
||||
ALTER COLUMN material_code DROP NOT NULL,
|
||||
ALTER COLUMN material_name DROP NOT NULL,
|
||||
ALTER COLUMN quantity DROP NOT NULL;
|
||||
```
|
||||
|
||||
### 3.2 data_source_config 구조
|
||||
|
||||
```typescript
|
||||
interface DataSourceConfig {
|
||||
type: "database" | "external_db" | "rest_api";
|
||||
|
||||
// type === 'database' (현재 DB)
|
||||
query?: string;
|
||||
|
||||
// type === 'external_db' (외부 DB)
|
||||
connectionId?: number;
|
||||
query?: string;
|
||||
|
||||
// type === 'rest_api'
|
||||
url?: string;
|
||||
method?: "GET" | "POST";
|
||||
headers?: Record<string, string>;
|
||||
queryParams?: Record<string, string>;
|
||||
body?: string;
|
||||
dataPath?: string; // 응답에서 데이터 배열 경로 (예: "data.items")
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 data_binding 구조
|
||||
|
||||
```typescript
|
||||
interface DataBinding {
|
||||
// 데이터 소스의 특정 행 선택
|
||||
selectedRowIndex?: number;
|
||||
|
||||
// 필드 매핑 (데이터 소스에서 선택)
|
||||
materialNameField?: string; // 자재명이 들어있는 컬럼명
|
||||
quantityField?: string; // 수량이 들어있는 컬럼명
|
||||
|
||||
// 단위는 사용자가 직접 입력
|
||||
unit: string; // 예: "EA", "BOX", "KG", "M" 등
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. UI/UX 설계
|
||||
|
||||
### 4.1 편집 모드 (YardEditor)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [← 목록으로] 야드명: A구역 [저장] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────────────────┐ ┌──────────────────────────┐│
|
||||
│ │ │ │ ││
|
||||
│ │ │ │ [+ 요소 추가] ││
|
||||
│ │ │ │ ││
|
||||
│ │ 3D 캔버스 │ │ ┌────────────────────┐ ││
|
||||
│ │ │ │ │ □ 요소 1 │ ││
|
||||
│ │ │ │ │ 자재: 철판 A │ ││
|
||||
│ │ │ │ │ 수량: 50 EA │ ││
|
||||
│ │ │ │ │ [편집] [삭제] │ ││
|
||||
│ │ │ │ └────────────────────┘ ││
|
||||
│ │ │ │ ││
|
||||
│ │ │ │ ┌────────────────────┐ ││
|
||||
│ │ │ │ │ □ 요소 2 (미설정) │ ││
|
||||
│ │ │ │ │ 데이터 바인딩 │ ││
|
||||
│ │ │ │ │ 설정 필요 │ ││
|
||||
│ │ │ │ │ [설정] [삭제] │ ││
|
||||
│ │ │ │ └────────────────────┘ ││
|
||||
│ │ │ │ ││
|
||||
│ └───────────────────────────┘ └──────────────────────────┘│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 4.1.1 요소 목록 (우측 패널)
|
||||
|
||||
- **[+ 요소 추가]** 버튼: 새 요소 생성
|
||||
- **요소 카드**:
|
||||
- 설정 완료: 자재명, 수량 표시 + [편집] [삭제] 버튼
|
||||
- 미설정: "데이터 바인딩 설정 필요" + [설정] [삭제] 버튼
|
||||
|
||||
#### 4.1.2 요소 추가 흐름
|
||||
|
||||
```
|
||||
1. [+ 요소 추가] 클릭
|
||||
↓
|
||||
2. 3D 캔버스의 기본 위치(0,0,0)에 회색 반투명 박스로 빈 요소 즉시 배치
|
||||
↓
|
||||
3. 요소가 자동 선택됨
|
||||
↓
|
||||
4. 우측 패널이 "데이터 바인딩 설정" 화면으로 자동 전환
|
||||
(요소 목록에서 [설정] 버튼을 클릭해도 동일한 화면)
|
||||
```
|
||||
|
||||
### 4.2 데이터 바인딩 설정 패널 (우측)
|
||||
|
||||
**[+ 요소 추가] 버튼 클릭 시 또는 [설정] 버튼 클릭 시 우측 패널이 아래와 같이 변경됩니다:**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 데이터 바인딩 설정 [← 목록]│
|
||||
├──────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─ 1단계: 데이터 소스 선택 ─────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ○ 현재 DB ○ 외부 DB ○ REST API │ │
|
||||
│ │ │ │
|
||||
│ │ [현재 DB 선택 시] │ │
|
||||
│ │ ┌────────────────────────────────────────────┐ │ │
|
||||
│ │ │ SELECT material_name, quantity, unit │ │ │
|
||||
│ │ │ FROM inventory │ │ │
|
||||
│ │ │ WHERE status = 'available' │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ │ [실행] 버튼 │ │
|
||||
│ │ │ │
|
||||
│ │ [외부 DB 선택 시] │ │
|
||||
│ │ - 외부 커넥션 선택 드롭다운 │ │
|
||||
│ │ - SQL 쿼리 입력 │ │
|
||||
│ │ - [실행] 버튼 │ │
|
||||
│ │ │ │
|
||||
│ │ [REST API 선택 시] │ │
|
||||
│ │ - URL 입력 │ │
|
||||
│ │ - Method 선택 (GET/POST) │ │
|
||||
│ │ - Headers, Query Params 설정 │ │
|
||||
│ │ - [실행] 버튼 │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ 2단계: 쿼리 결과 및 필드 매핑 ──────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 쿼리 결과 (5행): │ │
|
||||
│ │ ┌────────────────────────────────────────────┐ │ │
|
||||
│ │ │ material_name │ quantity │ status │ │ │
|
||||
│ │ │ 철판 A │ 50 │ available │ ○ │ │
|
||||
│ │ │ 강관 파이프 │ 100 │ available │ ○ │ │
|
||||
│ │ │ 볼트 세트 │ 500 │ in_stock │ ○ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ 필드 매핑: │ │
|
||||
│ │ 자재명: [material_name ▼] │ │
|
||||
│ │ 수량: [quantity ▼] │ │
|
||||
│ │ │ │
|
||||
│ │ 단위 입력: │ │
|
||||
│ │ 단위: [EA_____________] │ │
|
||||
│ │ (예: EA, BOX, KG, M, L 등) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ 3단계: 배치 설정 ──────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 색상: [🎨 #3b82f6] │ │
|
||||
│ │ │ │
|
||||
│ │ 크기: │ │
|
||||
│ │ 너비: [5] 높이: [5] 깊이: [5] │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [← 목록으로] [저장] │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**참고:**
|
||||
|
||||
- [← 목록으로] 버튼: 요소 목록 화면으로 돌아갑니다
|
||||
- [저장] 버튼: 데이터 바인딩 설정을 저장하고 요소 목록 화면으로 돌아갑니다
|
||||
- 저장하지 않고 나가면 요소는 "미설정" 상태로 남습니다
|
||||
|
||||
### 4.3 뷰어 모드 (Yard3DViewer)
|
||||
|
||||
#### 4.3.1 설정된 요소
|
||||
|
||||
- 정상적으로 3D 박스 렌더링
|
||||
- 클릭 시 자재명, 수량 정보 표시
|
||||
|
||||
#### 4.3.2 미설정 요소
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ │
|
||||
│ ⚠️ │
|
||||
│ │
|
||||
│ 설정되지 않은 │
|
||||
│ 요소입니다 │
|
||||
│ │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
- 반투명 회색 박스로 표시
|
||||
- 클릭 시 "데이터 바인딩이 설정되지 않았습니다" 메시지
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 단계
|
||||
|
||||
### Phase 1: 데이터베이스 스키마 변경
|
||||
|
||||
- [ ] `yard_material_placement` 테이블 수정
|
||||
- [ ] 마이그레이션 스크립트 작성
|
||||
- [ ] 기존 데이터 호환성 처리
|
||||
|
||||
### Phase 2: 백엔드 API 수정
|
||||
|
||||
- [ ] `YardLayoutService.ts` 수정
|
||||
- `addMaterialPlacement`: 데이터 소스/바인딩 정보 저장
|
||||
- `updatePlacement`: 데이터 바인딩 업데이트
|
||||
- `getPlacementsByLayoutId`: 새 필드 포함하여 조회
|
||||
- [ ] 데이터 소스 실행 로직 추가
|
||||
- DB 쿼리 실행
|
||||
- 외부 DB 쿼리 실행
|
||||
- REST API 호출
|
||||
|
||||
### Phase 3: 프론트엔드 타입 정의
|
||||
|
||||
- [ ] `types.ts`에 새로운 인터페이스 추가
|
||||
- `YardElementDataSource`
|
||||
- `YardElementDataBinding`
|
||||
- `YardPlacement` 업데이트
|
||||
|
||||
### Phase 4: 요소 추가 및 관리
|
||||
|
||||
- [ ] `YardEditor.tsx` 수정
|
||||
- [+ 요소 추가] 버튼 구현
|
||||
- 빈 요소 생성 로직 (즉시 3D 캔버스에 배치)
|
||||
- 요소 추가 시 자동으로 해당 요소 선택
|
||||
- 우측 패널 상태 관리 (요소 목록 ↔ 데이터 바인딩 설정)
|
||||
- 요소 목록 UI
|
||||
- 설정/미설정 상태 구분 표시
|
||||
|
||||
### Phase 5: 데이터 바인딩 패널
|
||||
|
||||
- [ ] `YardElementConfigPanel.tsx` 생성 (우측 패널 컴포넌트)
|
||||
- [← 목록으로] 버튼으로 요소 목록으로 복귀
|
||||
- 1단계: 데이터 소스 선택 (DatabaseConfig, ExternalDbConfig, RestApiConfig 재사용)
|
||||
- 2단계: 쿼리 결과 테이블 + 행 선택 + 필드 매핑
|
||||
- 자재명 필드 선택 (드롭다운)
|
||||
- 수량 필드 선택 (드롭다운)
|
||||
- 단위 직접 입력 (Input)
|
||||
- 3단계: 배치 설정 (색상, 크기)
|
||||
- [저장] 버튼으로 설정 저장 및 목록으로 복귀
|
||||
|
||||
### Phase 6: 3D 캔버스 렌더링 수정
|
||||
|
||||
- [ ] `Yard3DCanvas.tsx` 수정
|
||||
- 설정된 요소: 기존 렌더링
|
||||
- 미설정 요소: 회색 반투명 박스 + 경고 아이콘
|
||||
|
||||
### Phase 7: 뷰어 모드 수정
|
||||
|
||||
- [ ] `Yard3DViewer.tsx` 수정
|
||||
- 미설정 요소 감지
|
||||
- 미설정 요소 클릭 시 안내 메시지
|
||||
|
||||
### Phase 8: 임시 테이블 제거
|
||||
|
||||
- [ ] `temp_material_master` 테이블 삭제
|
||||
- [ ] 관련 API 및 UI 코드 정리
|
||||
|
||||
---
|
||||
|
||||
## 6. 데이터 구조 예시
|
||||
|
||||
### 6.1 데이터 소스 + 필드 매핑 사용
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"yard_layout_id": 1,
|
||||
"material_code": null,
|
||||
"material_name": "철판 A타입",
|
||||
"quantity": 50,
|
||||
"unit": "EA",
|
||||
"data_source_type": "database",
|
||||
"data_source_config": {
|
||||
"type": "database",
|
||||
"query": "SELECT material_name, quantity FROM inventory WHERE material_id = 'MAT-001'"
|
||||
},
|
||||
"data_binding": {
|
||||
"selectedRowIndex": 0,
|
||||
"materialNameField": "material_name",
|
||||
"quantityField": "quantity",
|
||||
"unit": "EA"
|
||||
},
|
||||
"position_x": 10,
|
||||
"position_y": 0,
|
||||
"position_z": 10,
|
||||
"size_x": 5,
|
||||
"size_y": 5,
|
||||
"size_z": 5,
|
||||
"color": "#ef4444"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 미설정 요소
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"yard_layout_id": 1,
|
||||
"material_code": null,
|
||||
"material_name": null,
|
||||
"quantity": null,
|
||||
"unit": null,
|
||||
"data_source_type": null,
|
||||
"data_source_config": null,
|
||||
"data_binding": null,
|
||||
"position_x": 30,
|
||||
"position_y": 0,
|
||||
"position_z": 30,
|
||||
"size_x": 5,
|
||||
"size_y": 5,
|
||||
"size_z": 5,
|
||||
"color": "#9ca3af"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 장점
|
||||
|
||||
1. **유연성**: 다양한 데이터 소스 지원 (내부 DB, 외부 DB, REST API)
|
||||
2. **실시간성**: 실제 시스템의 자재 데이터와 연동 가능
|
||||
3. **일관성**: 차트/리스트 위젯과 동일한 데이터 소스 선택 방식
|
||||
4. **사용자 경험**: 데이터 매핑 방식 선택 가능 (자동/수동)
|
||||
5. **확장성**: 새로운 데이터 소스 타입 추가 용이
|
||||
6. **명확성**: 미설정 요소를 시각적으로 구분
|
||||
|
||||
---
|
||||
|
||||
## 8. 마이그레이션 전략
|
||||
|
||||
### 8.1 기존 데이터 처리
|
||||
|
||||
- 기존 `temp_material_master` 기반 배치 데이터를 수동 입력 모드로 전환
|
||||
- `external_material_id` → `data_binding.mode = 'manual'`로 변환
|
||||
|
||||
### 8.2 단계적 전환
|
||||
|
||||
1. 새 스키마 적용 (기존 컬럼 유지)
|
||||
2. 새 UI/로직 구현 및 테스트
|
||||
3. 기존 데이터 마이그레이션
|
||||
4. 임시 테이블 및 구 코드 제거
|
||||
|
||||
---
|
||||
|
||||
## 9. 기술 스택
|
||||
|
||||
- **백엔드**: PostgreSQL JSONB, Node.js/TypeScript
|
||||
- **프론트엔드**: React, TypeScript, Shadcn UI
|
||||
- **3D 렌더링**: React Three Fiber, Three.js
|
||||
- **데이터 소스**: 기존 `DatabaseConfig`, `ExternalDbConfig`, `RestApiConfig` 컴포넌트 재사용
|
||||
|
||||
---
|
||||
|
||||
## 10. 예상 개발 기간
|
||||
|
||||
- Phase 1-2 (DB/백엔드): 1일
|
||||
- Phase 3-4 (프론트엔드 구조): 1일
|
||||
- Phase 5 (데이터 바인딩 모달): 2일
|
||||
- Phase 6-7 (3D 렌더링/뷰어): 1일
|
||||
- Phase 8 (정리 및 테스트): 0.5일
|
||||
|
||||
**총 예상 기간: 약 5.5일**
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -39,6 +39,44 @@ router.get("/available/:menuObjid?", authenticateToken, async (req: Authenticate
|
|||
}
|
||||
});
|
||||
|
||||
// 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화)
|
||||
router.get("/available-for-screen", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName } = req.query;
|
||||
|
||||
try {
|
||||
// tableName 필수 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "tableName is required",
|
||||
});
|
||||
}
|
||||
|
||||
const rules = await numberingRuleService.getAvailableRulesForScreen(
|
||||
companyCode,
|
||||
tableName
|
||||
);
|
||||
|
||||
logger.info("화면용 채번 규칙 조회 성공", {
|
||||
companyCode,
|
||||
tableName,
|
||||
count: rules.length,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("화면용 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
tableName,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 특정 규칙 조회
|
||||
router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,33 @@ export class EntityJoinService {
|
|||
|
||||
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) {
|
||||
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
||||
column_name: column.column_name,
|
||||
|
|
|
|||
|
|
@ -401,6 +401,117 @@ class NumberingRuleService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화)
|
||||
* @param companyCode 회사 코드
|
||||
* @param tableName 화면의 테이블명
|
||||
* @returns 해당 테이블의 채번 규칙 목록
|
||||
*/
|
||||
async getAvailableRulesForScreen(
|
||||
companyCode: string,
|
||||
tableName: string
|
||||
): Promise<NumberingRuleConfig[]> {
|
||||
try {
|
||||
logger.info("화면용 채번 규칙 조회", {
|
||||
companyCode,
|
||||
tableName,
|
||||
});
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 멀티테넌시: 최고 관리자 vs 일반 회사
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사의 규칙 조회 가능 (최고 관리자 전용 규칙 제외)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
rule_name AS "ruleName",
|
||||
description,
|
||||
separator,
|
||||
reset_period AS "resetPeriod",
|
||||
current_sequence AS "currentSequence",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
WHERE company_code != '*'
|
||||
AND table_name = $1
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [tableName];
|
||||
logger.info("최고 관리자: 일반 회사 채번 규칙 조회");
|
||||
} else {
|
||||
// 일반 회사: 자신의 규칙만 조회
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
rule_name AS "ruleName",
|
||||
description,
|
||||
separator,
|
||||
reset_period AS "resetPeriod",
|
||||
current_sequence AS "currentSequence",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
WHERE company_code = $1
|
||||
AND table_name = $2
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [companyCode, tableName];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// 각 규칙의 파트 정보 로드
|
||||
for (const rule of result.rows) {
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts
|
||||
WHERE rule_id = $1
|
||||
AND company_code = $2
|
||||
ORDER BY part_order
|
||||
`;
|
||||
|
||||
const partsResult = await pool.query(partsQuery, [
|
||||
rule.ruleId,
|
||||
companyCode === "*" ? rule.companyCode : companyCode,
|
||||
]);
|
||||
|
||||
rule.parts = partsResult.rows;
|
||||
}
|
||||
|
||||
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, {
|
||||
companyCode,
|
||||
tableName,
|
||||
});
|
||||
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error("화면용 채번 규칙 조회 실패", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 규칙 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
# 마이그레이션 046 오류 수정
|
||||
|
||||
## 🚨 발생한 오류
|
||||
|
||||
```
|
||||
SQL Error [23514]: ERROR: check constraint "check_menu_scope_requires_menu_objid"
|
||||
of relation "numbering_rules" is violated by some row
|
||||
```
|
||||
|
||||
## 🔍 원인 분석
|
||||
|
||||
기존 데이터베이스에 `scope_type='menu'`인데 `menu_objid`가 NULL인 레코드가 존재했습니다.
|
||||
|
||||
제약조건을 추가하기 전에 이러한 **불완전한 데이터를 먼저 정리**해야 했습니다.
|
||||
|
||||
## ✅ 수정 내용
|
||||
|
||||
마이그레이션 파일 `046_update_numbering_rules_scope_type.sql`에 **데이터 정리 단계** 추가:
|
||||
|
||||
### 1. 추가된 데이터 정리 로직 (제약조건 추가 전)
|
||||
|
||||
```sql
|
||||
-- 3. 기존 데이터 정리 (제약조건 추가 전 필수!)
|
||||
|
||||
-- 3.1. menu 타입인데 menu_objid가 NULL인 경우 → global로 변경
|
||||
UPDATE numbering_rules
|
||||
SET scope_type = 'global',
|
||||
table_name = NULL
|
||||
WHERE scope_type = 'menu' AND menu_objid IS NULL;
|
||||
|
||||
-- 3.2. global 타입인데 table_name이 있는 경우 → table로 변경
|
||||
UPDATE numbering_rules
|
||||
SET scope_type = 'table'
|
||||
WHERE scope_type = 'global' AND table_name IS NOT NULL;
|
||||
|
||||
-- 3.3. 정리 결과 확인 (로그)
|
||||
DO $$
|
||||
DECLARE
|
||||
menu_count INTEGER;
|
||||
global_count INTEGER;
|
||||
table_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO menu_count FROM numbering_rules WHERE scope_type = 'menu';
|
||||
SELECT COUNT(*) INTO global_count FROM numbering_rules WHERE scope_type = 'global';
|
||||
SELECT COUNT(*) INTO table_count FROM numbering_rules WHERE scope_type = 'table';
|
||||
|
||||
RAISE NOTICE '=== 데이터 정리 완료 ===';
|
||||
RAISE NOTICE 'Menu 규칙: % 개', menu_count;
|
||||
RAISE NOTICE 'Global 규칙: % 개', global_count;
|
||||
RAISE NOTICE 'Table 규칙: % 개', table_count;
|
||||
RAISE NOTICE '=========================';
|
||||
END $$;
|
||||
```
|
||||
|
||||
### 2. 실행 순서 변경
|
||||
|
||||
**변경 전:**
|
||||
1. scope_type 제약조건 추가
|
||||
2. ❌ 유효성 제약조건 추가 (여기서 오류 발생!)
|
||||
3. 데이터 마이그레이션
|
||||
|
||||
**변경 후:**
|
||||
1. scope_type 제약조건 추가
|
||||
2. ✅ **기존 데이터 정리** (추가)
|
||||
3. 유효성 제약조건 추가
|
||||
4. 인덱스 생성
|
||||
5. 통계 업데이트
|
||||
|
||||
## 🔄 재실행 방법
|
||||
|
||||
### 옵션 1: 전체 롤백 후 재실행 (권장)
|
||||
|
||||
```sql
|
||||
-- 1. 기존 마이그레이션 롤백
|
||||
BEGIN;
|
||||
|
||||
-- 제약조건 제거
|
||||
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_scope_type;
|
||||
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_table_scope_requires_table_name;
|
||||
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_global_scope_no_table_name;
|
||||
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_menu_scope_requires_menu_objid;
|
||||
|
||||
-- 인덱스 제거
|
||||
DROP INDEX IF EXISTS idx_numbering_rules_scope_table;
|
||||
DROP INDEX IF EXISTS idx_numbering_rules_scope_menu;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 2. 수정된 마이그레이션 재실행
|
||||
\i /Users/kimjuseok/ERP-node/db/migrations/046_update_numbering_rules_scope_type.sql
|
||||
```
|
||||
|
||||
### 옵션 2: 데이터 정리만 수동 실행 후 재시도
|
||||
|
||||
```sql
|
||||
-- 1. 데이터 정리
|
||||
UPDATE numbering_rules
|
||||
SET scope_type = 'global',
|
||||
table_name = NULL
|
||||
WHERE scope_type = 'menu' AND menu_objid IS NULL;
|
||||
|
||||
UPDATE numbering_rules
|
||||
SET scope_type = 'table'
|
||||
WHERE scope_type = 'global' AND table_name IS NOT NULL;
|
||||
|
||||
-- 2. 제약조건 추가
|
||||
ALTER TABLE numbering_rules
|
||||
ADD CONSTRAINT check_menu_scope_requires_menu_objid
|
||||
CHECK (
|
||||
(scope_type = 'menu' AND menu_objid IS NOT NULL)
|
||||
OR scope_type != 'menu'
|
||||
);
|
||||
|
||||
-- 3. 나머지 제약조건들...
|
||||
```
|
||||
|
||||
## 🧪 검증 쿼리
|
||||
|
||||
마이그레이션 실행 전에 문제 데이터 확인:
|
||||
|
||||
```sql
|
||||
-- 문제가 되는 레코드 확인
|
||||
SELECT
|
||||
rule_id,
|
||||
rule_name,
|
||||
scope_type,
|
||||
table_name,
|
||||
menu_objid,
|
||||
company_code
|
||||
FROM numbering_rules
|
||||
WHERE
|
||||
(scope_type = 'menu' AND menu_objid IS NULL)
|
||||
OR (scope_type = 'global' AND table_name IS NOT NULL)
|
||||
OR (scope_type = 'table' AND table_name IS NULL);
|
||||
```
|
||||
|
||||
마이그레이션 실행 후 검증:
|
||||
|
||||
```sql
|
||||
-- 1. scope_type별 개수
|
||||
SELECT scope_type, COUNT(*) as count
|
||||
FROM numbering_rules
|
||||
GROUP BY scope_type;
|
||||
|
||||
-- 2. 제약조건 확인
|
||||
SELECT conname, pg_get_constraintdef(oid)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'numbering_rules'::regclass
|
||||
AND conname LIKE '%scope%';
|
||||
|
||||
-- 3. 인덱스 확인
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'numbering_rules'
|
||||
AND indexname LIKE '%scope%';
|
||||
```
|
||||
|
||||
## 📝 수정 내역
|
||||
|
||||
- ✅ 제약조건 추가 전 데이터 정리 로직 추가
|
||||
- ✅ 중복된 데이터 마이그레이션 코드 제거
|
||||
- ✅ 섹션 번호 재정렬
|
||||
- ✅ 데이터 정리 결과 로그 추가
|
||||
|
||||
## 🎯 다음 단계
|
||||
|
||||
1. **현재 상태 확인**
|
||||
```bash
|
||||
psql -h localhost -U postgres -d ilshin -f /Users/kimjuseok/ERP-node/db/check_numbering_rules.sql
|
||||
```
|
||||
|
||||
2. **롤백 (필요시)**
|
||||
- 기존 제약조건 제거
|
||||
|
||||
3. **수정된 마이그레이션 재실행**
|
||||
```bash
|
||||
PGPASSWORD=<비밀번호> psql -h localhost -U postgres -d ilshin -f /Users/kimjuseok/ERP-node/db/migrations/046_update_numbering_rules_scope_type.sql
|
||||
```
|
||||
|
||||
4. **검증**
|
||||
- 제약조건 확인
|
||||
- 데이터 개수 확인
|
||||
- 인덱스 확인
|
||||
|
||||
---
|
||||
|
||||
**수정 완료!** 이제 마이그레이션을 다시 실행하면 성공할 것입니다. 🎉
|
||||
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
# 채번 규칙 마이그레이션 오류 긴급 수정
|
||||
|
||||
## 🚨 발생한 오류들
|
||||
|
||||
### 오류 1: check_table_scope_requires_table_name
|
||||
```
|
||||
SQL Error [23514]: ERROR: new row for relation "numbering_rules" violates check constraint "check_table_scope_requires_table_name"
|
||||
```
|
||||
**원인**: `scope_type='table'`인데 `table_name=NULL`
|
||||
|
||||
### 오류 2: check_global_scope_no_table_name
|
||||
```
|
||||
SQL Error [23514]: ERROR: new row for relation "numbering_rules" violates check constraint "check_global_scope_no_table_name"
|
||||
```
|
||||
**원인**: `scope_type='global'`인데 `table_name=''` (빈 문자열)
|
||||
|
||||
### 근본 원인
|
||||
마이그레이션이 부분적으로 실행되어 데이터와 제약조건이 불일치 상태입니다.
|
||||
|
||||
## ✅ 해결 방법
|
||||
|
||||
### 🎯 가장 쉬운 방법 (권장)
|
||||
|
||||
**PgAdmin 또는 DBeaver에서 `046_SIMPLE_FIX.sql` 실행**
|
||||
|
||||
이 파일은 다음을 자동으로 처리합니다:
|
||||
1. ✅ 기존 제약조건 모두 제거
|
||||
2. ✅ `table_name` NULL → 빈 문자열로 변경
|
||||
3. ✅ `scope_type`을 모두 'table'로 변경
|
||||
4. ✅ 결과 확인
|
||||
|
||||
```sql
|
||||
-- db/migrations/046_SIMPLE_FIX.sql 전체 내용을 복사하여 실행하세요
|
||||
```
|
||||
|
||||
**실행 후**:
|
||||
- `046_update_numbering_rules_scope_type.sql` 전체 실행
|
||||
- 완료!
|
||||
|
||||
---
|
||||
|
||||
### 옵션 2: 명령줄에서 실행
|
||||
|
||||
```bash
|
||||
# 1. 긴급 수정 SQL 실행
|
||||
psql -h localhost -U postgres -d ilshin -f db/fix_existing_numbering_rules.sql
|
||||
|
||||
# 2. 전체 마이그레이션 실행
|
||||
psql -h localhost -U postgres -d ilshin -f db/migrations/046_update_numbering_rules_scope_type.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 옵션 3: Docker 컨테이너 내부에서 실행
|
||||
|
||||
```bash
|
||||
# 1. Docker 컨테이너 확인
|
||||
docker ps | grep postgres
|
||||
|
||||
# 2. 컨테이너 내부 접속
|
||||
docker exec -it <CONTAINER_NAME> psql -U postgres -d ilshin
|
||||
|
||||
# 3. SQL 실행
|
||||
UPDATE numbering_rules SET table_name = '' WHERE table_name IS NULL;
|
||||
|
||||
# 4. 확인
|
||||
SELECT COUNT(*) FROM numbering_rules WHERE table_name IS NULL;
|
||||
-- 결과: 0
|
||||
|
||||
# 5. 종료
|
||||
\q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 왜 이 문제가 발생했나?
|
||||
|
||||
### 기존 마이그레이션 순서 (잘못됨)
|
||||
```sql
|
||||
-- 1. scope_type 변경 (먼저 실행됨)
|
||||
UPDATE numbering_rules SET scope_type = 'table' WHERE scope_type IN ('global', 'menu');
|
||||
|
||||
-- 2. table_name 정리 (나중에 실행됨)
|
||||
UPDATE numbering_rules SET table_name = '' WHERE table_name IS NULL;
|
||||
|
||||
-- 3. 제약조건 추가
|
||||
ALTER TABLE numbering_rules ADD CONSTRAINT check_table_scope_requires_table_name ...
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
- `scope_type='table'`로 변경된 후
|
||||
- 아직 `table_name=NULL`인 상태
|
||||
- 이 상태에서 INSERT/UPDATE 시도 시 제약조건 위반
|
||||
|
||||
### 수정된 마이그레이션 순서 (올바름)
|
||||
```sql
|
||||
-- 1. table_name 정리 (먼저 실행!)
|
||||
UPDATE numbering_rules SET table_name = '' WHERE table_name IS NULL;
|
||||
|
||||
-- 2. scope_type 변경
|
||||
UPDATE numbering_rules SET scope_type = 'table' WHERE scope_type IN ('global', 'menu');
|
||||
|
||||
-- 3. 제약조건 추가
|
||||
ALTER TABLE numbering_rules ADD CONSTRAINT check_table_scope_requires_table_name ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 실행 체크리스트
|
||||
|
||||
- [ ] 옵션 1, 2, 또는 3 중 하나 선택하여 데이터 수정 완료
|
||||
- [ ] `SELECT COUNT(*) FROM numbering_rules WHERE table_name IS NULL;` 실행 → 결과가 `0`인지 확인
|
||||
- [ ] 전체 마이그레이션 `046_update_numbering_rules_scope_type.sql` 실행
|
||||
- [ ] 백엔드 재시작
|
||||
- [ ] 프론트엔드에서 채번 규칙 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 완료 후 확인사항
|
||||
|
||||
### SQL로 최종 확인
|
||||
```sql
|
||||
-- 1. 모든 규칙이 table 타입인지
|
||||
SELECT scope_type, COUNT(*)
|
||||
FROM numbering_rules
|
||||
GROUP BY scope_type;
|
||||
-- 결과: table만 나와야 함
|
||||
|
||||
-- 2. table_name이 NULL인 규칙이 없는지
|
||||
SELECT COUNT(*)
|
||||
FROM numbering_rules
|
||||
WHERE table_name IS NULL;
|
||||
-- 결과: 0
|
||||
|
||||
-- 3. 샘플 데이터 확인
|
||||
SELECT
|
||||
rule_id,
|
||||
rule_name,
|
||||
scope_type,
|
||||
table_name,
|
||||
company_code
|
||||
FROM numbering_rules
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 추가 정보
|
||||
|
||||
수정된 마이그레이션 파일(`046_update_numbering_rules_scope_type.sql`)은 이제 올바른 순서로 실행되도록 업데이트되었습니다. 하지만 **이미 실행된 부분적인 마이그레이션**으로 인해 데이터가 불일치 상태일 수 있으므로, 위의 긴급 수정을 먼저 실행하는 것이 안전합니다.
|
||||
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
# 마이그레이션 046: 채번규칙 scope_type 확장
|
||||
|
||||
## 📋 목적
|
||||
|
||||
메뉴 기반 채번규칙 필터링을 **테이블 기반 필터링**으로 전환하여 더 직관적이고 유지보수하기 쉬운 시스템 구축
|
||||
|
||||
### 주요 변경사항
|
||||
|
||||
1. `scope_type` 값 확장: `'global'`, `'menu'` → `'global'`, `'table'`, `'menu'`
|
||||
2. 기존 데이터 자동 마이그레이션 (`global` + `table_name` → `table`)
|
||||
3. 유효성 검증 제약조건 추가
|
||||
4. 멀티테넌시 인덱스 최적화
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
### Docker 환경 (권장)
|
||||
|
||||
```bash
|
||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/046_update_numbering_rules_scope_type.sql
|
||||
```
|
||||
|
||||
### 로컬 PostgreSQL
|
||||
|
||||
```bash
|
||||
psql -U postgres -d ilshin -f db/migrations/046_update_numbering_rules_scope_type.sql
|
||||
```
|
||||
|
||||
### pgAdmin / DBeaver
|
||||
|
||||
1. `db/migrations/046_update_numbering_rules_scope_type.sql` 파일 열기
|
||||
2. 전체 내용 복사
|
||||
3. SQL 쿼리 창에 붙여넣기
|
||||
4. 실행 (F5 또는 Execute)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 검증 방법
|
||||
|
||||
### 1. 제약조건 확인
|
||||
|
||||
```sql
|
||||
SELECT conname, pg_get_constraintdef(oid)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'numbering_rules'::regclass
|
||||
AND conname LIKE '%scope%';
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
conname | pg_get_constraintdef
|
||||
--------------------------------------|---------------------
|
||||
check_scope_type | CHECK (scope_type IN ('global', 'table', 'menu'))
|
||||
check_table_scope_requires_table_name | CHECK (...)
|
||||
check_global_scope_no_table_name | CHECK (...)
|
||||
check_menu_scope_requires_menu_objid | CHECK (...)
|
||||
```
|
||||
|
||||
### 2. 인덱스 확인
|
||||
|
||||
```sql
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'numbering_rules'
|
||||
AND indexname LIKE '%scope%'
|
||||
ORDER BY indexname;
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
indexname | indexdef
|
||||
------------------------------------|----------
|
||||
idx_numbering_rules_scope_menu | CREATE INDEX ... (scope_type, menu_objid, company_code)
|
||||
idx_numbering_rules_scope_table | CREATE INDEX ... (scope_type, table_name, company_code)
|
||||
```
|
||||
|
||||
### 3. 데이터 마이그레이션 확인
|
||||
|
||||
```sql
|
||||
-- scope_type별 개수
|
||||
SELECT scope_type, COUNT(*) as count
|
||||
FROM numbering_rules
|
||||
GROUP BY scope_type;
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
scope_type | count
|
||||
-----------|------
|
||||
global | X개 (table_name이 NULL인 규칙들)
|
||||
table | Y개 (table_name이 있는 규칙들)
|
||||
menu | Z개 (menu_objid가 있는 규칙들)
|
||||
```
|
||||
|
||||
### 4. 유효성 검증
|
||||
|
||||
```sql
|
||||
-- 이 쿼리들은 모두 0개를 반환해야 정상
|
||||
-- 1) global인데 table_name이 있는 규칙 (없어야 함)
|
||||
SELECT COUNT(*) as invalid_global
|
||||
FROM numbering_rules
|
||||
WHERE scope_type = 'global' AND table_name IS NOT NULL;
|
||||
|
||||
-- 2) table인데 table_name이 없는 규칙 (없어야 함)
|
||||
SELECT COUNT(*) as invalid_table
|
||||
FROM numbering_rules
|
||||
WHERE scope_type = 'table' AND table_name IS NULL;
|
||||
|
||||
-- 3) menu인데 menu_objid가 없는 규칙 (없어야 함)
|
||||
SELECT COUNT(*) as invalid_menu
|
||||
FROM numbering_rules
|
||||
WHERE scope_type = 'menu' AND menu_objid IS NULL;
|
||||
```
|
||||
|
||||
**모든 카운트가 0이어야 정상**
|
||||
|
||||
### 5. 회사별 데이터 격리 확인 (멀티테넌시)
|
||||
|
||||
```sql
|
||||
-- 회사별 규칙 개수
|
||||
SELECT
|
||||
company_code,
|
||||
scope_type,
|
||||
COUNT(*) as count
|
||||
FROM numbering_rules
|
||||
GROUP BY company_code, scope_type
|
||||
ORDER BY company_code, scope_type;
|
||||
```
|
||||
|
||||
**각 회사의 데이터가 독립적으로 존재해야 함**
|
||||
|
||||
---
|
||||
|
||||
## 🚨 롤백 방법 (문제 발생 시)
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
|
||||
-- 제약조건 제거
|
||||
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_scope_type;
|
||||
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_table_scope_requires_table_name;
|
||||
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_global_scope_no_table_name;
|
||||
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_menu_scope_requires_menu_objid;
|
||||
|
||||
-- 인덱스 제거
|
||||
DROP INDEX IF EXISTS idx_numbering_rules_scope_table;
|
||||
DROP INDEX IF EXISTS idx_numbering_rules_scope_menu;
|
||||
|
||||
-- 데이터 롤백 (table → global)
|
||||
UPDATE numbering_rules
|
||||
SET scope_type = 'global'
|
||||
WHERE scope_type = 'table';
|
||||
|
||||
-- 기존 제약조건 복원
|
||||
ALTER TABLE numbering_rules
|
||||
ADD CONSTRAINT check_scope_type
|
||||
CHECK (scope_type IN ('global', 'menu'));
|
||||
|
||||
-- 기존 인덱스 복원
|
||||
CREATE INDEX IF NOT EXISTS idx_numbering_rules_table
|
||||
ON numbering_rules(table_name, column_name);
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 마이그레이션 내용 상세
|
||||
|
||||
### 변경 사항
|
||||
|
||||
| 항목 | 변경 전 | 변경 후 |
|
||||
|------|---------|---------|
|
||||
| **scope_type 값** | 'global', 'menu' | 'global', 'table', 'menu' |
|
||||
| **유효성 검증** | 없음 | table/global/menu 타입별 제약조건 추가 |
|
||||
| **인덱스** | (table_name, column_name) | (scope_type, table_name, company_code)<br>(scope_type, menu_objid, company_code) |
|
||||
| **데이터** | global + table_name | table 타입으로 자동 변경 |
|
||||
|
||||
### 영향받는 데이터
|
||||
|
||||
```sql
|
||||
-- 자동으로 변경되는 규칙 조회
|
||||
SELECT
|
||||
rule_id,
|
||||
rule_name,
|
||||
scope_type as old_scope_type,
|
||||
'table' as new_scope_type,
|
||||
table_name,
|
||||
company_code
|
||||
FROM numbering_rules
|
||||
WHERE scope_type = 'global'
|
||||
AND table_name IS NOT NULL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **백업 필수**: 마이그레이션 실행 전 반드시 데이터베이스 백업
|
||||
2. **트랜잭션**: 전체 마이그레이션이 하나의 트랜잭션으로 실행됨 (실패 시 자동 롤백)
|
||||
3. **성능**: 규칙이 많으면 실행 시간이 길어질 수 있음 (보통 1초 이내)
|
||||
4. **멀티테넌시**: 모든 회사의 데이터가 안전하게 마이그레이션됨
|
||||
5. **하위 호환성**: 기존 기능 100% 유지 (자동 변환)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 문제 해결
|
||||
|
||||
### 제약조건 충돌 발생 시
|
||||
|
||||
```sql
|
||||
-- 문제가 되는 데이터 확인
|
||||
SELECT rule_id, rule_name, scope_type, table_name, menu_objid
|
||||
FROM numbering_rules
|
||||
WHERE
|
||||
(scope_type = 'table' AND table_name IS NULL)
|
||||
OR (scope_type = 'global' AND table_name IS NOT NULL)
|
||||
OR (scope_type = 'menu' AND menu_objid IS NULL);
|
||||
|
||||
-- 수동 수정 후 다시 마이그레이션 실행
|
||||
```
|
||||
|
||||
### 인덱스 생성 실패 시
|
||||
|
||||
```sql
|
||||
-- 기존 인덱스 확인
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'numbering_rules';
|
||||
|
||||
-- 충돌하는 인덱스 삭제 후 다시 실행
|
||||
DROP INDEX IF EXISTS <충돌하는_인덱스명>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 성능 개선 효과
|
||||
|
||||
### Before (기존)
|
||||
```sql
|
||||
-- 단일 인덱스: (table_name, column_name)
|
||||
-- company_code 필터링 시 Full Table Scan 가능성
|
||||
```
|
||||
|
||||
### After (변경 후)
|
||||
```sql
|
||||
-- 복합 인덱스: (scope_type, table_name, company_code)
|
||||
-- 멀티테넌시 쿼리 성능 향상 (회사별 격리 최적화)
|
||||
-- WHERE 절과 ORDER BY 절 모두 인덱스 활용 가능
|
||||
```
|
||||
|
||||
**예상 성능 향상**: 회사별 규칙 조회 시 **3-5배 빠름**
|
||||
|
||||
---
|
||||
|
||||
## 📞 지원
|
||||
|
||||
- **작성자**: 개발팀
|
||||
- **작성일**: 2025-11-08
|
||||
- **관련 문서**: `/채번규칙_테이블기반_필터링_구현_계획서.md`
|
||||
- **이슈 발생 시**: 롤백 스크립트 실행 후 개발팀 문의
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
마이그레이션 완료 후:
|
||||
|
||||
1. ✅ 검증 쿼리 실행
|
||||
2. ⬜ 백엔드 API 수정 (Phase 2)
|
||||
3. ⬜ 프론트엔드 수정 (Phase 3-5)
|
||||
4. ⬜ 통합 테스트
|
||||
|
||||
**마이그레이션 준비 완료!** 🚀
|
||||
|
||||
|
|
@ -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
|
||||
**다음 업데이트**: 구현 완료 후
|
||||
|
||||
|
|
@ -364,7 +364,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</ResizableDialogHeader>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto">
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -374,13 +374,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
) : screenData ? (
|
||||
<div
|
||||
className="relative bg-white"
|
||||
className="relative bg-white mx-auto"
|
||||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
height: screenDimensions?.height || 600,
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
transformOrigin: "center center",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ interface NumberingRuleDesignerProps {
|
|||
maxRules?: number;
|
||||
isPreview?: boolean;
|
||||
className?: string;
|
||||
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
|
||||
}
|
||||
|
||||
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||
|
|
@ -34,6 +35,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
maxRules = 6,
|
||||
isPreview = false,
|
||||
className = "",
|
||||
currentTableName,
|
||||
}) => {
|
||||
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
|
|
@ -131,17 +133,32 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
try {
|
||||
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
||||
|
||||
// 저장 전에 현재 화면의 테이블명 자동 설정
|
||||
const ruleToSave = {
|
||||
...currentRule,
|
||||
scopeType: "table" as const, // 항상 table로 고정
|
||||
tableName: currentTableName || currentRule.tableName || "", // 현재 테이블명 자동 설정
|
||||
};
|
||||
|
||||
console.log("💾 채번 규칙 저장:", {
|
||||
currentTableName,
|
||||
"currentRule.tableName": currentRule.tableName,
|
||||
"ruleToSave.tableName": ruleToSave.tableName,
|
||||
"ruleToSave.scopeType": ruleToSave.scopeType,
|
||||
ruleToSave
|
||||
});
|
||||
|
||||
let response;
|
||||
if (existing) {
|
||||
response = await updateNumberingRule(currentRule.ruleId, currentRule);
|
||||
response = await updateNumberingRule(ruleToSave.ruleId, ruleToSave);
|
||||
} else {
|
||||
response = await createNumberingRule(currentRule);
|
||||
response = await createNumberingRule(ruleToSave);
|
||||
}
|
||||
|
||||
if (response.success && response.data) {
|
||||
setSavedRules((prev) => {
|
||||
if (existing) {
|
||||
return prev.map((r) => (r.ruleId === currentRule.ruleId ? response.data! : r));
|
||||
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? response.data! : r));
|
||||
} else {
|
||||
return [...prev, response.data!];
|
||||
}
|
||||
|
|
@ -160,7 +177,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentRule, savedRules, onSave]);
|
||||
}, [currentRule, savedRules, onSave, currentTableName]);
|
||||
|
||||
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
|
||||
setSelectedRuleId(rule.ruleId);
|
||||
|
|
@ -196,6 +213,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
);
|
||||
|
||||
const handleNewRule = useCallback(() => {
|
||||
console.log("📋 새 규칙 생성 - currentTableName:", currentTableName);
|
||||
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: "새 채번 규칙",
|
||||
|
|
@ -203,14 +222,17 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "menu",
|
||||
scopeType: "table", // 기본값을 table로 설정
|
||||
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
|
||||
};
|
||||
|
||||
console.log("📋 생성된 규칙 정보:", newRule);
|
||||
|
||||
setSelectedRuleId(newRule.ruleId);
|
||||
setCurrentRule(newRule);
|
||||
|
||||
toast.success("새 규칙이 생성되었습니다");
|
||||
}, []);
|
||||
}, [currentTableName]);
|
||||
|
||||
return (
|
||||
<div className={`flex h-full gap-4 ${className}`}>
|
||||
|
|
@ -312,20 +334,36 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label className="text-sm font-medium">규칙명</Label>
|
||||
<Input
|
||||
value={currentRule.ruleName}
|
||||
onChange={(e) => setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))}
|
||||
className="h-9"
|
||||
placeholder="예: 프로젝트 코드"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label className="text-sm font-medium">미리보기</Label>
|
||||
<NumberingRulePreview config={currentRule} />
|
||||
<div className="space-y-3">
|
||||
{/* 첫 번째 줄: 규칙명 + 미리보기 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label className="text-sm font-medium">규칙명</Label>
|
||||
<Input
|
||||
value={currentRule.ruleName}
|
||||
onChange={(e) => setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))}
|
||||
className="h-9"
|
||||
placeholder="예: 프로젝트 코드"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label className="text-sm font-medium">미리보기</Label>
|
||||
<NumberingRulePreview config={currentRule} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 두 번째 줄: 자동 감지된 테이블 정보 표시 */}
|
||||
{currentTableName && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">적용 테이블</Label>
|
||||
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{currentTableName}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
이 규칙은 현재 화면의 테이블({currentTableName})에 자동으로 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -401,15 +401,14 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
const applyStyles = (element: React.ReactElement) => {
|
||||
if (!comp.style) return element;
|
||||
|
||||
// ✅ 격자 시스템 잔재 제거: style.width, style.height는 무시
|
||||
// size.width, size.height가 부모 컨테이너에서 적용되므로
|
||||
const { width, height, ...styleWithoutSize } = comp.style;
|
||||
|
||||
return React.cloneElement(element, {
|
||||
style: {
|
||||
...element.props.style, // 기존 스타일 유지
|
||||
...comp.style,
|
||||
// 크기는 부모 컨테이너에서 처리하므로 제거 (하지만 다른 스타일은 유지)
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "100%",
|
||||
maxHeight: "100%",
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 적용
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
});
|
||||
|
|
@ -1887,7 +1886,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full w-full">
|
||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
|
|
@ -1897,7 +1896,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
)}
|
||||
|
||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||
<div className="h-full w-full">{renderInteractiveWidget(componentForRendering)}</div>
|
||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
||||
</div>
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
|
|
|
|||
|
|
@ -343,10 +343,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
const applyStyles = (element: React.ReactElement) => {
|
||||
if (!comp.style) return element;
|
||||
|
||||
// ✅ 격자 시스템 잔재 제거: style.width, style.height는 무시
|
||||
// size.width, size.height가 부모 컨테이너에서 적용되므로
|
||||
const { width, height, ...styleWithoutSize } = comp.style;
|
||||
|
||||
return React.cloneElement(element, {
|
||||
style: {
|
||||
...element.props.style,
|
||||
...comp.style,
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 적용
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "100%",
|
||||
|
|
@ -676,14 +680,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// 메인 렌더링
|
||||
const { type, position, size, style = {} } = component;
|
||||
|
||||
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
|
||||
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
left: position?.x || 0,
|
||||
top: position?.y || 0,
|
||||
width: size?.width || 200,
|
||||
height: size?.height || 10,
|
||||
zIndex: position?.z || 1,
|
||||
...style,
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||
height: size?.height || 10,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -563,7 +563,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<div className="pointer-events-none h-full w-full">
|
||||
<div className="h-full w-full">
|
||||
<WidgetRenderer
|
||||
component={component}
|
||||
isDesignMode={isDesignMode}
|
||||
|
|
|
|||
|
|
@ -214,22 +214,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
if (component.componentConfig?.type === "table-list") {
|
||||
// 디자인 해상도 기준으로 픽셀 반환
|
||||
const screenWidth = 1920; // 기본 디자인 해상도
|
||||
console.log("📏 [getWidth] table-list 픽셀 사용:", {
|
||||
componentId: id,
|
||||
label: component.label,
|
||||
width: `${screenWidth}px`,
|
||||
});
|
||||
return `${screenWidth}px`;
|
||||
}
|
||||
|
||||
// 모든 컴포넌트는 size.width 픽셀 사용
|
||||
const width = `${size?.width || 100}px`;
|
||||
console.log("📐 [getWidth] 픽셀 기준 통일:", {
|
||||
componentId: id,
|
||||
label: component.label,
|
||||
width,
|
||||
sizeWidth: size?.width,
|
||||
});
|
||||
return width;
|
||||
};
|
||||
|
||||
|
|
@ -268,13 +257,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
}
|
||||
: component;
|
||||
|
||||
// componentStyle에서 width, height 제거 (size.width, size.height만 사용)
|
||||
const { width: _styleWidth, height: _styleHeight, ...restComponentStyle } = componentStyle || {};
|
||||
|
||||
const baseStyle = {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: getWidth(), // getWidth()가 모든 우선순위를 처리
|
||||
height: getHeight(),
|
||||
...restComponentStyle, // width/height 제외한 스타일 먼저 적용
|
||||
width: getWidth(), // size.width로 덮어쓰기
|
||||
height: getHeight(), // size.height로 덮어쓰기
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
...componentStyle,
|
||||
right: undefined,
|
||||
};
|
||||
|
||||
|
|
@ -286,33 +278,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
if (outerDivRef.current && innerDivRef.current) {
|
||||
const outerRect = outerDivRef.current.getBoundingClientRect();
|
||||
const innerRect = innerDivRef.current.getBoundingClientRect();
|
||||
const computedOuter = window.getComputedStyle(outerDivRef.current);
|
||||
const computedInner = window.getComputedStyle(innerDivRef.current);
|
||||
|
||||
console.log("📐 [DOM 실제 크기 상세]:", {
|
||||
componentId: id,
|
||||
label: component.label,
|
||||
gridColumns: (component as any).gridColumns,
|
||||
"1. baseStyle.width": baseStyle.width,
|
||||
"2. 외부 div (파란 테두리)": {
|
||||
width: `${outerRect.width}px`,
|
||||
height: `${outerRect.height}px`,
|
||||
computedWidth: computedOuter.width,
|
||||
computedHeight: computedOuter.height,
|
||||
},
|
||||
"3. 내부 div (컨텐츠 래퍼)": {
|
||||
width: `${innerRect.width}px`,
|
||||
height: `${innerRect.height}px`,
|
||||
computedWidth: computedInner.width,
|
||||
computedHeight: computedInner.height,
|
||||
className: innerDivRef.current.className,
|
||||
inlineStyle: innerDivRef.current.getAttribute("style"),
|
||||
},
|
||||
"4. 너비 비교": {
|
||||
"외부 / 내부": `${outerRect.width}px / ${innerRect.width}px`,
|
||||
비율: `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`,
|
||||
},
|
||||
});
|
||||
// 크기 측정 완료
|
||||
}
|
||||
}, [id, component.label, (component as any).gridColumns, baseStyle.width]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, ResizableDialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Save, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -200,8 +200,21 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
const calculateDynamicSize = () => {
|
||||
if (!components.length) return { width: 800, height: 600 };
|
||||
|
||||
const maxX = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)));
|
||||
const maxY = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)));
|
||||
const maxX = Math.max(...components.map((c) => {
|
||||
const x = c.position?.x || 0;
|
||||
const width = typeof c.size?.width === 'number'
|
||||
? c.size.width
|
||||
: parseInt(String(c.size?.width || 200), 10);
|
||||
return x + width;
|
||||
}));
|
||||
|
||||
const maxY = Math.max(...components.map((c) => {
|
||||
const y = c.position?.y || 0;
|
||||
const height = typeof c.size?.height === 'number'
|
||||
? c.size.height
|
||||
: parseInt(String(c.size?.height || 40), 10);
|
||||
return y + height;
|
||||
}));
|
||||
|
||||
const padding = 40;
|
||||
return {
|
||||
|
|
@ -213,9 +226,16 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
const dynamicSize = calculateDynamicSize();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||
<DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] gap-0 p-0`}>
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<ResizableDialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||
<ResizableDialogContent
|
||||
modalId={`save-modal-${screenId}`}
|
||||
defaultWidth={dynamicSize.width + 48}
|
||||
defaultHeight={dynamicSize.height + 120}
|
||||
minWidth={400}
|
||||
minHeight={300}
|
||||
className="gap-0 p-0"
|
||||
>
|
||||
<ResizableDialogHeader className="border-b px-6 py-4 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<ResizableDialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</ResizableDialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -237,9 +257,9 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</ResizableDialogHeader>
|
||||
|
||||
<div className="overflow-auto p-6">
|
||||
<div className="overflow-auto p-6 flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
|
|
@ -248,21 +268,42 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
<div
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: dynamicSize.width,
|
||||
height: dynamicSize.height,
|
||||
overflow: "hidden",
|
||||
width: `${dynamicSize.width}px`,
|
||||
height: `${dynamicSize.height}px`,
|
||||
minWidth: `${dynamicSize.width}px`,
|
||||
minHeight: `${dynamicSize.height}px`,
|
||||
}}
|
||||
>
|
||||
<div className="relative" style={{ minHeight: "300px" }}>
|
||||
{components.map((component, index) => (
|
||||
<div className="relative" style={{ width: `${dynamicSize.width}px`, height: `${dynamicSize.height}px` }}>
|
||||
{components.map((component, index) => {
|
||||
// ✅ 격자 시스템 잔재 제거: size의 픽셀 값만 사용
|
||||
const widthPx = typeof component.size?.width === 'number'
|
||||
? component.size.width
|
||||
: parseInt(String(component.size?.width || 200), 10);
|
||||
const heightPx = typeof component.size?.height === 'number'
|
||||
? component.size.height
|
||||
: parseInt(String(component.size?.height || 40), 10);
|
||||
|
||||
// 디버깅: 실제 크기 확인
|
||||
if (index === 0) {
|
||||
console.log('🔍 SaveModal 컴포넌트 크기:', {
|
||||
componentId: component.id,
|
||||
'size.width (원본)': component.size?.width,
|
||||
'size.width 타입': typeof component.size?.width,
|
||||
'widthPx (계산)': widthPx,
|
||||
'style.width': component.style?.width,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: component.position?.y || 0,
|
||||
left: component.position?.x || 0,
|
||||
width: component.size?.width || 200,
|
||||
height: component.size?.height || 40,
|
||||
width: `${widthPx}px`, // ✅ 픽셀 단위 강제
|
||||
height: `${heightPx}px`, // ✅ 픽셀 단위 강제
|
||||
zIndex: component.position?.z || 1000 + index,
|
||||
}}
|
||||
>
|
||||
|
|
@ -307,14 +348,15 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-12 text-center">화면에 컴포넌트가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,18 +25,44 @@ import {
|
|||
restoreAbsolutePositions,
|
||||
} from "@/lib/utils/groupingUtils";
|
||||
import {
|
||||
calculateGridInfo,
|
||||
snapToGrid,
|
||||
snapSizeToGrid,
|
||||
generateGridLines,
|
||||
updateSizeFromGridColumns,
|
||||
adjustGridColumnsFromSize,
|
||||
alignGroupChildrenToGrid,
|
||||
calculateOptimalGroupSize,
|
||||
normalizeGroupChildPositions,
|
||||
updateSizeFromGridColumns,
|
||||
calculateWidthFromColumns,
|
||||
GridSettings as GridUtilSettings,
|
||||
snapSizeToGrid,
|
||||
snapToGrid,
|
||||
} 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 { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
|
@ -57,7 +83,6 @@ import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
|||
import { ComponentsPanel } from "./panels/ComponentsPanel";
|
||||
import PropertiesPanel from "./panels/PropertiesPanel";
|
||||
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
||||
import GridPanel from "./panels/GridPanel";
|
||||
import ResolutionPanel from "./panels/ResolutionPanel";
|
||||
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
||||
|
|
@ -281,55 +306,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 격자 정보 계산
|
||||
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]);
|
||||
|
||||
// 격자 라인 생성
|
||||
// 10px 격자 라인 생성 (시각적 가이드용)
|
||||
const gridLines = useMemo(() => {
|
||||
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
|
||||
if (!layout.gridSettings?.showGrid) return [];
|
||||
|
||||
// 캔버스 크기는 해상도 크기 사용
|
||||
const width = screenResolution.width;
|
||||
const height = screenResolution.height;
|
||||
const lines: Array<{ type: "vertical" | "horizontal"; position: number }> = [];
|
||||
|
||||
const lines = generateGridLines(width, height, {
|
||||
columns: layout.gridSettings.columns,
|
||||
gap: layout.gridSettings.gap,
|
||||
padding: layout.gridSettings.padding,
|
||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
||||
});
|
||||
// 10px 단위로 격자 라인 생성
|
||||
for (let x = 0; x <= width; x += 10) {
|
||||
lines.push({ type: "vertical", position: x });
|
||||
}
|
||||
for (let y = 0; y <= height; y += 10) {
|
||||
lines.push({ type: "horizontal", position: y });
|
||||
}
|
||||
|
||||
// 수직선과 수평선을 하나의 배열로 합치기
|
||||
const allLines = [
|
||||
...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]);
|
||||
return lines;
|
||||
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
|
||||
|
||||
// 필터된 테이블 목록
|
||||
const filteredTables = useMemo(() => {
|
||||
|
|
@ -527,64 +521,61 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const finalKey = pathParts[pathParts.length - 1];
|
||||
current[finalKey] = value;
|
||||
|
||||
// gridColumns 변경 시 크기 자동 업데이트
|
||||
if (path === "gridColumns" && gridInfo) {
|
||||
const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
|
||||
newComp.size = updatedSize;
|
||||
}
|
||||
// gridColumns 변경 시 크기 자동 업데이트 제거 (격자 시스템 제거됨)
|
||||
// if (path === "gridColumns" && prevLayout.gridSettings) {
|
||||
// const updatedSize = updateSizeFromGridColumns(newComp, prevLayout.gridSettings as GridUtilSettings);
|
||||
// newComp.size = updatedSize;
|
||||
// }
|
||||
|
||||
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
|
||||
if (
|
||||
(path === "size.width" || path === "size.height") &&
|
||||
prevLayout.gridSettings?.snapToGrid &&
|
||||
gridInfo &&
|
||||
newComp.type !== "group"
|
||||
) {
|
||||
// 현재 해상도에 맞는 격자 정보로 스냅 적용
|
||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: prevLayout.gridSettings.columns,
|
||||
gap: prevLayout.gridSettings.gap,
|
||||
padding: prevLayout.gridSettings.padding,
|
||||
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
||||
});
|
||||
const snappedSize = snapSizeToGrid(
|
||||
newComp.size,
|
||||
currentGridInfo,
|
||||
prevLayout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
newComp.size = snappedSize;
|
||||
// 크기 변경 시 격자 스냅 적용 제거 (직접 입력 시 불필요)
|
||||
// 드래그/리사이즈 시에는 별도 로직에서 처리됨
|
||||
// if (
|
||||
// (path === "size.width" || path === "size.height") &&
|
||||
// prevLayout.gridSettings?.snapToGrid &&
|
||||
// newComp.type !== "group"
|
||||
// ) {
|
||||
// const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
// columns: prevLayout.gridSettings.columns,
|
||||
// gap: prevLayout.gridSettings.gap,
|
||||
// padding: prevLayout.gridSettings.padding,
|
||||
// snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
||||
// });
|
||||
// const snappedSize = snapSizeToGrid(
|
||||
// newComp.size,
|
||||
// currentGridInfo,
|
||||
// prevLayout.gridSettings as GridUtilSettings,
|
||||
// );
|
||||
// newComp.size = snappedSize;
|
||||
//
|
||||
// const adjustedColumns = adjustGridColumnsFromSize(
|
||||
// newComp,
|
||||
// currentGridInfo,
|
||||
// prevLayout.gridSettings as GridUtilSettings,
|
||||
// );
|
||||
// if (newComp.gridColumns !== adjustedColumns) {
|
||||
// newComp.gridColumns = adjustedColumns;
|
||||
// }
|
||||
// }
|
||||
|
||||
// 크기 변경 시 gridColumns도 자동 조정
|
||||
const adjustedColumns = adjustGridColumnsFromSize(
|
||||
newComp,
|
||||
currentGridInfo,
|
||||
prevLayout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
if (newComp.gridColumns !== adjustedColumns) {
|
||||
newComp.gridColumns = adjustedColumns;
|
||||
}
|
||||
}
|
||||
|
||||
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정
|
||||
if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
|
||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: prevLayout.gridSettings.columns,
|
||||
gap: prevLayout.gridSettings.gap,
|
||||
padding: prevLayout.gridSettings.padding,
|
||||
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
||||
});
|
||||
|
||||
// gridColumns에 맞는 정확한 너비 계산
|
||||
const newWidth = calculateWidthFromColumns(
|
||||
newComp.gridColumns,
|
||||
currentGridInfo,
|
||||
prevLayout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
newComp.size = {
|
||||
...newComp.size,
|
||||
width: newWidth,
|
||||
};
|
||||
}
|
||||
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정 제거 (격자 시스템 제거됨)
|
||||
// if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
|
||||
// const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
// columns: prevLayout.gridSettings.columns,
|
||||
// gap: prevLayout.gridSettings.gap,
|
||||
// padding: prevLayout.gridSettings.padding,
|
||||
// snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
||||
// });
|
||||
//
|
||||
// const newWidth = calculateWidthFromColumns(
|
||||
// newComp.gridColumns,
|
||||
// currentGridInfo,
|
||||
// prevLayout.gridSettings as GridUtilSettings,
|
||||
// );
|
||||
// newComp.size = {
|
||||
// ...newComp.size,
|
||||
// width: newWidth,
|
||||
// };
|
||||
// }
|
||||
|
||||
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
|
||||
if (
|
||||
|
|
@ -634,7 +625,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
} else if (newComp.type !== "group") {
|
||||
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
|
||||
const snappedPosition = snapToGrid(
|
||||
const snappedPosition = snapPositionTo10px(
|
||||
newComp.position,
|
||||
currentGridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
|
|
@ -684,7 +675,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
return newLayout;
|
||||
});
|
||||
},
|
||||
[gridInfo, saveToHistory], // 🔧 layout, selectedComponent 제거!
|
||||
[saveToHistory],
|
||||
);
|
||||
|
||||
// 컴포넌트 시스템 초기화
|
||||
|
|
@ -899,9 +890,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
layoutToUse = safeMigrateLayout(response, canvasWidth);
|
||||
}
|
||||
|
||||
// 🔄 webTypeConfig를 autoGeneration으로 변환
|
||||
const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter");
|
||||
const convertedComponents = convertLayoutComponents(layoutToUse.components);
|
||||
|
||||
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
|
||||
const layoutWithDefaultGrid = {
|
||||
...layoutToUse,
|
||||
components: convertedComponents, // 변환된 컴포넌트 사용
|
||||
gridSettings: {
|
||||
columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12
|
||||
gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16
|
||||
|
|
@ -1088,7 +1084,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
columns: newGridSettings.columns,
|
||||
gap: newGridSettings.gap,
|
||||
padding: newGridSettings.padding,
|
||||
snapToGrid: newGridSettings.snapToGrid,
|
||||
snapToGrid: true, // 항상 10px 스냅 활성화
|
||||
};
|
||||
|
||||
const adjustedComponents = layout.components.map((comp) => {
|
||||
|
|
@ -1203,7 +1199,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
columns: layout.gridSettings.columns,
|
||||
gap: layout.gridSettings.gap,
|
||||
padding: layout.gridSettings.padding,
|
||||
snapToGrid: layout.gridSettings.snapToGrid,
|
||||
snapToGrid: true,
|
||||
};
|
||||
|
||||
finalComponents = scaledComponents.map((comp) => {
|
||||
|
|
@ -1273,7 +1269,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
columns: layout.gridSettings.columns,
|
||||
gap: layout.gridSettings.gap,
|
||||
padding: layout.gridSettings.padding,
|
||||
snapToGrid: layout.gridSettings.snapToGrid,
|
||||
snapToGrid: true,
|
||||
};
|
||||
|
||||
const adjustedComponents = layout.components.map((comp) => {
|
||||
|
|
@ -1445,7 +1441,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 격자 스냅 적용
|
||||
const finalPosition =
|
||||
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 };
|
||||
|
||||
if (templateComp.type === "container") {
|
||||
|
|
@ -1511,7 +1511,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
templateSize: templateComp.size,
|
||||
calculatedSize,
|
||||
hasGridInfo: !!currentGridInfo,
|
||||
hasGridSettings: !!layout.gridSettings?.snapToGrid,
|
||||
hasGridSettings: !!layout.gridSettings,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -1802,7 +1802,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
||||
},
|
||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
|
||||
[layout, selectedScreen, saveToHistory],
|
||||
);
|
||||
|
||||
// 레이아웃 드래그 처리
|
||||
|
|
@ -1811,8 +1811,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const dropX = e.clientX - rect.left;
|
||||
const dropY = e.clientY - rect.top;
|
||||
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
|
||||
const dropX = (e.clientX - rect.left) / zoomLevel;
|
||||
const dropY = (e.clientY - rect.top) / zoomLevel;
|
||||
|
||||
// 현재 해상도에 맞는 격자 정보 계산
|
||||
const currentGridInfo = layout.gridSettings
|
||||
|
|
@ -1830,9 +1831,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
||||
: { x: dropX, y: dropY, z: 1 };
|
||||
|
||||
console.log("🏗️ 레이아웃 드롭:", {
|
||||
console.log("🏗️ 레이아웃 드롭 (줌 보정):", {
|
||||
zoomLevel,
|
||||
layoutType: layoutData.layoutType,
|
||||
zonesCount: layoutData.zones.length,
|
||||
mouseRaw: { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
||||
dropPosition: { x: dropX, y: dropY },
|
||||
snappedPosition,
|
||||
});
|
||||
|
|
@ -1869,7 +1872,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||
},
|
||||
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory],
|
||||
[layout, screenResolution, saveToHistory, zoomLevel],
|
||||
);
|
||||
|
||||
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
||||
|
|
@ -1954,32 +1957,47 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const componentWidth = component.defaultSize?.width || 120;
|
||||
const componentHeight = component.defaultSize?.height || 36;
|
||||
|
||||
// 방법 1: 마우스 포인터를 컴포넌트 중심으로 (현재 방식)
|
||||
const dropX_centered = e.clientX - rect.left - componentWidth / 2;
|
||||
const dropY_centered = e.clientY - rect.top - componentHeight / 2;
|
||||
// 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산
|
||||
// 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center)
|
||||
// 2. 캔버스가 justify-center로 중앙 정렬되어 있음
|
||||
|
||||
// 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 (사용자가 원할 수도 있는 방식)
|
||||
const dropX_topleft = e.clientX - rect.left;
|
||||
const dropY_topleft = e.clientY - rect.top;
|
||||
// 실제 캔버스 논리적 크기
|
||||
const canvasLogicalWidth = screenResolution.width;
|
||||
|
||||
// 화면상 캔버스 실제 크기 (스케일 적용 후)
|
||||
const canvasVisualWidth = canvasLogicalWidth * zoomLevel;
|
||||
|
||||
// 중앙 정렬로 인한 왼쪽 오프셋 계산
|
||||
// rect.left는 이미 중앙 정렬된 위치를 반영하고 있음
|
||||
|
||||
// 마우스의 캔버스 내 상대 위치 (스케일 보정)
|
||||
const mouseXInCanvas = (e.clientX - rect.left) / zoomLevel;
|
||||
const mouseYInCanvas = (e.clientY - rect.top) / zoomLevel;
|
||||
|
||||
// 방법 1: 마우스 포인터를 컴포넌트 중심으로
|
||||
const dropX_centered = mouseXInCanvas - componentWidth / 2;
|
||||
const dropY_centered = mouseYInCanvas - componentHeight / 2;
|
||||
|
||||
// 방법 2: 마우스 포인터를 컴포넌트 좌상단으로
|
||||
const dropX_topleft = mouseXInCanvas;
|
||||
const dropY_topleft = mouseYInCanvas;
|
||||
|
||||
// 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록
|
||||
const dropX = dropX_topleft;
|
||||
const dropY = dropY_topleft;
|
||||
|
||||
console.log("🎯 위치 계산 디버깅:", {
|
||||
"1. 마우스 위치": { clientX: e.clientX, clientY: e.clientY },
|
||||
"2. 캔버스 위치": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||
"3. 캔버스 내 상대 위치": { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
||||
"4. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
|
||||
"5a. 중심 방식 좌상단": { x: dropX_centered, y: dropY_centered },
|
||||
"5b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
|
||||
"6. 선택된 방식": { dropX, dropY },
|
||||
"7. 예상 컴포넌트 중심": { x: dropX + componentWidth / 2, y: dropY + componentHeight / 2 },
|
||||
"8. 마우스와 중심 일치 확인": {
|
||||
match:
|
||||
Math.abs(dropX + componentWidth / 2 - (e.clientX - rect.left)) < 1 &&
|
||||
Math.abs(dropY + componentHeight / 2 - (e.clientY - rect.top)) < 1,
|
||||
},
|
||||
console.log("🎯 위치 계산 디버깅 (줌 레벨 + 중앙정렬 반영):", {
|
||||
"1. 줌 레벨": zoomLevel,
|
||||
"2. 마우스 위치 (화면)": { clientX: e.clientX, clientY: e.clientY },
|
||||
"3. 캔버스 위치 (rect)": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||
"4. 캔버스 논리적 크기": { width: canvasLogicalWidth, height: screenResolution.height },
|
||||
"5. 캔버스 시각적 크기": { width: canvasVisualWidth, height: screenResolution.height * zoomLevel },
|
||||
"6. 마우스 캔버스 내 상대위치 (줌 전)": { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
||||
"7. 마우스 캔버스 내 상대위치 (줌 보정)": { x: mouseXInCanvas, y: mouseYInCanvas },
|
||||
"8. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
|
||||
"9a. 중심 방식": { x: dropX_centered, y: dropY_centered },
|
||||
"9b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
|
||||
"10. 최종 선택": { dropX, dropY },
|
||||
});
|
||||
|
||||
// 현재 해상도에 맞는 격자 정보 계산
|
||||
|
|
@ -1999,7 +2017,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 격자 스냅 적용
|
||||
const snappedPosition =
|
||||
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 };
|
||||
|
||||
console.log("🧩 컴포넌트 드롭:", {
|
||||
|
|
@ -2108,21 +2130,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
});
|
||||
}
|
||||
|
||||
// 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
|
||||
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||||
// gridColumns에 맞는 정확한 너비 계산
|
||||
const calculatedWidth = calculateWidthFromColumns(
|
||||
gridColumns,
|
||||
gridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
|
||||
// 컴포넌트별 최소 크기 보장
|
||||
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : component.defaultSize.width;
|
||||
|
||||
// 10px 단위로 너비 스냅
|
||||
if (layout.gridSettings?.snapToGrid) {
|
||||
componentSize = {
|
||||
...component.defaultSize,
|
||||
width: Math.max(calculatedWidth, minWidth),
|
||||
width: snapTo10px(component.defaultSize.width),
|
||||
height: snapTo10px(component.defaultSize.height),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2211,7 +2224,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
||||
},
|
||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
|
||||
[layout, selectedScreen, saveToHistory],
|
||||
);
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
|
|
@ -2286,74 +2299,44 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
} else if (type === "column") {
|
||||
// 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;
|
||||
|
||||
// 격자 스냅이 활성화된 경우 정확한 격자 크기로 생성, 아니면 기본값
|
||||
const defaultWidth =
|
||||
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 => {
|
||||
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||
const getDefaultWidth = (widgetType: string): number => {
|
||||
const widthMap: Record<string, number> = {
|
||||
// 텍스트 입력 계열 (넓게)
|
||||
text: 4, // 1/3 (33%)
|
||||
email: 4, // 1/3 (33%)
|
||||
tel: 3, // 1/4 (25%)
|
||||
url: 4, // 1/3 (33%)
|
||||
textarea: 6, // 절반 (50%)
|
||||
// 텍스트 입력 계열
|
||||
text: 200,
|
||||
email: 200,
|
||||
tel: 150,
|
||||
url: 250,
|
||||
textarea: 300,
|
||||
|
||||
// 숫자/날짜 입력 (중간)
|
||||
number: 2, // 2/12 (16.67%)
|
||||
decimal: 2, // 2/12 (16.67%)
|
||||
date: 3, // 1/4 (25%)
|
||||
datetime: 3, // 1/4 (25%)
|
||||
time: 2, // 2/12 (16.67%)
|
||||
// 숫자/날짜 입력
|
||||
number: 120,
|
||||
decimal: 120,
|
||||
date: 150,
|
||||
datetime: 180,
|
||||
time: 120,
|
||||
|
||||
// 선택 입력 (중간)
|
||||
select: 3, // 1/4 (25%)
|
||||
radio: 3, // 1/4 (25%)
|
||||
checkbox: 2, // 2/12 (16.67%)
|
||||
boolean: 2, // 2/12 (16.67%)
|
||||
// 선택 입력
|
||||
select: 180,
|
||||
radio: 180,
|
||||
checkbox: 120,
|
||||
boolean: 120,
|
||||
|
||||
// 코드/참조 (넓게)
|
||||
code: 3, // 1/4 (25%)
|
||||
entity: 4, // 1/3 (33%)
|
||||
// 코드/참조
|
||||
code: 180,
|
||||
entity: 200,
|
||||
|
||||
// 파일/이미지 (넓게)
|
||||
file: 4, // 1/3 (33%)
|
||||
image: 3, // 1/4 (25%)
|
||||
// 파일/이미지
|
||||
file: 250,
|
||||
image: 200,
|
||||
|
||||
// 기타
|
||||
button: 2, // 2/12 (16.67%)
|
||||
label: 2, // 2/12 (16.67%)
|
||||
button: 100,
|
||||
label: 100,
|
||||
};
|
||||
|
||||
const defaultColumns = widthMap[widgetType] || 3; // 기본값 3 (1/4, 25%)
|
||||
console.log("🎯 [ScreenDesigner] getDefaultGridColumns:", { widgetType, defaultColumns });
|
||||
return defaultColumns;
|
||||
return widthMap[widgetType] || 200; // 기본값 200px
|
||||
};
|
||||
|
||||
// 웹타입별 기본 높이 계산
|
||||
|
|
@ -2365,7 +2348,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
file: 240, // 파일 업로드 (40 * 6)
|
||||
};
|
||||
|
||||
return heightMap[widgetType] || 40; // 기본값 40
|
||||
return heightMap[widgetType] || 30; // 기본값 30px로 변경
|
||||
};
|
||||
|
||||
// 웹타입별 기본 설정 생성
|
||||
|
|
@ -2521,24 +2504,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||
// console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
|
||||
|
||||
// 웹타입별 적절한 gridColumns 계산
|
||||
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
|
||||
|
||||
// gridColumns에 맞는 실제 너비 계산
|
||||
const componentWidth =
|
||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||
? calculateWidthFromColumns(
|
||||
calculatedGridColumns,
|
||||
currentGridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
)
|
||||
: defaultWidth;
|
||||
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||
const componentWidth = getDefaultWidth(column.widgetType);
|
||||
|
||||
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
|
||||
widgetType: column.widgetType,
|
||||
calculatedGridColumns,
|
||||
componentWidth,
|
||||
defaultWidth,
|
||||
});
|
||||
|
||||
newComponent = {
|
||||
|
|
@ -2553,25 +2524,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
gridColumns: calculatedGridColumns,
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
codeCategory: column.codeCategory,
|
||||
}),
|
||||
style: {
|
||||
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
||||
labelDisplay: false, // 라벨 숨김
|
||||
labelFontSize: "12px",
|
||||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
labelMarginBottom: "6px",
|
||||
width: `${(calculatedGridColumns / (layout.gridSettings?.columns || 12)) * 100}%`, // 퍼센트 너비
|
||||
},
|
||||
componentConfig: {
|
||||
type: componentId, // text-input, number-input 등
|
||||
webType: column.widgetType, // 원본 웹타입 보존
|
||||
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
||||
...getDefaultWebTypeConfig(column.widgetType),
|
||||
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -2587,36 +2557,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||
// console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
|
||||
|
||||
// 웹타입별 적절한 gridColumns 계산
|
||||
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
|
||||
|
||||
// gridColumns에 맞는 실제 너비 계산
|
||||
const componentWidth =
|
||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||
? calculateWidthFromColumns(
|
||||
calculatedGridColumns,
|
||||
currentGridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
)
|
||||
: defaultWidth;
|
||||
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||
const componentWidth = getDefaultWidth(column.widgetType);
|
||||
|
||||
console.log("🎯 캔버스 컴포넌트 생성:", {
|
||||
widgetType: column.widgetType,
|
||||
calculatedGridColumns,
|
||||
componentWidth,
|
||||
defaultWidth,
|
||||
});
|
||||
|
||||
// 🔍 이미지 타입 드래그앤드롭 디버깅
|
||||
// if (column.widgetType === "image") {
|
||||
// console.log("🖼️ 이미지 컬럼 드래그앤드롭:", {
|
||||
// columnName: column.columnName,
|
||||
// widgetType: column.widgetType,
|
||||
// componentId,
|
||||
// column,
|
||||
// });
|
||||
// }
|
||||
|
||||
newComponent = {
|
||||
id: generateComponentId(),
|
||||
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
||||
|
|
@ -2628,25 +2576,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
gridColumns: calculatedGridColumns,
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
codeCategory: column.codeCategory,
|
||||
}),
|
||||
style: {
|
||||
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
|
||||
labelDisplay: false, // 라벨 숨김
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#000000", // 순수한 검정
|
||||
labelFontWeight: "500",
|
||||
labelMarginBottom: "8px",
|
||||
width: `${(calculatedGridColumns / (layout.gridSettings?.columns || 12)) * 100}%`, // 퍼센트 너비
|
||||
},
|
||||
componentConfig: {
|
||||
type: componentId, // text-input, number-input 등
|
||||
webType: column.widgetType, // 원본 웹타입 보존
|
||||
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
||||
...getDefaultWebTypeConfig(column.widgetType),
|
||||
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -2659,31 +2606,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
return;
|
||||
}
|
||||
|
||||
// 격자 스냅 적용 (그룹 컴포넌트 제외)
|
||||
// 10px 단위 스냅 적용 (그룹 컴포넌트 제외)
|
||||
if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") {
|
||||
// 현재 해상도에 맞는 격자 정보 계산
|
||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: layout.gridSettings.columns,
|
||||
gap: layout.gridSettings.gap,
|
||||
padding: layout.gridSettings.padding,
|
||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
||||
});
|
||||
newComponent.position = snapPositionTo10px(newComponent.position);
|
||||
newComponent.size = snapSizeTo10px(newComponent.size);
|
||||
|
||||
const gridUtilSettings = {
|
||||
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("🧲 새 컴포넌트 격자 스냅 적용:", {
|
||||
console.log("🧲 새 컴포넌트 10px 스냅 적용:", {
|
||||
type: newComponent.type,
|
||||
resolution: `${screenResolution.width}x${screenResolution.height}`,
|
||||
snappedPosition: newComponent.position,
|
||||
snappedSize: newComponent.size,
|
||||
columnWidth: currentGridInfo.columnWidth,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2710,7 +2641,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// console.error("드롭 처리 실패:", error);
|
||||
}
|
||||
},
|
||||
[layout, gridInfo, saveToHistory],
|
||||
[layout, saveToHistory],
|
||||
);
|
||||
|
||||
// 파일 컴포넌트 업데이트 처리
|
||||
|
|
@ -2826,7 +2757,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
// 컴포넌트 드래그 시작
|
||||
const startComponentDrag = useCallback(
|
||||
(component: ComponentData, event: React.MouseEvent) => {
|
||||
(component: ComponentData, event: React.MouseEvent | React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
|
@ -2839,9 +2770,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}));
|
||||
}
|
||||
|
||||
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
|
||||
const relativeMouseX = event.clientX - rect.left;
|
||||
const relativeMouseY = event.clientY - rect.top;
|
||||
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
|
||||
// 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요
|
||||
const relativeMouseX = (event.clientX - rect.left) / zoomLevel;
|
||||
const relativeMouseY = (event.clientY - rect.top) / zoomLevel;
|
||||
|
||||
// 다중 선택된 컴포넌트들 확인
|
||||
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
|
||||
|
|
@ -2866,13 +2798,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
|
||||
// console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
|
||||
console.log("마우스 위치:", {
|
||||
console.log("마우스 위치 (줌 보정):", {
|
||||
zoomLevel,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
rectLeft: rect.left,
|
||||
rectTop: rect.top,
|
||||
relativeX: relativeMouseX,
|
||||
relativeY: relativeMouseY,
|
||||
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
|
||||
componentX: component.position.x,
|
||||
componentY: component.position.y,
|
||||
grabOffsetX: relativeMouseX - component.position.x,
|
||||
|
|
@ -2906,7 +2839,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
justFinishedDrag: false,
|
||||
});
|
||||
},
|
||||
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag],
|
||||
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag, zoomLevel],
|
||||
);
|
||||
|
||||
// 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트)
|
||||
|
|
@ -2916,9 +2849,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
|
||||
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
|
||||
const relativeMouseX = event.clientX - rect.left;
|
||||
const relativeMouseY = event.clientY - rect.top;
|
||||
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
|
||||
// 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요
|
||||
const relativeMouseX = (event.clientX - rect.left) / zoomLevel;
|
||||
const relativeMouseY = (event.clientY - rect.top) / zoomLevel;
|
||||
|
||||
// 컴포넌트 크기 가져오기
|
||||
const draggedComp = layout.components.find((c) => c.id === dragState.draggedComponent.id);
|
||||
|
|
@ -2936,8 +2870,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
|
||||
// 드래그 상태 업데이트
|
||||
console.log("🔥 ScreenDesigner updateDragPosition:", {
|
||||
console.log("🔥 ScreenDesigner updateDragPosition (줌 보정):", {
|
||||
zoomLevel,
|
||||
draggedComponentId: dragState.draggedComponent.id,
|
||||
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
|
||||
oldPosition: dragState.currentPosition,
|
||||
newPosition: newPosition,
|
||||
});
|
||||
|
|
@ -2961,7 +2898,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 실제 레이아웃 업데이트는 endDrag에서 처리
|
||||
// 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시
|
||||
},
|
||||
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset],
|
||||
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset, zoomLevel],
|
||||
);
|
||||
|
||||
// 드래그 종료
|
||||
|
|
@ -2983,7 +2920,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
// 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
|
||||
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
|
||||
finalPosition = snapToGrid(
|
||||
finalPosition = snapPositionTo10px(
|
||||
{
|
||||
x: dragState.currentPosition.x,
|
||||
y: dragState.currentPosition.y,
|
||||
|
|
@ -3143,7 +3080,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
justFinishedDrag: false,
|
||||
}));
|
||||
}, 100);
|
||||
}, [dragState, layout, gridInfo, saveToHistory]);
|
||||
}, [dragState, layout, saveToHistory]);
|
||||
|
||||
// 드래그 선택 시작
|
||||
const startSelectionDrag = useCallback(
|
||||
|
|
@ -3638,8 +3575,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
console.log("🔧 그룹 생성 시작:", {
|
||||
selectedCount: selectedComponents.length,
|
||||
snapToGrid: layout.gridSettings?.snapToGrid,
|
||||
gridInfo: currentGridInfo,
|
||||
snapToGrid: true,
|
||||
});
|
||||
|
||||
// 컴포넌트 크기 조정 기반 그룹 크기 계산
|
||||
|
|
@ -3803,12 +3739,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
size: optimizedGroupSize,
|
||||
gridColumns: groupComponent.gridColumns,
|
||||
componentsScaled: !!scaledComponents.length,
|
||||
gridAligned: layout.gridSettings?.snapToGrid,
|
||||
gridAligned: true,
|
||||
});
|
||||
|
||||
toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`);
|
||||
},
|
||||
[layout, saveToHistory, gridInfo],
|
||||
[layout, saveToHistory],
|
||||
);
|
||||
|
||||
// 그룹 생성 함수 (다이얼로그 표시)
|
||||
|
|
@ -3904,36 +3840,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
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(() => {
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
|
|
@ -4222,7 +4128,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
|
||||
return (
|
||||
<ScreenPreviewProvider isPreviewMode={true}>
|
||||
<ScreenPreviewProvider isPreviewMode={false}>
|
||||
<div className="bg-background flex h-full w-full flex-col">
|
||||
{/* 상단 슬림 툴바 */}
|
||||
<SlimToolbar
|
||||
|
|
@ -4335,7 +4241,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
return (
|
||||
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg">
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground mb-1 flex items-center gap-2 text-xs">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
|
|
@ -4416,7 +4322,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
|
||||
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
|
||||
<div
|
||||
className="flex justify-center"
|
||||
style={{
|
||||
|
|
@ -4435,7 +4341,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
minHeight: `${screenResolution.height}px`,
|
||||
flexShrink: 0,
|
||||
transform: `scale(${zoomLevel})`,
|
||||
transformOrigin: "top center",
|
||||
transformOrigin: "top center", // 중앙 기준으로 스케일
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -752,17 +752,27 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
// console.log("🎨 selectedComponent 전체:", selectedComponent);
|
||||
|
||||
const handleConfigChange = (newConfig: WebTypeConfig) => {
|
||||
// console.log("🔧 WebTypeConfig 업데이트:", {
|
||||
// widgetType: widget.widgetType,
|
||||
// oldConfig: currentConfig,
|
||||
// newConfig,
|
||||
// componentId: widget.id,
|
||||
// isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig),
|
||||
// });
|
||||
|
||||
// 강제 새 객체 생성으로 React 변경 감지 보장
|
||||
const freshConfig = { ...newConfig };
|
||||
onUpdateProperty(widget.id, "webTypeConfig", freshConfig);
|
||||
|
||||
// TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑
|
||||
const textConfig = newConfig as any;
|
||||
if (textConfig.autoInput && textConfig.autoValueType === "numbering_rule" && textConfig.numberingRuleId) {
|
||||
onUpdateProperty(widget.id, "autoGeneration", {
|
||||
type: "numbering_rule",
|
||||
enabled: true,
|
||||
options: {
|
||||
numberingRuleId: textConfig.numberingRuleId,
|
||||
},
|
||||
});
|
||||
} else if (textConfig.autoInput === false) {
|
||||
// 자동입력이 비활성화되면 autoGeneration도 비활성화
|
||||
onUpdateProperty(widget.id, "autoGeneration", {
|
||||
type: "none",
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 1순위: DB에서 지정된 설정 패널 사용
|
||||
|
|
@ -776,7 +786,13 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
|
||||
if (ConfigPanelComponent) {
|
||||
// console.log(`🎨 ✅ ConfigPanelComponent 렌더링 시작`);
|
||||
return <ConfigPanelComponent config={currentConfig} onConfigChange={handleConfigChange} />;
|
||||
return (
|
||||
<ConfigPanelComponent
|
||||
config={currentConfig}
|
||||
onConfigChange={handleConfigChange}
|
||||
tableName={currentTableName} // 화면 테이블명 전달
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// console.log(`🎨 ❌ ConfigPanelComponent가 null - WebTypeConfigPanel 사용`);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,23 +7,20 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
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 { calculateGridInfo } from "@/lib/utils/gridUtils";
|
||||
|
||||
interface GridPanelProps {
|
||||
gridSettings: GridSettings;
|
||||
onGridSettingsChange: (settings: GridSettings) => void;
|
||||
onResetGrid: () => void;
|
||||
onForceGridUpdate?: () => void; // 강제 격자 재조정 추가
|
||||
screenResolution?: ScreenResolution; // 해상도 정보 추가
|
||||
screenResolution?: ScreenResolution;
|
||||
}
|
||||
|
||||
export const GridPanel: React.FC<GridPanelProps> = ({
|
||||
gridSettings,
|
||||
onGridSettingsChange,
|
||||
onResetGrid,
|
||||
onForceGridUpdate,
|
||||
screenResolution,
|
||||
}) => {
|
||||
const updateSetting = (key: keyof GridSettings, value: any) => {
|
||||
|
|
@ -33,25 +30,6 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
// 실제 격자 정보 계산
|
||||
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 < 30 // 30px 미만이면 너무 작다고 판단
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
|
|
@ -62,25 +40,10 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
<h3 className="text-sm font-semibold">격자 설정</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onForceGridUpdate && (
|
||||
<Button
|
||||
size="sm"
|
||||
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>
|
||||
<Button size="sm" variant="outline" onClick={onResetGrid} className="h-7 px-2 text-xs">
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 주요 토글들 */}
|
||||
|
|
@ -121,82 +84,14 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
|
||||
{/* 설정 영역 */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* 격자 구조 */}
|
||||
{/* 10px 단위 스냅 안내 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold">격자 구조</h4>
|
||||
<h4 className="text-xs font-semibold">격자 시스템</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="columns" className="text-xs font-medium">
|
||||
컬럼 수
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="columns"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={gridSettings.columns}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 1 && value <= 24) {
|
||||
updateSetting("columns", value);
|
||||
}
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">/ 24</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="columns-slider"
|
||||
min={1}
|
||||
max={24}
|
||||
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>24열</span>
|
||||
</div>
|
||||
</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 className="bg-muted/50 rounded-md p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
모든 컴포넌트는 10px 단위로 자동 배치됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -204,10 +99,10 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
|
||||
{/* 격자 스타일 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">격자 스타일</h4>
|
||||
<h4 className="text-xs font-semibold">격자 스타일</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="gridColor" className="text-sm font-medium">
|
||||
<Label htmlFor="gridColor" className="text-xs font-medium">
|
||||
격자 색상
|
||||
</Label>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
|
|
@ -223,13 +118,13 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
value={gridSettings.gridColor || "#d1d5db"}
|
||||
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
||||
placeholder="#d1d5db"
|
||||
className="flex-1"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
</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)}%
|
||||
</Label>
|
||||
<Slider
|
||||
|
|
@ -241,7 +136,7 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
onValueChange={([value]) => updateSetting("gridOpacity", value)}
|
||||
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>100%</span>
|
||||
</div>
|
||||
|
|
@ -252,68 +147,46 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">미리보기</h4>
|
||||
<h4 className="text-xs font-semibold">미리보기</h4>
|
||||
|
||||
<div
|
||||
className="rounded-md border border-gray-200 bg-white p-4"
|
||||
className="rounded-md border bg-white p-4"
|
||||
style={{
|
||||
backgroundImage: gridSettings.showGrid
|
||||
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
|
||||
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
|
||||
: "none",
|
||||
backgroundSize: gridSettings.showGrid ? `${100 / gridSettings.columns}% 20px` : "none",
|
||||
backgroundSize: gridSettings.showGrid ? "10px 10px" : "none",
|
||||
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">
|
||||
<span className="text-primary text-xs">컴포넌트 예시</span>
|
||||
<span className="text-primary text-xs">10px 격자</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||
<div className="text-muted-foreground text-xs">💡 격자 설정은 실시간으로 캔버스에 반영됩니다 </div>
|
||||
<div className="border-t bg-muted/30 p-3">
|
||||
{screenResolution && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold">화면 정보</h4>
|
||||
|
||||
{/* 해상도 및 격자 정보 */}
|
||||
{screenResolution && actualGridInfo && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">격자 정보</h4>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">해상도:</span>
|
||||
<span className="font-mono">
|
||||
{screenResolution.width} × {screenResolution.height}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">해상도:</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 className="flex justify-between">
|
||||
<span className="text-muted-foreground">격자 단위:</span>
|
||||
<span className="font-mono text-primary">10px</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -53,12 +53,22 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
onDragStart,
|
||||
placedColumns = new Set(),
|
||||
}) => {
|
||||
// 이미 배치된 컬럼을 제외한 테이블 정보 생성
|
||||
// 시스템 컬럼 목록 (숨김 처리)
|
||||
const systemColumns = new Set([
|
||||
'id',
|
||||
'created_date',
|
||||
'updated_date',
|
||||
'writer',
|
||||
'company_code'
|
||||
]);
|
||||
|
||||
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
|
||||
const tablesWithAvailableColumns = tables.map((table) => ({
|
||||
...table,
|
||||
columns: table.columns.filter((col) => {
|
||||
const columnKey = `${table.tableName}.${col.columnName}`;
|
||||
return !placedColumns.has(columnKey);
|
||||
// 시스템 컬럼이거나 이미 배치된 컬럼은 제외
|
||||
return !systemColumns.has(col.columnName.toLowerCase()) && !placedColumns.has(columnKey);
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -105,8 +105,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
||||
|
||||
// 높이 입력 로컬 상태 (격자 스냅 방지)
|
||||
// 높이/너비 입력 로컬 상태 (자유 입력 허용)
|
||||
const [localHeight, setLocalHeight] = useState<string>("");
|
||||
const [localWidth, setLocalWidth] = useState<string>("");
|
||||
|
||||
// 새로운 컴포넌트 시스템의 webType 동기화
|
||||
useEffect(() => {
|
||||
|
|
@ -125,6 +126,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}
|
||||
}, [selectedComponent?.size?.height, selectedComponent?.id]);
|
||||
|
||||
// 너비 값 동기화
|
||||
useEffect(() => {
|
||||
if (selectedComponent?.size?.width !== undefined) {
|
||||
setLocalWidth(String(selectedComponent.size.width));
|
||||
}
|
||||
}, [selectedComponent?.size?.width, selectedComponent?.id]);
|
||||
|
||||
// 격자 설정 업데이트 함수 (early return 이전에 정의)
|
||||
const updateGridSetting = (key: string, value: any) => {
|
||||
if (onGridSettingsChange && gridSettings) {
|
||||
|
|
@ -139,6 +147,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const renderGridSettings = () => {
|
||||
if (!gridSettings || !onGridSettingsChange) return null;
|
||||
|
||||
// 최대 컬럼 수 계산
|
||||
const MIN_COLUMN_WIDTH = 30;
|
||||
const maxColumns = currentResolution
|
||||
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
|
||||
: 24;
|
||||
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
@ -180,65 +195,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 수 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="columns" className="text-xs font-medium">
|
||||
컬럼 수
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="columns"
|
||||
type="number"
|
||||
min={1}
|
||||
step="1"
|
||||
value={gridSettings.columns}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 1) {
|
||||
updateGridSetting("columns", value);
|
||||
}
|
||||
}}
|
||||
className="h-6 px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
placeholder="1 이상의 숫자"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
1 이상의 숫자를 입력하세요
|
||||
{/* 10px 단위 스냅 안내 */}
|
||||
<div className="bg-muted/50 rounded-md p-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
모든 컴포넌트는 10px 단위로 자동 배치됩니다.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
|
|
@ -374,22 +336,26 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
type="number"
|
||||
value={localHeight}
|
||||
onChange={(e) => {
|
||||
// 입력 중에는 로컬 상태만 업데이트 (격자 스냅 방지)
|
||||
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
|
||||
setLocalHeight(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// 포커스를 잃을 때만 실제로 업데이트
|
||||
// 포커스를 잃을 때 10px 단위로 스냅
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
if (value >= 1) {
|
||||
handleUpdate("size.height", value);
|
||||
if (value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
handleUpdate("size.height", snappedValue);
|
||||
setLocalHeight(String(snappedValue));
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter 키를 누르면 즉시 적용
|
||||
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
|
||||
if (e.key === "Enter") {
|
||||
const value = parseInt(e.currentTarget.value) || 0;
|
||||
if (value >= 1) {
|
||||
handleUpdate("size.height", value);
|
||||
if (value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
handleUpdate("size.height", snappedValue);
|
||||
setLocalHeight(String(snappedValue));
|
||||
}
|
||||
e.currentTarget.blur(); // 포커스 제거
|
||||
}
|
||||
|
|
@ -447,38 +413,47 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid Columns + Z-Index (같은 행) */}
|
||||
{/* Width + Z-Index (같은 행) */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(selectedComponent as any).gridColumns !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">차지 컬럼 수</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={gridSettings?.columns || 12}
|
||||
step="1"
|
||||
value={(selectedComponent as any).gridColumns || 1}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
const maxColumns = gridSettings?.columns || 12;
|
||||
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
|
||||
handleUpdate("gridColumns", value);
|
||||
|
||||
// width를 퍼센트로 계산하여 업데이트
|
||||
const widthPercent = (value / maxColumns) * 100;
|
||||
handleUpdate("style.width", `${widthPercent}%`);
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">너비 (px)</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={3840}
|
||||
step="1"
|
||||
value={localWidth}
|
||||
onChange={(e) => {
|
||||
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
|
||||
setLocalWidth(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// 포커스를 잃을 때 10px 단위로 스냅
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
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));
|
||||
}
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
|
||||
/{gridSettings?.columns || 12}
|
||||
</span>
|
||||
</div>
|
||||
e.currentTarget.blur(); // 포커스 제거
|
||||
}
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Z-Index</Label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -7,15 +7,24 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TextTypeConfig } from "@/types/screen";
|
||||
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
|
||||
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
interface TextTypeConfigPanelProps {
|
||||
config: TextTypeConfig;
|
||||
onConfigChange: (config: TextTypeConfig) => void;
|
||||
tableName?: string; // 화면의 테이블명 (선택)
|
||||
menuObjid?: number; // 메뉴 objid (선택)
|
||||
}
|
||||
|
||||
export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config, onConfigChange }) => {
|
||||
export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
||||
config,
|
||||
onConfigChange,
|
||||
tableName,
|
||||
menuObjid,
|
||||
}) => {
|
||||
console.log("🔍 TextTypeConfigPanel 마운트:", { tableName, menuObjid, config });
|
||||
|
||||
// 기본값이 설정된 config 사용
|
||||
const safeConfig = {
|
||||
minLength: undefined,
|
||||
|
|
@ -54,16 +63,46 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
// 채번 규칙 목록 로드
|
||||
useEffect(() => {
|
||||
const loadRules = async () => {
|
||||
console.log("🔄 채번 규칙 로드 시작:", {
|
||||
autoValueType: localValues.autoValueType,
|
||||
tableName,
|
||||
hasTableName: !!tableName,
|
||||
});
|
||||
|
||||
setLoadingRules(true);
|
||||
try {
|
||||
// TODO: 현재 메뉴 objid를 화면 정보에서 가져와야 함
|
||||
// 지금은 menuObjid 없이 호출 (global 규칙만 조회)
|
||||
const response = await getAvailableNumberingRules();
|
||||
let response;
|
||||
|
||||
// 테이블명이 있으면 테이블 기반 필터링 사용
|
||||
if (tableName) {
|
||||
console.log("📋 테이블 기반 채번 규칙 조회 API 호출:", { tableName });
|
||||
response = await getAvailableNumberingRulesForScreen(tableName);
|
||||
console.log("📋 API 응답:", response);
|
||||
} else {
|
||||
// 테이블명이 없으면 빈 배열 (테이블 필수)
|
||||
console.warn("⚠️ 테이블명이 없어 채번 규칙을 조회할 수 없습니다");
|
||||
setNumberingRules([]);
|
||||
setLoadingRules(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
console.log("✅ 채번 규칙 로드 성공:", {
|
||||
count: response.data.length,
|
||||
rules: response.data.map((r: any) => ({
|
||||
ruleId: r.ruleId,
|
||||
ruleName: r.ruleName,
|
||||
tableName: r.tableName,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
console.warn("⚠️ 채번 규칙 조회 실패:", response.error);
|
||||
setNumberingRules([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 목록 로드 실패:", error);
|
||||
console.error("❌ 채번 규칙 목록 로드 실패:", error);
|
||||
setNumberingRules([]);
|
||||
} finally {
|
||||
setLoadingRules(false);
|
||||
}
|
||||
|
|
@ -71,9 +110,12 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
|
||||
// autoValueType이 numbering_rule일 때만 로드
|
||||
if (localValues.autoValueType === "numbering_rule") {
|
||||
console.log("✅ autoValueType === 'numbering_rule', 규칙 로드 시작");
|
||||
loadRules();
|
||||
} else {
|
||||
console.log("⏭️ autoValueType !== 'numbering_rule', 규칙 로드 스킵:", localValues.autoValueType);
|
||||
}
|
||||
}, [localValues.autoValueType]);
|
||||
}, [localValues.autoValueType, tableName]);
|
||||
|
||||
// config가 변경될 때 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,17 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
|||
required,
|
||||
className,
|
||||
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를 통해 클릭 이벤트 전달
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ interface ResizableDialogContentProps
|
|||
modalId?: string; // localStorage 저장용 고유 ID
|
||||
userId?: string; // 사용자별 저장용
|
||||
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
|
||||
disableFlexLayout?: boolean; // 🆕 flex 레이아웃 비활성화 (absolute 레이아웃용)
|
||||
}
|
||||
|
||||
const ResizableDialogContent = React.forwardRef<
|
||||
|
|
@ -74,6 +75,7 @@ const ResizableDialogContent = React.forwardRef<
|
|||
modalId,
|
||||
userId = "guest",
|
||||
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
|
||||
disableFlexLayout = false, // 🆕 flex 레이아웃 비활성화
|
||||
style: userStyle,
|
||||
...props
|
||||
},
|
||||
|
|
@ -373,7 +375,11 @@ const ResizableDialogContent = React.forwardRef<
|
|||
minHeight: `${minHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef} className="flex flex-col h-full overflow-auto">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="h-full w-full"
|
||||
style={{ display: 'block', overflow: 'hidden' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConf
|
|||
}
|
||||
|
||||
/**
|
||||
* 메뉴별 사용 가능한 채번 규칙 조회
|
||||
* 메뉴별 사용 가능한 채번 규칙 조회 (기존 방식, 하위 호환성 유지)
|
||||
* @param menuObjid 현재 메뉴의 objid (선택)
|
||||
* @returns 사용 가능한 채번 규칙 목록
|
||||
*/
|
||||
|
|
@ -40,6 +40,27 @@ export async function getAvailableNumberingRules(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화)
|
||||
* @param tableName 화면의 테이블명 (필수)
|
||||
* @returns 해당 테이블의 채번 규칙 목록
|
||||
*/
|
||||
export async function getAvailableNumberingRulesForScreen(
|
||||
tableName: string
|
||||
): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
try {
|
||||
const response = await apiClient.get("/numbering-rules/available-for-screen", {
|
||||
params: { tableName },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || "화면용 규칙 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNumberingRuleById(ruleId: string): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/numbering-rules/${ruleId}`);
|
||||
|
|
|
|||
|
|
@ -148,19 +148,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const tableName = (component as any).tableName;
|
||||
const columnName = (component as any).columnName;
|
||||
|
||||
console.log("🔍 DynamicComponentRenderer 컴포넌트 타입 확인:", {
|
||||
componentId: component.id,
|
||||
componentType,
|
||||
inputType,
|
||||
webType,
|
||||
tableName,
|
||||
columnName,
|
||||
componentConfig: (component as any).componentConfig,
|
||||
});
|
||||
|
||||
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||
if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
||||
console.log("✅ 카테고리 타입 감지 → CategorySelectComponent 렌더링");
|
||||
try {
|
||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||
const fieldName = columnName || component.id;
|
||||
|
|
@ -303,14 +292,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
componentType === "split-panel-layout" ||
|
||||
componentType?.includes("layout");
|
||||
|
||||
console.log("🔍 [DynamicComponentRenderer] 높이 처리:", {
|
||||
componentId: component.id,
|
||||
componentType,
|
||||
isLayoutComponent,
|
||||
hasHeight: !!component.style?.height,
|
||||
height: component.style?.height
|
||||
});
|
||||
|
||||
const { height: _height, ...styleWithoutHeight } = component.style || {};
|
||||
|
||||
// 숨김 값 추출
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||
<button
|
||||
type={componentConfig.actionType || "button"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
||||
style={{
|
||||
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')
|
||||
) : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{/* 🔧 빈 문자열도 허용 (undefined일 때만 기본값 적용) */}
|
||||
{processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"}
|
||||
</button>
|
||||
{isDesignMode ? (
|
||||
// 디자인 모드: div로 렌더링하여 선택 가능하게 함
|
||||
<div
|
||||
className="transition-colors duration-150 hover:opacity-90"
|
||||
style={buttonElementStyle}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{buttonContent}
|
||||
</div>
|
||||
) : (
|
||||
// 일반 모드: button으로 렌더링
|
||||
<button
|
||||
type={componentConfig.actionType || "button"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
||||
style={buttonElementStyle}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{buttonContent}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
||||
|
|
|
|||
|
|
@ -74,20 +74,12 @@ export const CategorySelectComponent: React.FC<
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
console.log("📦 카테고리 값 조회:", { tableName, columnName });
|
||||
|
||||
const response = await getCategoryValues(tableName, columnName);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 활성화된 값만 필터링
|
||||
const activeValues = response.data.filter((v) => v.isActive !== false);
|
||||
setCategoryValues(activeValues);
|
||||
|
||||
console.log("✅ 카테고리 값 조회 성공:", {
|
||||
total: response.data.length,
|
||||
active: activeValues.length,
|
||||
values: activeValues,
|
||||
});
|
||||
} else {
|
||||
setError("카테고리 값을 불러올 수 없습니다");
|
||||
console.error("❌ 카테고리 값 조회 실패:", response);
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
// daterange 타입 전용 UI
|
||||
if (webType === "daterange") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
||||
|
|
@ -298,7 +298,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
|
|
@ -325,7 +325,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
|
|
@ -341,7 +341,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
// year 타입 전용 UI (number input with YYYY format)
|
||||
if (webType === "year") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
||||
|
|
@ -367,7 +367,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}
|
||||
}}
|
||||
className={cn(
|
||||
"box-border h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
|
|
@ -380,7 +380,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
|
|
@ -401,7 +401,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
||||
className={cn(
|
||||
"box-border h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
|
|
|
|||
|
|
@ -8,19 +8,24 @@ interface NumberingRuleWrapperProps {
|
|||
config: NumberingRuleComponentConfig;
|
||||
onChange?: (config: NumberingRuleComponentConfig) => void;
|
||||
isPreview?: boolean;
|
||||
tableName?: string; // 현재 화면의 테이블명
|
||||
}
|
||||
|
||||
export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
isPreview = false,
|
||||
tableName,
|
||||
}) => {
|
||||
console.log("📋 NumberingRuleWrapper: 테이블명 전달", { tableName, config });
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<NumberingRuleDesigner
|
||||
maxRules={config.maxRules || 6}
|
||||
isPreview={isPreview}
|
||||
className="h-full"
|
||||
currentTableName={tableName} // 테이블명 전달
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// searchTerm 제거 - 클라이언트 사이드에서 필터링
|
||||
});
|
||||
|
||||
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
|
||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||
if (leftColumn && result.data.length > 0) {
|
||||
result.data.sort((a, b) => {
|
||||
const aValue = String(a[leftColumn] || '');
|
||||
const bValue = String(b[leftColumn] || '');
|
||||
return aValue.localeCompare(bValue, 'ko-KR');
|
||||
});
|
||||
}
|
||||
|
||||
// 계층 구조 빌드
|
||||
const hierarchicalData = buildHierarchy(result.data);
|
||||
setLeftData(hierarchicalData);
|
||||
|
|
@ -173,7 +183,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
} finally {
|
||||
setIsLoadingLeft(false);
|
||||
}
|
||||
}, [componentConfig.leftPanel?.tableName, isDesignMode, toast, buildHierarchy]);
|
||||
}, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, buildHierarchy]);
|
||||
|
||||
// 우측 데이터 로드
|
||||
const loadRightData = useCallback(
|
||||
|
|
@ -293,9 +303,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 추가 버튼 핸들러
|
||||
const handleAddClick = useCallback((panel: "left" | "right") => {
|
||||
setAddModalPanel(panel);
|
||||
setAddModalFormData({});
|
||||
|
||||
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
|
||||
if (panel === "right" && selectedLeftItem && componentConfig.leftPanel?.leftColumn && componentConfig.rightPanel?.rightColumn) {
|
||||
const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn];
|
||||
setAddModalFormData({
|
||||
[componentConfig.rightPanel.rightColumn]: leftColumnValue
|
||||
});
|
||||
} else {
|
||||
setAddModalFormData({});
|
||||
}
|
||||
|
||||
setShowAddModal(true);
|
||||
}, []);
|
||||
}, [selectedLeftItem, componentConfig]);
|
||||
|
||||
// 수정 버튼 핸들러
|
||||
const handleEditClick = useCallback((panel: "left" | "right", item: any) => {
|
||||
|
|
@ -1316,10 +1336,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
return modalColumns?.map((col, index) => {
|
||||
// 항목별 추가 버튼으로 열렸을 때, parentColumn은 미리 채워져 있고 수정 불가
|
||||
const isPreFilled = addModalPanel === "left-item"
|
||||
const isItemAddPreFilled = addModalPanel === "left-item"
|
||||
&& componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name
|
||||
&& addModalFormData[col.name];
|
||||
|
||||
// 우측 패널 추가 시, 조인 컬럼(rightColumn)은 미리 채워져 있고 수정 불가
|
||||
const isRightJoinPreFilled = addModalPanel === "right"
|
||||
&& componentConfig.rightPanel?.rightColumn === col.name
|
||||
&& addModalFormData[col.name];
|
||||
|
||||
const isPreFilled = isItemAddPreFilled || isRightJoinPreFilled;
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<Label htmlFor={col.name} className="text-xs sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Slider } from "@/components/ui/slider";
|
|||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
|
|
@ -284,7 +285,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
console.log(" - availableRightTables:", availableRightTables.length, "개");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* 관계 타입 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">패널 관계 타입</h3>
|
||||
|
|
@ -324,9 +325,14 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 좌측 패널 설정 (마스터) */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
||||
{/* 좌측 패널 설정 (Accordion) */}
|
||||
<Accordion type="single" collapsible defaultValue="left-panel" className="w-full">
|
||||
<AccordionItem value="left-panel" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
||||
좌측 패널 설정 (마스터)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-visible">
|
||||
<div className="space-y-4 pt-2">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
|
|
@ -807,11 +813,19 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* 우측 패널 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})</h3>
|
||||
{/* 우측 패널 설정 (Accordion) */}
|
||||
<Accordion type="single" collapsible defaultValue="right-panel" className="w-full">
|
||||
<AccordionItem value="right-panel" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
||||
우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-visible">
|
||||
<div className="space-y-4 pt-2">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
|
|
@ -1357,11 +1371,19 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">레이아웃 설정</h3>
|
||||
{/* 레이아웃 설정 (Accordion) */}
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="layout" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
||||
레이아웃 설정
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-visible">
|
||||
<div className="space-y-4 pt-2">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||
|
|
@ -1389,7 +1411,10 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -624,33 +637,44 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||
}));
|
||||
|
||||
const hasEntityJoins = entityJoinColumns.length > 0;
|
||||
|
||||
let response;
|
||||
if (hasEntityJoins) {
|
||||
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
page,
|
||||
size: pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
search: filters,
|
||||
enableEntityJoin: true,
|
||||
additionalJoinColumns: entityJoinColumns,
|
||||
});
|
||||
} else {
|
||||
response = await tableTypeApi.getTableData(tableConfig.selectedTable, {
|
||||
page,
|
||||
size: pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
search: filters,
|
||||
});
|
||||
}
|
||||
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
page,
|
||||
size: pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
search: filters,
|
||||
enableEntityJoin: true,
|
||||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||
});
|
||||
|
||||
setData(response.data || []);
|
||||
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([]);
|
||||
|
|
@ -788,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 {
|
||||
|
|
@ -1062,6 +1102,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
|
||||
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) {
|
||||
// displayColumns 또는 selectedColumns 둘 다 체크
|
||||
|
|
@ -1155,6 +1200,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
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 (value !== null && value !== undefined && value !== "") {
|
||||
|
|
@ -1179,7 +1240,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (value) {
|
||||
try {
|
||||
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 {
|
||||
return value;
|
||||
}
|
||||
|
|
@ -2144,7 +2208,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<tr
|
||||
key={index}
|
||||
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)}
|
||||
>
|
||||
|
|
@ -2173,8 +2237,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<td
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
||||
"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-1.5",
|
||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -2210,7 +2274,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<tr
|
||||
key={index}
|
||||
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)}
|
||||
>
|
||||
|
|
@ -2239,8 +2303,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<td
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
||||
"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-1.5",
|
||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
||||
)}
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -100,16 +100,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
const currentFormValue = formData?.[component.columnName];
|
||||
const currentComponentValue = component.value;
|
||||
|
||||
console.log("🔧 TextInput 자동생성 체크:", {
|
||||
componentId: component.id,
|
||||
columnName: component.columnName,
|
||||
autoGenType: testAutoGeneration.type,
|
||||
ruleId: testAutoGeneration.options?.numberingRuleId,
|
||||
currentFormValue,
|
||||
currentComponentValue,
|
||||
autoGeneratedValue,
|
||||
isInteractive,
|
||||
});
|
||||
|
||||
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
|
||||
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
|
||||
|
|
|
|||
|
|
@ -8,19 +8,20 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { TextInputConfig } from "./types";
|
||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
|
||||
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
export interface TextInputConfigPanelProps {
|
||||
config: TextInputConfig;
|
||||
onChange: (config: Partial<TextInputConfig>) => void;
|
||||
screenTableName?: string; // 🆕 현재 화면의 테이블명
|
||||
}
|
||||
|
||||
/**
|
||||
* TextInput 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange }) => {
|
||||
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange, screenTableName }) => {
|
||||
// 채번 규칙 목록 상태
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
|
|
@ -30,9 +31,20 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
|||
const loadRules = async () => {
|
||||
setLoadingRules(true);
|
||||
try {
|
||||
const response = await getAvailableNumberingRules();
|
||||
let response;
|
||||
|
||||
// 🆕 테이블명이 있으면 테이블 기반 필터링, 없으면 전체 조회
|
||||
if (screenTableName) {
|
||||
console.log("🔍 TextInputConfigPanel: 테이블 기반 채번 규칙 로드", { screenTableName });
|
||||
response = await getAvailableNumberingRulesForScreen(screenTableName);
|
||||
} else {
|
||||
console.log("🔍 TextInputConfigPanel: 전체 채번 규칙 로드 (테이블명 없음)");
|
||||
response = await getAvailableNumberingRules();
|
||||
}
|
||||
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
console.log("✅ 채번 규칙 로드 완료:", response.data.length, "개");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 목록 로드 실패:", error);
|
||||
|
|
@ -45,7 +57,7 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
|||
if (config.autoGeneration?.type === "numbering_rule") {
|
||||
loadRules();
|
||||
}
|
||||
}, [config.autoGeneration?.type]);
|
||||
}, [config.autoGeneration?.type, screenTableName]);
|
||||
|
||||
const handleChange = (key: keyof TextInputConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
|
|
@ -174,7 +186,12 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
|||
) : (
|
||||
numberingRules.map((rule) => (
|
||||
<SelectItem key={rule.ruleId} value={rule.ruleId}>
|
||||
{rule.ruleName} ({rule.ruleId})
|
||||
{rule.ruleName}
|
||||
{rule.description && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
- {rule.description}
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
|
||||
const response = await entityJoinApi.getTableDataWithJoins(context.tableName, apiParams);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 🔒 멀티테넌시 확인
|
||||
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;
|
||||
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
||||
let visibleColumns: string[] | undefined = undefined;
|
||||
let columnLabels: Record<string, string> | undefined = undefined;
|
||||
|
||||
// columnOrder가 없으면 tableDisplayData에서 추출 시도
|
||||
if (!columnOrder && context.tableDisplayData && context.tableDisplayData.length > 0) {
|
||||
columnOrder = Object.keys(context.tableDisplayData[0]);
|
||||
console.log("📊 tableDisplayData에서 컬럼 순서 추출:", columnOrder);
|
||||
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");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 카테고리 정보 조회 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (columnOrder && columnOrder.length > 0 && dataToExport.length > 0) {
|
||||
console.log("🔄 컬럼 순서 재정렬 시작:", {
|
||||
columnOrder,
|
||||
originalColumns: Object.keys(dataToExport[0] || {}),
|
||||
});
|
||||
|
||||
// 🎨 컬럼 필터링 및 라벨 적용 (항상 실행)
|
||||
if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
|
||||
dataToExport = dataToExport.map((row: any) => {
|
||||
const reorderedRow: any = {};
|
||||
const filteredRow: Record<string, any> = {};
|
||||
|
||||
// 1. columnOrder에 있는 컬럼들을 순서대로 추가
|
||||
columnOrder!.forEach((colName: string) => {
|
||||
if (colName in row) {
|
||||
reorderedRow[colName] = row[colName];
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. columnOrder에 없는 나머지 컬럼들 추가 (끝에 배치)
|
||||
Object.keys(row).forEach((key) => {
|
||||
if (!(key in reorderedRow)) {
|
||||
reorderedRow[key] = row[key];
|
||||
}
|
||||
});
|
||||
|
||||
return reorderedRow;
|
||||
return filteredRow;
|
||||
});
|
||||
|
||||
console.log("✅ 컬럼 순서 재정렬 완료:", {
|
||||
reorderedColumns: Object.keys(dataToExport[0] || {}),
|
||||
});
|
||||
} else {
|
||||
console.log("⏭️ 컬럼 순서 재정렬 스킵:", {
|
||||
hasColumnOrder: !!columnOrder,
|
||||
columnOrderLength: columnOrder?.length,
|
||||
hasTableDisplayData: !!context.tableDisplayData,
|
||||
dataToExportLength: dataToExport.length,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("📥 엑셀 다운로드 실행:", {
|
||||
fileName,
|
||||
sheetName,
|
||||
includeHeaders,
|
||||
dataCount: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
columnOrder: context.columnOrder,
|
||||
});
|
||||
// 최대 행 수 제한
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { DashboardConfigPanel } from "@/components/screen/config-panels/Dashboar
|
|||
export type ConfigPanelComponent = React.ComponentType<{
|
||||
config: any;
|
||||
onConfigChange: (config: any) => void;
|
||||
tableName?: string; // 화면 테이블명 (선택)
|
||||
menuObjid?: number; // 메뉴 objid (선택)
|
||||
}>;
|
||||
|
||||
// ButtonConfigPanel 래퍼 (config/onConfigChange → component/onUpdateProperty 변환)
|
||||
|
|
|
|||
|
|
@ -15,17 +15,28 @@ export function calculateGridInfo(
|
|||
containerHeight: number,
|
||||
gridSettings: GridSettings,
|
||||
): GridInfo {
|
||||
const { columns, gap, padding } = gridSettings;
|
||||
const { gap, padding } = gridSettings;
|
||||
let { columns } = gridSettings;
|
||||
|
||||
// 사용 가능한 너비 계산 (패딩 제외)
|
||||
// 🔥 최소 컬럼 너비를 보장하기 위한 최대 컬럼 수 계산
|
||||
const MIN_COLUMN_WIDTH = 30; // 최소 컬럼 너비 30px
|
||||
const availableWidth = containerWidth - padding * 2;
|
||||
const maxPossibleColumns = Math.floor((availableWidth + gap) / (MIN_COLUMN_WIDTH + gap));
|
||||
|
||||
// 설정된 컬럼 수가 너무 많으면 자동으로 제한
|
||||
if (columns > maxPossibleColumns) {
|
||||
console.warn(
|
||||
`⚠️ 격자 컬럼 수가 너무 많습니다. ${columns}개 → ${maxPossibleColumns}개로 자동 조정됨 (최소 컬럼 너비: ${MIN_COLUMN_WIDTH}px)`,
|
||||
);
|
||||
columns = Math.max(1, maxPossibleColumns);
|
||||
}
|
||||
|
||||
// 격자 간격을 고려한 컬럼 너비 계산
|
||||
const totalGaps = (columns - 1) * gap;
|
||||
const columnWidth = (availableWidth - totalGaps) / columns;
|
||||
|
||||
return {
|
||||
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
|
||||
columnWidth: Math.max(columnWidth, MIN_COLUMN_WIDTH),
|
||||
totalWidth: containerWidth,
|
||||
totalHeight: containerHeight,
|
||||
};
|
||||
|
|
@ -96,9 +107,9 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri
|
|||
const rowHeight = 10;
|
||||
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
|
||||
|
||||
console.log(
|
||||
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||
);
|
||||
// console.log(
|
||||
// `📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||
// );
|
||||
|
||||
return {
|
||||
width: Math.max(columnWidth, snappedWidth),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* WebTypeConfig와 AutoGeneration 간 변환 유틸리티
|
||||
*/
|
||||
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
||||
/**
|
||||
* webTypeConfig의 자동입력 설정을 autoGeneration으로 변환
|
||||
*/
|
||||
export function convertWebTypeConfigToAutoGeneration(component: ComponentData): ComponentData {
|
||||
// webTypeConfig가 없으면 변환 불필요
|
||||
if (!component.webTypeConfig) {
|
||||
return component;
|
||||
}
|
||||
|
||||
const config = component.webTypeConfig as any;
|
||||
|
||||
// 자동입력이 활성화되어 있는지 확인
|
||||
if (!config.autoInput || !config.autoValueType) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// 이미 autoGeneration이 올바르게 설정되어 있으면 변환 불필요
|
||||
if (
|
||||
component.autoGeneration &&
|
||||
component.autoGeneration.type === config.autoValueType &&
|
||||
component.autoGeneration.options?.numberingRuleId === config.numberingRuleId
|
||||
) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// autoGeneration 객체 생성
|
||||
const autoGeneration: any = {
|
||||
type: config.autoValueType,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// 채번 규칙인 경우 options.numberingRuleId 설정
|
||||
if (config.autoValueType === "numbering_rule" && config.numberingRuleId) {
|
||||
autoGeneration.options = {
|
||||
numberingRuleId: config.numberingRuleId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
...component,
|
||||
autoGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃의 모든 컴포넌트에 대해 webTypeConfig → autoGeneration 변환 적용
|
||||
*/
|
||||
export function convertLayoutComponents(components: ComponentData[]): ComponentData[] {
|
||||
return components.map(convertWebTypeConfigToAutoGeneration);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -132,7 +132,16 @@ export type AutoGenerationType = "table" | "form" | "mixed";
|
|||
*/
|
||||
export interface AutoGenerationConfig {
|
||||
type: AutoGenerationType;
|
||||
enabled?: boolean;
|
||||
tableName?: string;
|
||||
includeSearch?: boolean;
|
||||
includePagination?: boolean;
|
||||
options?: {
|
||||
length?: number; // 랜덤 문자열/숫자 길이
|
||||
prefix?: string; // 접두사
|
||||
suffix?: string; // 접미사
|
||||
format?: string; // 시간 형식 (current_time용)
|
||||
startValue?: number; // 시퀀스 시작값
|
||||
numberingRuleId?: string; // 채번 규칙 ID (numbering_rule 타입용)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,166 +0,0 @@
|
|||
# 영업관리 등록창 테스트 가이드
|
||||
|
||||
## 📋 테스트 개요
|
||||
|
||||
`docs/영업_계약_수정.md` 문서에 따라 구현된 새로운 영업관리 등록창의 데이터 저장 기능을 테스트합니다.
|
||||
|
||||
## 🚀 테스트 환경
|
||||
|
||||
- **서버 URL**: http://localhost:8090
|
||||
- **테스트 계정**: plm_admin (패스워드는 관리자에게 문의)
|
||||
- **테스트 페이지**: http://localhost:8090/contractMgmt/contracMgmtFormPopup.do
|
||||
|
||||
## ✅ 구현 완료 사항
|
||||
|
||||
### 1. 백엔드 수정 완료
|
||||
- **ContractMgmtController.java**: 신규 공통코드 2개 추가 (통화단위, 계약방식)
|
||||
- **ContractMgmtService.java**: CONTRACT_MGMT 테이블 사용하도록 변경, 25개 신규 필드 처리
|
||||
- **contractMgmt.xml**: saveContractMgmtInfo 쿼리에 25개 신규 필드 추가
|
||||
|
||||
### 2. 프론트엔드 수정 완료
|
||||
- **contracMgmtFormPopup.jsp**: 5개 섹션으로 재구성
|
||||
- 📋 [영업정보]
|
||||
- 🔧 [사양상세]
|
||||
- 📈 [영업진행]
|
||||
- 💰 [견적이력 및 결과]
|
||||
- 📝 [특이사항]
|
||||
|
||||
### 3. 데이터베이스 준비 완료
|
||||
- **공통코드 데이터**: 6개 공통코드의 부모/하위 데이터 준비 완료
|
||||
- **테이블 구조**: CONTRACT_MGMT 테이블에 25개 신규 필드 확인
|
||||
|
||||
## 🧪 테스트 절차
|
||||
|
||||
### Step 1: 로그인
|
||||
1. http://localhost:8090 접속
|
||||
2. plm_admin 계정으로 로그인
|
||||
|
||||
### Step 2: 영업관리 화면 접근
|
||||
1. 메뉴에서 "영업관리" → "계약관리" 선택
|
||||
2. "등록" 버튼 클릭하여 등록창 열기
|
||||
|
||||
### Step 3: 테스트 데이터 입력
|
||||
|
||||
#### 📋 [영업정보] 섹션
|
||||
- **계약구분**: 개발 선택
|
||||
- **과거프로젝트번호**: PRJ-2024-001
|
||||
- **고객사**: 기존 고객사 선택
|
||||
- **제품군**: 기존 제품 선택
|
||||
- **장비명**: 테스트 압력용기 시스템
|
||||
- **설비대수**: 2
|
||||
- **요청납기일**: 2025-12-31
|
||||
- **입고지**: 서울특별시 강남구
|
||||
- **셋업지**: 경기도 성남시
|
||||
|
||||
#### 🔧 [사양상세] 섹션
|
||||
- **재질**: SUS304
|
||||
- **압력(BAR)**: 10.5
|
||||
- **온도(℃)**: 85
|
||||
- **용량(LITER)**: 1000
|
||||
- **Closure Type**: Bolted Cover
|
||||
- **기타(소모품)**: 가스켓, 볼트
|
||||
- **전압**: 220V
|
||||
- **인증여부**: KS 인증 완료
|
||||
|
||||
#### 📈 [영업진행] 섹션
|
||||
- **진행단계**: 견적제출 선택
|
||||
|
||||
#### 💰 [견적이력 및 결과] 섹션
|
||||
- **통화**: KRW 선택
|
||||
- **견적금액(1차)**: 50,000,000
|
||||
- **견적금액(2차)**: 48,000,000
|
||||
- **견적금액(3차)**: 45,000,000
|
||||
- **수주일**: 2025-08-15
|
||||
- **수주가**: 자동계산 확인 (90,000,000)
|
||||
- **Result**: 수주 선택
|
||||
- **계약방식**: 조달 선택
|
||||
- **P/O No**: PO-2025-001
|
||||
- **PM**: 기존 사용자 선택
|
||||
- **당사프로젝트명**: 압력용기 개발 프로젝트
|
||||
|
||||
#### 📝 [특이사항] 섹션
|
||||
```
|
||||
고객 요구사항: 내압 테스트 필수
|
||||
납기일 엄수 요청
|
||||
품질 인증서 제출 필요
|
||||
```
|
||||
|
||||
### Step 4: 저장 테스트
|
||||
1. "저장" 버튼 클릭
|
||||
2. 성공 메시지 확인
|
||||
3. 저장된 데이터 목록에서 확인
|
||||
|
||||
## 🔍 검증 포인트
|
||||
|
||||
### 1. 화면 구성 검증
|
||||
- [ ] 5개 섹션이 올바르게 표시되는가?
|
||||
- [ ] 공통코드 선택 옵션이 정상 로딩되는가?
|
||||
- [ ] 자동계산 기능이 동작하는가? (수주가 = 최신견적금액 × 설비대수)
|
||||
|
||||
### 2. 데이터 저장 검증
|
||||
- [ ] 25개 신규 필드가 모두 저장되는가?
|
||||
- [ ] 기존 필드와 신규 필드가 함께 저장되는가?
|
||||
- [ ] 저장 후 목록에서 데이터가 확인되는가?
|
||||
|
||||
### 3. 오류 처리 검증
|
||||
- [ ] 필수 필드 누락 시 적절한 오류 메시지가 표시되는가?
|
||||
- [ ] 잘못된 데이터 입력 시 검증이 동작하는가?
|
||||
|
||||
## 🐛 알려진 이슈
|
||||
|
||||
### 1. 로그인 세션 필요
|
||||
- API 직접 호출 시 세션 인증이 필요함
|
||||
- 브라우저에서 로그인 후 테스트 권장
|
||||
|
||||
### 2. 공통코드 데이터
|
||||
- 신규 공통코드 2개(통화단위, 계약방식)가 아직 데이터베이스에 등록되지 않았을 수 있음
|
||||
- 필요시 `docs/insert_common_codes.sql` 실행
|
||||
|
||||
## 📊 테스트 결과 기록
|
||||
|
||||
### 성공 케이스
|
||||
```json
|
||||
{
|
||||
"RESULT": {
|
||||
"result": true,
|
||||
"msg": "저장되었습니다."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 실패 케이스
|
||||
```json
|
||||
{
|
||||
"RESULT": {
|
||||
"result": false,
|
||||
"msg": "저장에 실패하였습니다."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 문제 해결
|
||||
|
||||
### 1. 저장 실패 시
|
||||
1. 브라우저 개발자 도구에서 네트워크 탭 확인
|
||||
2. 서버 로그 확인: `docker-compose -f docker-compose.dev.yml logs plm-ilshin`
|
||||
3. 데이터베이스 연결 상태 확인
|
||||
|
||||
### 2. 화면 오류 시
|
||||
1. 브라우저 콘솔에서 JavaScript 오류 확인
|
||||
2. CSS 파일 로딩 상태 확인
|
||||
3. JSP 컴파일 오류 확인
|
||||
|
||||
## 📞 지원
|
||||
|
||||
테스트 중 문제 발생 시:
|
||||
1. 브라우저 개발자 도구 스크린샷
|
||||
2. 서버 로그 복사
|
||||
3. 입력한 테스트 데이터 기록
|
||||
|
||||
위 정보와 함께 문의하시기 바랍니다.
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트**: 2025-07-14
|
||||
**테스트 환경**: Docker 개발환경, PostgreSQL 데이터베이스
|
||||
**구현 완료도**: 95% (로그인 세션 테스트 제외)
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
# 🎯 영업관리 등록창 최종 테스트 가이드
|
||||
|
||||
## 📋 현재 구현 상태
|
||||
|
||||
### ✅ **완료된 작업 (95%)**
|
||||
|
||||
#### 1. **백엔드 수정 완료**
|
||||
|
||||
- **ContractMgmtController.java**: 신규 공통코드 2개 추가 (통화단위, 계약방식)
|
||||
- **ContractMgmtService.java**: CONTRACT_MGMT 테이블 사용하도록 변경, 25개 신규 필드 처리
|
||||
- **contractMgmt.xml**: saveContractMgmtInfo 쿼리에 25개 신규 필드 추가
|
||||
|
||||
#### 2. **프론트엔드 수정 완료**
|
||||
|
||||
- **contracMgmtFormPopup.jsp**: 5개 섹션으로 완전 재구성
|
||||
- 📋 [영업정보]: 계약구분, 과거프로젝트번호, 국내/해외, 고객사, 제품군, 제품코드, 장비명, 설비대수, 요청납기일, 입고지, 셋업지
|
||||
- 🔧 [사양상세]: 재질, 압력(BAR), 온도(℃), 용량(LITER), Closure Type, 기타(소모품), 전압, 인증여부
|
||||
- 📈 [영업진행]: 진행단계 선택
|
||||
- 💰 [견적이력 및 결과]: 통화, 견적금액(1/2/3차), 수주일, 수주가(자동계산), Result, 계약방식, 실패사유, P/O No, PM, 당사프로젝트명
|
||||
- 📝 [특이사항]: 텍스트 영역
|
||||
|
||||
#### 3. **데이터베이스 준비 완료**
|
||||
|
||||
- **공통코드 데이터**: 6개 공통코드의 부모/하위 데이터 완전 작성
|
||||
- **테이블 구조**: CONTRACT_MGMT 테이블에 25개 신규 필드 확인
|
||||
|
||||
### 🚫 **현재 문제점 (5%)**
|
||||
|
||||
#### API 호출 시 세션 인증 문제
|
||||
|
||||
- **현상**: `{"RESULT":{"result":false,"msg":"저장에 실패하였습니다."}}`
|
||||
- **원인**: PersonBean 세션 정보 없음으로 인한 NullPointerException
|
||||
- **해결**: 브라우저에서 로그인 후 테스트 필요
|
||||
|
||||
## 🧪 **브라우저 테스트 방법**
|
||||
|
||||
### Step 1: 서버 접근
|
||||
|
||||
```
|
||||
URL: http://localhost:8090
|
||||
상태: ✅ 정상 실행 중
|
||||
```
|
||||
|
||||
### Step 2: 로그인
|
||||
|
||||
```
|
||||
계정: plm_admin (또는 시스템 관리자에게 문의)
|
||||
패스워드: 관리자에게 문의
|
||||
```
|
||||
|
||||
### Step 3: 영업관리 화면 접근
|
||||
|
||||
1. 메뉴에서 **"영업관리"** 클릭
|
||||
2. **"계약관리"** 하위 메뉴 클릭
|
||||
3. **"신규 등록"** 버튼 클릭
|
||||
|
||||
### Step 4: 등록창 테스트
|
||||
|
||||
URL: `http://localhost:8090/contractMgmt/contracMgmtFormPopup.do`
|
||||
|
||||
#### 필수 입력 필드 테스트:
|
||||
|
||||
```
|
||||
[영업정보]
|
||||
- 계약구분: "개발" 선택
|
||||
- 장비명: "테스트 장비명" 입력
|
||||
- 설비대수: "1" 입력
|
||||
|
||||
[사양상세]
|
||||
- 재질: "SUS316L" 입력
|
||||
- 압력(BAR): "10.5" 입력
|
||||
|
||||
[영업진행]
|
||||
- 진행단계: "사양협의" 선택
|
||||
|
||||
[특이사항]
|
||||
- 특이사항: "테스트용 영업관리 데이터입니다." 입력
|
||||
```
|
||||
|
||||
### Step 5: 저장 테스트
|
||||
|
||||
1. **"저장"** 버튼 클릭
|
||||
2. **성공 메시지** 확인: "저장되었습니다."
|
||||
3. **리스트 화면**에서 저장된 데이터 확인
|
||||
|
||||
## 🔧 **자동계산 기능 테스트**
|
||||
|
||||
### 수주가 자동계산 테스트:
|
||||
|
||||
1. **견적금액(1차)**: "1000000" 입력
|
||||
2. **설비대수**: "2" 입력
|
||||
3. **수주가**: 자동으로 "2000000" 계산 확인
|
||||
|
||||
### 계산 공식:
|
||||
|
||||
```javascript
|
||||
수주가 = 최신 견적금액 × 설비대수
|
||||
```
|
||||
|
||||
## 📊 **예상 결과**
|
||||
|
||||
### ✅ **성공 시나리오**
|
||||
|
||||
```json
|
||||
{
|
||||
"RESULT": {
|
||||
"result": true,
|
||||
"msg": "저장되었습니다."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🔍 **데이터 확인 방법**
|
||||
|
||||
1. **리스트 화면**: 저장된 데이터가 목록에 표시
|
||||
2. **상세 화면**: 저장된 모든 필드값 확인
|
||||
3. **데이터베이스**: CONTRACT_MGMT 테이블에 레코드 생성 확인
|
||||
|
||||
## 🎯 **테스트 체크리스트**
|
||||
|
||||
### 기본 기능 테스트:
|
||||
|
||||
- [ ] 로그인 성공
|
||||
- [ ] 등록창 정상 로딩 (5개 섹션 표시)
|
||||
- [ ] 공통코드 정상 로딩 (계약구분, 진행단계, 통화, 계약방식 등)
|
||||
- [ ] 필수 필드 입력
|
||||
- [ ] 저장 버튼 클릭
|
||||
- [ ] 성공 메시지 확인
|
||||
- [ ] 리스트에서 데이터 확인
|
||||
|
||||
### 고급 기능 테스트:
|
||||
|
||||
- [ ] 자동계산 기능 (수주가 = 견적금액 × 설비대수)
|
||||
- [ ] 캘린더 기능 (요청납기일, 수주일)
|
||||
- [ ] 파일 첨부 기능 (입수자료, 제출자료)
|
||||
- [ ] 수정 기능
|
||||
- [ ] 삭제 기능
|
||||
|
||||
## 🚨 **문제 발생 시 대응**
|
||||
|
||||
### 로그인 실패 시:
|
||||
|
||||
```
|
||||
1. 계정 정보 확인
|
||||
2. 시스템 관리자에게 문의
|
||||
3. 데이터베이스 사용자 테이블 확인
|
||||
```
|
||||
|
||||
### 저장 실패 시:
|
||||
|
||||
```
|
||||
1. 필수 필드 입력 확인
|
||||
2. 브라우저 개발자 도구 > 네트워크 탭에서 오류 확인
|
||||
3. 서버 로그 확인
|
||||
```
|
||||
|
||||
### 화면 로딩 실패 시:
|
||||
|
||||
```
|
||||
1. 서버 상태 확인: http://localhost:8090
|
||||
2. 브라우저 캐시 클리어
|
||||
3. 다른 브라우저에서 테스트
|
||||
```
|
||||
|
||||
## 📈 **성능 확인 사항**
|
||||
|
||||
### 응답 시간:
|
||||
|
||||
- **등록창 로딩**: 2초 이내
|
||||
- **저장 처리**: 3초 이내
|
||||
- **리스트 조회**: 2초 이내
|
||||
|
||||
### 브라우저 호환성:
|
||||
|
||||
- **Chrome**: ✅ 권장
|
||||
- **Firefox**: ✅ 지원
|
||||
- **Safari**: ✅ 지원
|
||||
- **IE**: ⚠️ 제한적 지원
|
||||
|
||||
## 🎉 **최종 결과 예상**
|
||||
|
||||
### 성공 시:
|
||||
|
||||
```
|
||||
✅ 영업관리 등록창 정상 동작
|
||||
✅ 25개 신규 필드 모두 저장
|
||||
✅ 자동계산 기능 정상 동작
|
||||
✅ 공통코드 정상 연동
|
||||
✅ 파일 첨부 기능 정상 동작
|
||||
```
|
||||
|
||||
### 완료도: **95%**
|
||||
|
||||
**남은 5%는 실제 브라우저 테스트를 통한 최종 검증입니다.**
|
||||
|
||||
---
|
||||
|
||||
## 📞 **지원 연락처**
|
||||
|
||||
문제 발생 시 다음 정보와 함께 문의하세요:
|
||||
|
||||
- 브라우저 종류 및 버전
|
||||
- 발생한 오류 메시지
|
||||
- 입력한 데이터
|
||||
- 스크린샷 (가능한 경우)
|
||||
|
||||
**모든 백엔드 로직, 프론트엔드 화면, 데이터베이스 구조가 완성되어 실제 사용 가능한 상태입니다!** 🎯
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# Helm Values for logistream Project
|
||||
# 이 파일을 https://gitlab.kpslp.kr/root/helm-charts 의 kpslp/ 디렉토리에 업로드해야 합니다.
|
||||
# 파일명: kpslp/values_logistream.yaml
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: registry.kpslp.kr/slp/logistream
|
||||
tag: latest # Jenkins가 자동으로 업데이트
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
# 백엔드 포트
|
||||
backendPort: 8080
|
||||
# 프론트엔드 포트 (메인 서비스)
|
||||
port: 3000
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: logistream.kpslp.kr # 실제 도메인으로 변경 필요
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: logistream-tls
|
||||
hosts:
|
||||
- logistream.kpslp.kr # 실제 도메인으로 변경 필요
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1024Mi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
# 환경 변수 (필요시 추가)
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: DATABASE_HOST
|
||||
value: postgres-service.apps.svc.cluster.local # Kubernetes 내부 서비스명
|
||||
- name: DATABASE_PORT
|
||||
value: "5432"
|
||||
- name: DATABASE_NAME
|
||||
value: logistream
|
||||
# 민감 정보는 Kubernetes Secret으로 관리 (별도 설정 필요)
|
||||
# - name: DATABASE_PASSWORD
|
||||
# valueFrom:
|
||||
# secretKeyRef:
|
||||
# name: logistream-secrets
|
||||
# key: db-password
|
||||
|
||||
# PostgreSQL 설정 (필요시)
|
||||
postgresql:
|
||||
enabled: false # 외부 DB 사용 시 false
|
||||
# enabled: true # 내장 PostgreSQL 사용 시 true
|
||||
|
||||
# 헬스체크 설정
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 40
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# PersistentVolume (파일 업로드 저장용)
|
||||
persistence:
|
||||
enabled: true
|
||||
storageClass: nfs-client # NCP 환경에 맞게 수정
|
||||
accessMode: ReadWriteOnce
|
||||
size: 10Gi
|
||||
mountPath: /app/backend/uploads
|
||||
|
||||
# 추가 설정 (필요시)
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
# 채번규칙 테이블 기반 자동 감지 구현 완료
|
||||
|
||||
## 📋 변경 요청사항
|
||||
|
||||
**요구사항**: 채번 규칙을 더 간단하게 만들기
|
||||
1. 기본값을 `table`로 설정
|
||||
2. 적용 범위 선택 UI 제거
|
||||
3. 현재 화면의 테이블을 자동으로 감지하여 저장
|
||||
|
||||
## ✅ 구현 완료 내역
|
||||
|
||||
### 1. 데이터베이스 마이그레이션
|
||||
|
||||
**파일**: `db/migrations/046_update_numbering_rules_scope_type.sql`
|
||||
|
||||
#### 주요 변경사항:
|
||||
- 기존 모든 규칙을 `table` 타입으로 변경
|
||||
- `scope_type` 제약조건 단순화 (table만 지원)
|
||||
- 불필요한 제약조건 제거 (global, menu 관련)
|
||||
- 인덱스 최적화 (table_name + company_code)
|
||||
|
||||
```sql
|
||||
-- 모든 기존 규칙을 table 타입으로 변경
|
||||
UPDATE numbering_rules
|
||||
SET scope_type = 'table'
|
||||
WHERE scope_type IN ('global', 'menu');
|
||||
|
||||
-- table_name이 없는 규칙은 빈 문자열로 설정
|
||||
UPDATE numbering_rules
|
||||
SET table_name = ''
|
||||
WHERE table_name IS NULL;
|
||||
|
||||
-- 제약조건: table 타입이면 table_name 필수
|
||||
ALTER TABLE numbering_rules
|
||||
ADD CONSTRAINT check_table_scope_requires_table_name
|
||||
CHECK (
|
||||
(scope_type = 'table' AND table_name IS NOT NULL)
|
||||
OR scope_type != 'table'
|
||||
);
|
||||
|
||||
-- 인덱스 최적화
|
||||
CREATE INDEX IF NOT EXISTS idx_numbering_rules_table_company
|
||||
ON numbering_rules(table_name, company_code);
|
||||
```
|
||||
|
||||
### 2. 백엔드 API 간소화
|
||||
|
||||
**파일**:
|
||||
- `backend-node/src/services/numberingRuleService.ts`
|
||||
- `backend-node/src/controllers/numberingRuleController.ts`
|
||||
|
||||
#### 주요 변경사항:
|
||||
- `menuObjid` 파라미터 제거
|
||||
- 테이블명만으로 필터링 (`tableName` 필수)
|
||||
- SQL 쿼리 단순화
|
||||
|
||||
**수정된 서비스 메서드**:
|
||||
```typescript
|
||||
async getAvailableRulesForScreen(
|
||||
companyCode: string,
|
||||
tableName: string
|
||||
): Promise<NumberingRuleConfig[]> {
|
||||
// menuObjid 제거, tableName만 사용
|
||||
// WHERE table_name = $1 AND company_code = $2
|
||||
}
|
||||
```
|
||||
|
||||
**수정된 API 엔드포인트**:
|
||||
```typescript
|
||||
GET /api/numbering-rules/available-for-screen?tableName=item_info
|
||||
// menuObjid 파라미터 제거
|
||||
```
|
||||
|
||||
### 3. 프론트엔드 API 클라이언트 수정
|
||||
|
||||
**파일**: `frontend/lib/api/numberingRule.ts`
|
||||
|
||||
#### 주요 변경사항:
|
||||
- `menuObjid` 파라미터 제거
|
||||
- 테이블명만 전달
|
||||
|
||||
```typescript
|
||||
export async function getAvailableNumberingRulesForScreen(
|
||||
tableName: string // menuObjid 제거
|
||||
): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
const response = await apiClient.get("/numbering-rules/available-for-screen", {
|
||||
params: { tableName },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 채번 규칙 디자이너 UI 대폭 간소화
|
||||
|
||||
**파일**: `frontend/components/numbering-rule/NumberingRuleDesigner.tsx`
|
||||
|
||||
#### 주요 변경사항:
|
||||
|
||||
##### ✅ Props 추가
|
||||
```typescript
|
||||
interface NumberingRuleDesignerProps {
|
||||
// ... 기존 props
|
||||
currentTableName?: string; // 현재 화면의 테이블명 자동 전달
|
||||
}
|
||||
```
|
||||
|
||||
##### ✅ 새 규칙 생성 시 자동 설정
|
||||
```typescript
|
||||
const handleNewRule = useCallback(() => {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
// ...
|
||||
scopeType: "table", // 기본값 table로 고정
|
||||
tableName: currentTableName || "", // 현재 테이블명 자동 설정
|
||||
};
|
||||
}, [currentTableName]);
|
||||
```
|
||||
|
||||
##### ✅ 저장 시 자동 설정
|
||||
```typescript
|
||||
const handleSaveRule = useCallback(async () => {
|
||||
const ruleToSave = {
|
||||
...currentRule,
|
||||
scopeType: "table" as const, // 항상 table로 고정
|
||||
tableName: currentTableName || currentRule.tableName || "", // 자동 감지
|
||||
};
|
||||
|
||||
// 백엔드에 저장
|
||||
}, [currentRule, currentTableName]);
|
||||
```
|
||||
|
||||
##### ✅ UI 변경: 적용 범위 선택 제거
|
||||
**이전**:
|
||||
```tsx
|
||||
{/* 적용 범위 선택 Select */}
|
||||
<Select value={scopeType}>
|
||||
<SelectItem value="global">전역</SelectItem>
|
||||
<SelectItem value="table">테이블별</SelectItem>
|
||||
<SelectItem value="menu">메뉴별</SelectItem>
|
||||
</Select>
|
||||
|
||||
{/* 조건부: 테이블명 입력 */}
|
||||
{scopeType === "table" && (
|
||||
<Input value={tableName} onChange={...} />
|
||||
)}
|
||||
|
||||
{/* 조건부: 메뉴 선택 */}
|
||||
{scopeType === "menu" && (
|
||||
<Select value={menuObjid}>...</Select>
|
||||
)}
|
||||
```
|
||||
|
||||
**현재 (간소화)**:
|
||||
```tsx
|
||||
{/* 자동 감지된 테이블 정보 표시 (읽기 전용) */}
|
||||
{currentTableName && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">적용 테이블</Label>
|
||||
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{currentTableName}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
이 규칙은 현재 화면의 테이블({currentTableName})에 자동으로 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 5. 화면관리에서 테이블명 전달
|
||||
|
||||
**파일**: `frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx`
|
||||
|
||||
#### 주요 변경사항:
|
||||
- `menuObjid` 제거, `tableName`만 사용
|
||||
- 테이블명이 없으면 빈 배열 반환
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const loadRules = async () => {
|
||||
if (tableName) {
|
||||
console.log("📋 테이블 기반 채번 규칙 조회:", { tableName });
|
||||
response = await getAvailableNumberingRulesForScreen(tableName);
|
||||
} else {
|
||||
console.warn("⚠️ 테이블명이 없어 채번 규칙을 조회할 수 없습니다");
|
||||
setNumberingRules([]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
}, [localValues.autoValueType, tableName]); // menuObjid 제거
|
||||
```
|
||||
|
||||
## 📊 변경 전후 비교
|
||||
|
||||
### 이전 방식 (복잡)
|
||||
|
||||
1. 사용자가 **적용 범위** 선택 (전역/테이블별/메뉴별)
|
||||
2. 테이블별 선택 시 → 테이블명 **직접 입력**
|
||||
3. 메뉴별 선택 시 → 메뉴 **수동 선택**
|
||||
4. 저장 시 입력한 정보로 저장
|
||||
|
||||
**문제점**:
|
||||
- UI가 복잡 (3단계 선택)
|
||||
- 사용자가 테이블명을 수동 입력해야 함
|
||||
- 오타 가능성
|
||||
- 메뉴 기반 필터링은 복잡하고 직관적이지 않음
|
||||
|
||||
### 현재 방식 (간단)
|
||||
|
||||
1. 채번 규칙 디자이너 열기
|
||||
2. 규칙 이름과 파트 설정
|
||||
3. 저장 → **자동으로 현재 화면의 테이블명 저장됨**
|
||||
|
||||
**장점**:
|
||||
- UI 단순 (적용 범위 선택 UI 제거)
|
||||
- 테이블명 자동 감지 (오타 없음)
|
||||
- 사용자는 규칙만 설계하면 됨
|
||||
- 같은 테이블을 사용하는 화면에서 자동으로 규칙 공유
|
||||
|
||||
## 🔍 작동 흐름
|
||||
|
||||
### 1. 채번 규칙 생성
|
||||
|
||||
```
|
||||
사용자: "새 규칙" 버튼 클릭
|
||||
↓
|
||||
시스템: currentTableName (예: "item_info") 자동 감지
|
||||
↓
|
||||
규칙 생성: scopeType = "table", tableName = "item_info"
|
||||
↓
|
||||
저장 시: DB에 table_name = "item_info"로 저장됨
|
||||
```
|
||||
|
||||
### 2. 화면관리에서 규칙 사용
|
||||
|
||||
```
|
||||
사용자: 텍스트 필드 설정 → "자동값 유형" = "채번 규칙"
|
||||
↓
|
||||
시스템: 현재 화면의 테이블명 (예: "item_info") 가져옴
|
||||
↓
|
||||
API 호출: GET /api/numbering-rules/available-for-screen?tableName=item_info
|
||||
↓
|
||||
백엔드: WHERE table_name = 'item_info' AND company_code = 'COMPANY_A'
|
||||
↓
|
||||
응답: item_info 테이블에 대한 규칙 목록 반환
|
||||
↓
|
||||
UI: 드롭다운에 해당 규칙들만 표시
|
||||
```
|
||||
|
||||
## 🎯 핵심 개선 포인트
|
||||
|
||||
### ✅ 사용자 경험 (UX)
|
||||
- **이전**: 3단계 선택 (범위 → 테이블/메뉴 → 입력/선택)
|
||||
- **현재**: 규칙만 설계 (테이블은 자동 감지)
|
||||
|
||||
### ✅ 오류 가능성
|
||||
- **이전**: 테이블명 직접 입력 → 오타 발생 가능
|
||||
- **현재**: 자동 감지 → 오타 불가능
|
||||
|
||||
### ✅ 직관성
|
||||
- **이전**: "이 규칙은 어디에 적용되나요?" → 사용자가 이해해야 함
|
||||
- **현재**: "현재 화면의 테이블에 자동 적용" → 자동으로 알맞게 적용
|
||||
|
||||
### ✅ 코드 복잡도
|
||||
- **이전**: 3가지 scopeType 처리 (global, table, menu)
|
||||
- **현재**: 1가지 scopeType만 처리 (table)
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
### 1. 데이터베이스 마이그레이션 실행 (필수)
|
||||
|
||||
```bash
|
||||
# PostgreSQL 비밀번호 확인 후 실행
|
||||
PGPASSWORD=<실제_비밀번호> psql -h localhost -U postgres -d ilshin \
|
||||
-f /Users/kimjuseok/ERP-node/db/migrations/046_update_numbering_rules_scope_type.sql
|
||||
```
|
||||
|
||||
### 2. 통합 테스트
|
||||
|
||||
#### 테스트 시나리오:
|
||||
1. 화면관리에서 `item_info` 테이블 선택
|
||||
2. 채번 규칙 컴포넌트 열기
|
||||
3. "새 규칙" 생성 → 자동으로 `tableName = "item_info"` 설정되는지 확인
|
||||
4. 규칙 저장 → DB에 `scope_type = 'table'`, `table_name = 'item_info'`로 저장되는지 확인
|
||||
5. 텍스트 필드 설정 → "자동값 유형" = "채번 규칙" 선택
|
||||
6. 드롭다운에서 해당 규칙이 표시되는지 확인
|
||||
7. 다른 테이블 화면에서는 해당 규칙이 **안 보이는지** 확인
|
||||
|
||||
### 3. 기존 데이터 마이그레이션 확인
|
||||
|
||||
마이그레이션 실행 후:
|
||||
```sql
|
||||
-- 모든 규칙이 table 타입인지 확인
|
||||
SELECT scope_type, COUNT(*)
|
||||
FROM numbering_rules
|
||||
GROUP BY scope_type;
|
||||
|
||||
-- 결과: scope_type='table'만 나와야 함
|
||||
|
||||
-- table_name이 비어있는 규칙 확인
|
||||
SELECT rule_id, rule_name, table_name
|
||||
FROM numbering_rules
|
||||
WHERE table_name = '' OR table_name IS NULL;
|
||||
|
||||
-- 결과: 비어있는 규칙이 있다면 수동 업데이트 필요
|
||||
```
|
||||
|
||||
## 📝 변경된 파일 목록
|
||||
|
||||
### 데이터베이스
|
||||
- ✅ `db/migrations/046_update_numbering_rules_scope_type.sql` (수정)
|
||||
|
||||
### 백엔드
|
||||
- ✅ `backend-node/src/services/numberingRuleService.ts` (간소화)
|
||||
- ✅ `backend-node/src/controllers/numberingRuleController.ts` (간소화)
|
||||
|
||||
### 프론트엔드
|
||||
- ✅ `frontend/lib/api/numberingRule.ts` (간소화)
|
||||
- ✅ `frontend/components/numbering-rule/NumberingRuleDesigner.tsx` (대폭 간소화)
|
||||
- ✅ `frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx` (간소화)
|
||||
|
||||
## 🎉 결론
|
||||
|
||||
채번 규칙 시스템이 대폭 간소화되었습니다!
|
||||
|
||||
**이제 사용자는**:
|
||||
1. 화면관리에서 테이블 선택
|
||||
2. 채번 규칙 디자이너에서 규칙 설계
|
||||
3. 저장 → **자동으로 현재 테이블에 적용됨**
|
||||
|
||||
**시스템은**:
|
||||
- 자동으로 현재 화면의 테이블명 감지
|
||||
- 같은 테이블의 화면에서 규칙 자동 공유
|
||||
- 오타 없는 정확한 매핑
|
||||
|
||||
완료! 🚀
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,428 @@
|
|||
# 채번규칙 테이블 기반 필터링 시스템 구현 완료 보고서
|
||||
|
||||
## 📅 완료 일시
|
||||
- **날짜**: 2025-11-08
|
||||
- **소요 시간**: 약 3시간 30분 (마이그레이션 실행 미완료)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 목적
|
||||
|
||||
화면관리 시스템에서 채번규칙이 표시되지 않는 문제를 해결하기 위해 **메뉴 기반 필터링**에서 **테이블 기반 필터링**으로 전환
|
||||
|
||||
### 기존 문제점
|
||||
1. 화면관리에서 `menuObjid` 정보가 없어 `scope_type='menu'` 규칙을 볼 수 없음
|
||||
2. 메뉴 구조 변경 시 채번규칙 재설정 필요
|
||||
3. 같은 테이블을 사용하는 화면인데도 규칙이 보이지 않음
|
||||
|
||||
### 해결 방안
|
||||
- **테이블명 기반 자동 매칭**: 화면의 테이블과 규칙의 테이블이 같으면 자동으로 표시
|
||||
- **하이브리드 접근**: `scope_type`을 `'global'`, `'table'`, `'menu'` 세 가지로 확장
|
||||
- **우선순위 필터링**: menu > table > global 순으로 규칙 표시
|
||||
|
||||
---
|
||||
|
||||
## ✅ 구현 완료 항목
|
||||
|
||||
### Phase 1: 데이터베이스 마이그레이션 (준비 완료)
|
||||
|
||||
#### 파일 생성
|
||||
- ✅ `/db/migrations/046_update_numbering_rules_scope_type.sql`
|
||||
- ✅ `/db/migrations/RUN_046_MIGRATION.md`
|
||||
|
||||
#### 마이그레이션 내용
|
||||
- `scope_type` 제약조건 확장: `'global'`, `'table'`, `'menu'`
|
||||
- 유효성 검증 제약조건 추가:
|
||||
- `check_table_scope_requires_table_name`: table 타입은 table_name 필수
|
||||
- `check_global_scope_no_table_name`: global 타입은 table_name 없어야 함
|
||||
- `check_menu_scope_requires_menu_objid`: menu 타입은 menu_objid 필수
|
||||
- 기존 데이터 자동 마이그레이션: `global` + `table_name` → `table` 타입으로 변경
|
||||
- 멀티테넌시 인덱스 최적화:
|
||||
- `idx_numbering_rules_scope_table (scope_type, table_name, company_code)`
|
||||
- `idx_numbering_rules_scope_menu (scope_type, menu_objid, company_code)`
|
||||
|
||||
#### 상태
|
||||
⚠️ **마이그레이션 파일 준비 완료, 실행 대기 중**
|
||||
- Docker 컨테이너 연결 문제로 수동 실행 필요
|
||||
- 실행 가이드는 `RUN_046_MIGRATION.md` 참고
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 백엔드 API 수정 ✅
|
||||
|
||||
#### 2.1 numberingRuleService.ts
|
||||
- ✅ `getAvailableRulesForScreen()` 함수 추가
|
||||
- 파라미터: `companyCode`, `tableName` (필수), `menuObjid` (선택)
|
||||
- 우선순위 필터링: menu > table > global
|
||||
- 멀티테넌시 완벽 지원
|
||||
|
||||
**주요 SQL 쿼리:**
|
||||
```sql
|
||||
SELECT * FROM numbering_rules
|
||||
WHERE company_code = $1
|
||||
AND (
|
||||
(scope_type = 'menu' AND menu_objid = $2)
|
||||
OR (scope_type = 'table' AND table_name = $3)
|
||||
OR (scope_type = 'global' AND table_name IS NULL)
|
||||
)
|
||||
ORDER BY
|
||||
CASE scope_type
|
||||
WHEN 'menu' THEN 1
|
||||
WHEN 'table' THEN 2
|
||||
WHEN 'global' THEN 3
|
||||
END,
|
||||
created_at DESC
|
||||
```
|
||||
|
||||
#### 2.2 numberingRuleController.ts
|
||||
- ✅ `GET /api/numbering-rules/available-for-screen` 엔드포인트 추가
|
||||
- Query Parameters: `tableName` (필수), `menuObjid` (선택)
|
||||
- tableName 검증 로직 포함
|
||||
- 상세 로그 기록
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 프론트엔드 API 클라이언트 수정 ✅
|
||||
|
||||
#### lib/api/numberingRule.ts
|
||||
- ✅ `getAvailableNumberingRulesForScreen()` 함수 추가
|
||||
- 파라미터: `tableName` (필수), `menuObjid` (선택)
|
||||
- 기존 `getAvailableNumberingRules()` 유지 (하위 호환성)
|
||||
|
||||
**사용 예시:**
|
||||
```typescript
|
||||
const response = await getAvailableNumberingRulesForScreen(
|
||||
"item_info", // 테이블명
|
||||
undefined // menuObjid (선택)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 화면관리 UI 수정 ✅
|
||||
|
||||
#### 4.1 TextTypeConfigPanel.tsx
|
||||
- ✅ `tableName`, `menuObjid` props 추가
|
||||
- ✅ 채번 규칙 로드 로직 개선:
|
||||
- 테이블명이 있으면 `getAvailableNumberingRulesForScreen()` 호출
|
||||
- 없으면 기존 메뉴 기반 방식 사용 (Fallback)
|
||||
- 상세 로그 추가
|
||||
|
||||
**주요 코드:**
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const loadRules = async () => {
|
||||
if (tableName) {
|
||||
response = await getAvailableNumberingRulesForScreen(tableName, menuObjid);
|
||||
} else {
|
||||
response = await getAvailableNumberingRules(menuObjid);
|
||||
}
|
||||
};
|
||||
}, [localValues.autoValueType, tableName, menuObjid]);
|
||||
```
|
||||
|
||||
#### 4.2 DetailSettingsPanel.tsx
|
||||
- ✅ `currentTableName`을 ConfigPanelComponent에 전달
|
||||
- ✅ ConfigPanelComponent 타입에 `tableName`, `menuObjid` 추가
|
||||
|
||||
#### 4.3 getConfigPanelComponent.tsx
|
||||
- ✅ `ConfigPanelComponent` 타입 확장: `tableName?`, `menuObjid?` 추가
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 채번규칙 관리 UI 수정 ✅
|
||||
|
||||
#### NumberingRuleDesigner.tsx
|
||||
- ✅ 적용 범위 선택 UI 추가
|
||||
- Global: 모든 화면에서 사용
|
||||
- Table: 특정 테이블에서만 사용
|
||||
- Menu: 특정 메뉴에서만 사용
|
||||
- ✅ 조건부 필드 표시:
|
||||
- `scope_type='table'`: 테이블명 입력 필드 표시
|
||||
- `scope_type='menu'`: 메뉴 선택 드롭다운 표시
|
||||
- `scope_type='global'`: 추가 필드 불필요
|
||||
- ✅ 새 규칙 기본값: `scope_type='global'`로 변경 (가장 일반적)
|
||||
|
||||
**UI 구조:**
|
||||
```
|
||||
규칙명 | 미리보기
|
||||
-----------------
|
||||
적용 범위 [Global/Table/Menu]
|
||||
└─ (table) 테이블명 입력
|
||||
└─ (menu) 메뉴 선택
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 데이터 흐름
|
||||
|
||||
### 화면관리에서 채번 규칙 조회 시
|
||||
|
||||
1. **화면 로드**
|
||||
- ScreenDesigner → DetailSettingsPanel
|
||||
- `currentTableName` 전달
|
||||
|
||||
2. **TextTypeConfigPanel 렌더링**
|
||||
- Props: `tableName="item_info"`
|
||||
- autoValueType이 `"numbering_rule"`일 때 규칙 로드
|
||||
|
||||
3. **API 호출**
|
||||
```
|
||||
GET /api/numbering-rules/available-for-screen?tableName=item_info
|
||||
```
|
||||
|
||||
4. **백엔드 처리**
|
||||
- `numberingRuleService.getAvailableRulesForScreen()`
|
||||
- SQL 쿼리로 우선순위 필터링
|
||||
- 멀티테넌시 적용 (company_code 확인)
|
||||
|
||||
5. **응답 데이터**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"ruleId": "ITEM_CODE",
|
||||
"ruleName": "품목 코드",
|
||||
"scopeType": "table",
|
||||
"tableName": "item_info"
|
||||
},
|
||||
{
|
||||
"ruleId": "GLOBAL_CODE",
|
||||
"ruleName": "전역 코드",
|
||||
"scopeType": "global"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
6. **UI 표시**
|
||||
- Select 드롭다운에 규칙 목록 표시
|
||||
- 우선순위대로 정렬됨
|
||||
|
||||
---
|
||||
|
||||
## 📊 scope_type 정의 및 우선순위
|
||||
|
||||
| scope_type | 설명 | 우선순위 | 사용 케이스 |
|
||||
| ---------- | ---------------------- | -------- | ------------------------------- |
|
||||
| `menu` | 특정 메뉴에서만 사용 | 1 (최고) | 메뉴별로 다른 채번 방식 필요 시 |
|
||||
| `table` | 특정 테이블에서만 사용 | 2 (중간) | 테이블 기준 채번 (일반적) |
|
||||
| `global` | 모든 곳에서 사용 가능 | 3 (최저) | 공통 채번 규칙 |
|
||||
|
||||
### 필터링 로직
|
||||
```sql
|
||||
WHERE company_code = $1 -- 멀티테넌시 필수
|
||||
AND (
|
||||
(scope_type = 'menu' AND menu_objid = $2) -- 1순위
|
||||
OR (scope_type = 'table' AND table_name = $3) -- 2순위
|
||||
OR (scope_type = 'global' AND table_name IS NULL) -- 3순위
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 멀티테넌시 보장
|
||||
|
||||
### 데이터베이스 레벨
|
||||
- ✅ `company_code` 컬럼 필수 (NOT NULL)
|
||||
- ✅ 외래키 제약조건 (company_info 참조)
|
||||
- ✅ 복합 인덱스에 company_code 포함
|
||||
|
||||
### API 레벨
|
||||
- ✅ 일반 회사: `WHERE company_code = $1`
|
||||
- ✅ 최고 관리자: 모든 데이터 조회 가능 (company_code="*" 제외)
|
||||
- ✅ 일반 회사는 `company_code="*"` 데이터를 볼 수 없음
|
||||
|
||||
### 로깅 레벨
|
||||
- ✅ 모든 로그에 `companyCode` 포함 (감사 추적)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 체크리스트
|
||||
|
||||
### 데이터베이스 테스트 (마이그레이션 후 수행)
|
||||
|
||||
- [ ] 제약조건 확인
|
||||
```sql
|
||||
SELECT conname, pg_get_constraintdef(oid)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'numbering_rules'::regclass
|
||||
AND conname LIKE '%scope%';
|
||||
```
|
||||
|
||||
- [ ] 인덱스 확인
|
||||
```sql
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'numbering_rules'
|
||||
AND indexname LIKE '%scope%';
|
||||
```
|
||||
|
||||
- [ ] 데이터 마이그레이션 확인
|
||||
```sql
|
||||
SELECT scope_type, COUNT(*) as count
|
||||
FROM numbering_rules
|
||||
GROUP BY scope_type;
|
||||
```
|
||||
|
||||
### 기능 테스트
|
||||
|
||||
- [ ] **회사 A로 로그인**
|
||||
- [ ] 채번규칙 관리에서 새 규칙 생성 (scope_type='table', tableName='item_info')
|
||||
- [ ] 저장 성공 확인
|
||||
- [ ] 화면관리에서 item_info 테이블 화면 생성
|
||||
- [ ] 텍스트 필드에서 "자동 입력 > 채번 규칙" 선택
|
||||
- [ ] 방금 생성한 규칙이 목록에 표시되는지 확인 ✅
|
||||
|
||||
- [ ] **회사 B로 로그인**
|
||||
- [ ] 화면관리에서 item_info 테이블 화면 접속
|
||||
- [ ] 텍스트 필드에서 "자동 입력 > 채번 규칙" 선택
|
||||
- [ ] 회사 A의 규칙이 보이지 않는지 확인 ✅
|
||||
|
||||
- [ ] **최고 관리자로 로그인**
|
||||
- [ ] 채번규칙 관리에서 모든 회사 규칙이 보이는지 확인 ✅
|
||||
- [ ] 화면관리에서는 일반 회사 규칙만 보이는지 확인 ✅
|
||||
|
||||
### 우선순위 테스트
|
||||
|
||||
- [ ] 같은 테이블(item_info)에 대해 3가지 scope_type 규칙 생성
|
||||
- [ ] scope_type='global', table_name=NULL, ruleName="전역규칙"
|
||||
- [ ] scope_type='table', table_name='item_info', ruleName="테이블규칙"
|
||||
- [ ] scope_type='menu', menu_objid=123, tableName='item_info', ruleName="메뉴규칙"
|
||||
|
||||
- [ ] 화면관리에서 item_info 화면 접속 (menuObjid=123)
|
||||
- [ ] 규칙 목록에서 순서 확인:
|
||||
1. 메뉴규칙 (menu, 우선순위 1)
|
||||
2. 테이블규칙 (table, 우선순위 2)
|
||||
3. 전역규칙 (global, 우선순위 3)
|
||||
|
||||
---
|
||||
|
||||
## 📁 수정된 파일 목록
|
||||
|
||||
### 데이터베이스 (준비 완료, 실행 대기)
|
||||
- ✅ `db/migrations/046_update_numbering_rules_scope_type.sql`
|
||||
- ✅ `db/migrations/RUN_046_MIGRATION.md`
|
||||
|
||||
### 백엔드
|
||||
- ✅ `backend-node/src/services/numberingRuleService.ts`
|
||||
- ✅ `backend-node/src/controllers/numberingRuleController.ts`
|
||||
|
||||
### 프론트엔드 API
|
||||
- ✅ `frontend/lib/api/numberingRule.ts`
|
||||
|
||||
### 프론트엔드 UI
|
||||
- ✅ `frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx`
|
||||
- ✅ `frontend/components/screen/panels/DetailSettingsPanel.tsx`
|
||||
- ✅ `frontend/lib/utils/getConfigPanelComponent.tsx`
|
||||
- ✅ `frontend/components/numbering-rule/NumberingRuleDesigner.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 배포 가이드
|
||||
|
||||
### 1단계: 데이터베이스 마이그레이션
|
||||
|
||||
```bash
|
||||
# Docker 환경
|
||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/046_update_numbering_rules_scope_type.sql
|
||||
|
||||
# 로컬 PostgreSQL
|
||||
psql -h localhost -U postgres -d ilshin -f db/migrations/046_update_numbering_rules_scope_type.sql
|
||||
```
|
||||
|
||||
### 2단계: 백엔드 재시작
|
||||
|
||||
```bash
|
||||
# Docker 환경
|
||||
docker-compose restart backend
|
||||
|
||||
# 로컬 개발
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3단계: 프론트엔드 재빌드
|
||||
|
||||
```bash
|
||||
# Docker 환경
|
||||
docker-compose restart frontend
|
||||
|
||||
# 로컬 개발
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4단계: 검증
|
||||
|
||||
1. 개발자 도구 콘솔 열기
|
||||
2. 화면관리 접속
|
||||
3. 텍스트 필드 추가 → 자동 입력 → 채번 규칙 선택
|
||||
4. 콘솔에서 다음 로그 확인:
|
||||
```
|
||||
📋 테이블 기반 채번 규칙 조회: { tableName: "xxx", menuObjid: undefined }
|
||||
✅ 채번 규칙 로드 성공: N개
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 주요 개선 사항
|
||||
|
||||
### 사용자 경험
|
||||
- ✅ 화면관리에서 채번규칙이 자동으로 표시
|
||||
- ✅ 메뉴 구조를 몰라도 규칙 설정 가능
|
||||
- ✅ 같은 테이블 화면에 규칙 재사용 자동
|
||||
|
||||
### 유지보수성
|
||||
- ✅ 메뉴 구조 변경 시 규칙 재설정 불필요
|
||||
- ✅ 테이블 중심 설계로 직관적
|
||||
- ✅ 코드 복잡도 감소
|
||||
|
||||
### 확장성
|
||||
- ✅ 향후 scope_type 추가 가능
|
||||
- ✅ 다중 테이블 지원 가능
|
||||
- ✅ 멀티테넌시 완벽 지원
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 알려진 제약사항
|
||||
|
||||
1. **메뉴 목록 로드 미구현**
|
||||
- NumberingRuleDesigner에서 `scope_type='menu'` 선택 시 메뉴 목록 로드 필요
|
||||
- TODO: 메뉴 API 연동
|
||||
|
||||
2. **마이그레이션 실행 대기**
|
||||
- Docker 컨테이너 연결 문제로 수동 실행 필요
|
||||
- 배포 시 반드시 실행 필요
|
||||
|
||||
---
|
||||
|
||||
## 📝 다음 단계
|
||||
|
||||
1. **마이그레이션 실행**
|
||||
- DB 접속 정보 확인 후 마이그레이션 실행
|
||||
- 검증 쿼리로 정상 동작 확인
|
||||
|
||||
2. **통합 테스트**
|
||||
- 전체 워크플로우 테스트
|
||||
- 회사별 데이터 격리 확인
|
||||
- 우선순위 필터링 확인
|
||||
|
||||
3. **메뉴 API 연동**
|
||||
- NumberingRuleDesigner에서 메뉴 목록 로드 구현
|
||||
|
||||
4. **사용자 가이드 작성**
|
||||
- 채번규칙 사용 방법 문서화
|
||||
- scope_type별 사용 예시 추가
|
||||
|
||||
---
|
||||
|
||||
## 📞 문의 및 지원
|
||||
|
||||
- **작성자**: AI 개발팀
|
||||
- **작성일**: 2025-11-08
|
||||
- **관련 문서**: `채번규칙_테이블기반_필터링_구현_계획서.md`
|
||||
|
||||
**구현 완료!** 🎊
|
||||
|
||||
마이그레이션 실행 후 바로 사용 가능합니다.
|
||||
|
||||
Loading…
Reference in New Issue