Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
0613847c1f
|
|
@ -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) => {
|
router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,33 @@ export class EntityJoinService {
|
||||||
|
|
||||||
const joinConfigs: EntityJoinConfig[] = [];
|
const joinConfigs: EntityJoinConfig[] = [];
|
||||||
|
|
||||||
|
// 🎯 writer 컬럼 자동 감지 및 조인 설정 추가
|
||||||
|
const tableColumns = await query<{ column_name: string }>(
|
||||||
|
`SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND table_schema = 'public'
|
||||||
|
AND column_name = 'writer'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tableColumns.length > 0) {
|
||||||
|
const writerJoinConfig: EntityJoinConfig = {
|
||||||
|
sourceTable: tableName,
|
||||||
|
sourceColumn: "writer",
|
||||||
|
referenceTable: "user_info",
|
||||||
|
referenceColumn: "user_id",
|
||||||
|
displayColumns: ["user_name"],
|
||||||
|
displayColumn: "user_name",
|
||||||
|
aliasColumn: "writer_name",
|
||||||
|
separator: " - ",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await this.validateJoinConfig(writerJoinConfig)) {
|
||||||
|
joinConfigs.push(writerJoinConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const column of entityColumns) {
|
for (const column of entityColumns) {
|
||||||
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
||||||
column_name: column.column_name,
|
column_name: column.column_name,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</div>
|
||||||
</ResizableDialogHeader>
|
</ResizableDialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-center overflow-auto">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -374,13 +374,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
) : screenData ? (
|
) : screenData ? (
|
||||||
<div
|
<div
|
||||||
className="relative bg-white"
|
className="relative bg-white mx-auto"
|
||||||
style={{
|
style={{
|
||||||
width: screenDimensions?.width || 800,
|
width: `${screenDimensions?.width || 800}px`,
|
||||||
height: screenDimensions?.height || 600,
|
height: `${screenDimensions?.height || 600}px`,
|
||||||
transformOrigin: "center center",
|
transformOrigin: "center center",
|
||||||
maxWidth: "100%",
|
|
||||||
maxHeight: "100%",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{screenData.components.map((component) => {
|
{screenData.components.map((component) => {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ interface NumberingRuleDesignerProps {
|
||||||
maxRules?: number;
|
maxRules?: number;
|
||||||
isPreview?: boolean;
|
isPreview?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
|
|
@ -34,6 +35,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
maxRules = 6,
|
maxRules = 6,
|
||||||
isPreview = false,
|
isPreview = false,
|
||||||
className = "",
|
className = "",
|
||||||
|
currentTableName,
|
||||||
}) => {
|
}) => {
|
||||||
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||||
|
|
@ -131,17 +133,32 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
try {
|
try {
|
||||||
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
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;
|
let response;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
response = await updateNumberingRule(currentRule.ruleId, currentRule);
|
response = await updateNumberingRule(ruleToSave.ruleId, ruleToSave);
|
||||||
} else {
|
} else {
|
||||||
response = await createNumberingRule(currentRule);
|
response = await createNumberingRule(ruleToSave);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setSavedRules((prev) => {
|
setSavedRules((prev) => {
|
||||||
if (existing) {
|
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 {
|
} else {
|
||||||
return [...prev, response.data!];
|
return [...prev, response.data!];
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +177,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentRule, savedRules, onSave]);
|
}, [currentRule, savedRules, onSave, currentTableName]);
|
||||||
|
|
||||||
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
|
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
|
||||||
setSelectedRuleId(rule.ruleId);
|
setSelectedRuleId(rule.ruleId);
|
||||||
|
|
@ -196,6 +213,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNewRule = useCallback(() => {
|
const handleNewRule = useCallback(() => {
|
||||||
|
console.log("📋 새 규칙 생성 - currentTableName:", currentTableName);
|
||||||
|
|
||||||
const newRule: NumberingRuleConfig = {
|
const newRule: NumberingRuleConfig = {
|
||||||
ruleId: `rule-${Date.now()}`,
|
ruleId: `rule-${Date.now()}`,
|
||||||
ruleName: "새 채번 규칙",
|
ruleName: "새 채번 규칙",
|
||||||
|
|
@ -203,14 +222,17 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
separator: "-",
|
separator: "-",
|
||||||
resetPeriod: "none",
|
resetPeriod: "none",
|
||||||
currentSequence: 1,
|
currentSequence: 1,
|
||||||
scopeType: "menu",
|
scopeType: "table", // 기본값을 table로 설정
|
||||||
|
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("📋 생성된 규칙 정보:", newRule);
|
||||||
|
|
||||||
setSelectedRuleId(newRule.ruleId);
|
setSelectedRuleId(newRule.ruleId);
|
||||||
setCurrentRule(newRule);
|
setCurrentRule(newRule);
|
||||||
|
|
||||||
toast.success("새 규칙이 생성되었습니다");
|
toast.success("새 규칙이 생성되었습니다");
|
||||||
}, []);
|
}, [currentTableName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex h-full gap-4 ${className}`}>
|
<div className={`flex h-full gap-4 ${className}`}>
|
||||||
|
|
@ -312,6 +334,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 첫 번째 줄: 규칙명 + 미리보기 */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Label className="text-sm font-medium">규칙명</Label>
|
<Label className="text-sm font-medium">규칙명</Label>
|
||||||
|
|
@ -328,6 +352,20 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold">코드 구성</h3>
|
<h3 className="text-sm font-semibold">코드 구성</h3>
|
||||||
|
|
|
||||||
|
|
@ -401,15 +401,14 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
const applyStyles = (element: React.ReactElement) => {
|
const applyStyles = (element: React.ReactElement) => {
|
||||||
if (!comp.style) return element;
|
if (!comp.style) return element;
|
||||||
|
|
||||||
|
// ✅ 격자 시스템 잔재 제거: style.width, style.height는 무시
|
||||||
|
// size.width, size.height가 부모 컨테이너에서 적용되므로
|
||||||
|
const { width, height, ...styleWithoutSize } = comp.style;
|
||||||
|
|
||||||
return React.cloneElement(element, {
|
return React.cloneElement(element, {
|
||||||
style: {
|
style: {
|
||||||
...element.props.style, // 기존 스타일 유지
|
...element.props.style, // 기존 스타일 유지
|
||||||
...comp.style,
|
...styleWithoutSize, // width/height 제외한 스타일만 적용
|
||||||
// 크기는 부모 컨테이너에서 처리하므로 제거 (하지만 다른 스타일은 유지)
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
minHeight: "100%",
|
|
||||||
maxHeight: "100%",
|
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -1887,7 +1886,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full w-full">
|
<div className="h-full" style={{ width: '100%', height: '100%' }}>
|
||||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||||
{shouldShowLabel && (
|
{shouldShowLabel && (
|
||||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||||
|
|
|
||||||
|
|
@ -343,10 +343,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
const applyStyles = (element: React.ReactElement) => {
|
const applyStyles = (element: React.ReactElement) => {
|
||||||
if (!comp.style) return element;
|
if (!comp.style) return element;
|
||||||
|
|
||||||
|
// ✅ 격자 시스템 잔재 제거: style.width, style.height는 무시
|
||||||
|
// size.width, size.height가 부모 컨테이너에서 적용되므로
|
||||||
|
const { width, height, ...styleWithoutSize } = comp.style;
|
||||||
|
|
||||||
return React.cloneElement(element, {
|
return React.cloneElement(element, {
|
||||||
style: {
|
style: {
|
||||||
...element.props.style,
|
...element.props.style,
|
||||||
...comp.style,
|
...styleWithoutSize, // width/height 제외한 스타일만 적용
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: "100%",
|
minHeight: "100%",
|
||||||
|
|
@ -676,14 +680,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// 메인 렌더링
|
// 메인 렌더링
|
||||||
const { type, position, size, style = {} } = component;
|
const { type, position, size, style = {} } = component;
|
||||||
|
|
||||||
|
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
|
||||||
|
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
|
||||||
|
|
||||||
const componentStyle = {
|
const componentStyle = {
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
left: position?.x || 0,
|
left: position?.x || 0,
|
||||||
top: position?.y || 0,
|
top: position?.y || 0,
|
||||||
width: size?.width || 200,
|
|
||||||
height: size?.height || 10,
|
|
||||||
zIndex: position?.z || 1,
|
zIndex: position?.z || 1,
|
||||||
...style,
|
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||||
|
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||||
|
height: size?.height || 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -563,7 +563,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
|
|
||||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||||
{type === "widget" && !isFileComponent(component) && (
|
{type === "widget" && !isFileComponent(component) && (
|
||||||
<div className="pointer-events-none h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WidgetRenderer
|
<WidgetRenderer
|
||||||
component={component}
|
component={component}
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
|
|
|
||||||
|
|
@ -214,22 +214,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
if (component.componentConfig?.type === "table-list") {
|
if (component.componentConfig?.type === "table-list") {
|
||||||
// 디자인 해상도 기준으로 픽셀 반환
|
// 디자인 해상도 기준으로 픽셀 반환
|
||||||
const screenWidth = 1920; // 기본 디자인 해상도
|
const screenWidth = 1920; // 기본 디자인 해상도
|
||||||
console.log("📏 [getWidth] table-list 픽셀 사용:", {
|
|
||||||
componentId: id,
|
|
||||||
label: component.label,
|
|
||||||
width: `${screenWidth}px`,
|
|
||||||
});
|
|
||||||
return `${screenWidth}px`;
|
return `${screenWidth}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모든 컴포넌트는 size.width 픽셀 사용
|
// 모든 컴포넌트는 size.width 픽셀 사용
|
||||||
const width = `${size?.width || 100}px`;
|
const width = `${size?.width || 100}px`;
|
||||||
console.log("📐 [getWidth] 픽셀 기준 통일:", {
|
|
||||||
componentId: id,
|
|
||||||
label: component.label,
|
|
||||||
width,
|
|
||||||
sizeWidth: size?.width,
|
|
||||||
});
|
|
||||||
return width;
|
return width;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -268,13 +257,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
}
|
}
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
|
// componentStyle에서 width, height 제거 (size.width, size.height만 사용)
|
||||||
|
const { width: _styleWidth, height: _styleHeight, ...restComponentStyle } = componentStyle || {};
|
||||||
|
|
||||||
const baseStyle = {
|
const baseStyle = {
|
||||||
left: `${position.x}px`,
|
left: `${position.x}px`,
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
width: getWidth(), // getWidth()가 모든 우선순위를 처리
|
...restComponentStyle, // width/height 제외한 스타일 먼저 적용
|
||||||
height: getHeight(),
|
width: getWidth(), // size.width로 덮어쓰기
|
||||||
|
height: getHeight(), // size.height로 덮어쓰기
|
||||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||||
...componentStyle,
|
|
||||||
right: undefined,
|
right: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -286,33 +278,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
if (outerDivRef.current && innerDivRef.current) {
|
if (outerDivRef.current && innerDivRef.current) {
|
||||||
const outerRect = outerDivRef.current.getBoundingClientRect();
|
const outerRect = outerDivRef.current.getBoundingClientRect();
|
||||||
const innerRect = innerDivRef.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]);
|
}, [id, component.label, (component as any).gridColumns, baseStyle.width]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { X, Save, Loader2 } from "lucide-react";
|
import { X, Save, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -200,8 +200,21 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
const calculateDynamicSize = () => {
|
const calculateDynamicSize = () => {
|
||||||
if (!components.length) return { width: 800, height: 600 };
|
if (!components.length) return { width: 800, height: 600 };
|
||||||
|
|
||||||
const maxX = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)));
|
const maxX = Math.max(...components.map((c) => {
|
||||||
const maxY = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)));
|
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;
|
const padding = 40;
|
||||||
return {
|
return {
|
||||||
|
|
@ -213,9 +226,16 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
const dynamicSize = calculateDynamicSize();
|
const dynamicSize = calculateDynamicSize();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
<ResizableDialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||||
<DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] gap-0 p-0`}>
|
<ResizableDialogContent
|
||||||
<DialogHeader className="border-b px-6 py-4">
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<ResizableDialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</ResizableDialogTitle>
|
<ResizableDialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</ResizableDialogTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -237,9 +257,9 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</ResizableDialogHeader>
|
||||||
|
|
||||||
<div className="overflow-auto p-6">
|
<div className="overflow-auto p-6 flex-1">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
|
@ -248,21 +268,42 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
<div
|
<div
|
||||||
className="relative bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: dynamicSize.width,
|
width: `${dynamicSize.width}px`,
|
||||||
height: dynamicSize.height,
|
height: `${dynamicSize.height}px`,
|
||||||
overflow: "hidden",
|
minWidth: `${dynamicSize.width}px`,
|
||||||
|
minHeight: `${dynamicSize.height}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative" style={{ minHeight: "300px" }}>
|
<div className="relative" style={{ width: `${dynamicSize.width}px`, height: `${dynamicSize.height}px` }}>
|
||||||
{components.map((component, index) => (
|
{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
|
<div
|
||||||
key={component.id}
|
key={component.id}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: component.position?.y || 0,
|
top: component.position?.y || 0,
|
||||||
left: component.position?.x || 0,
|
left: component.position?.x || 0,
|
||||||
width: component.size?.width || 200,
|
width: `${widthPx}px`, // ✅ 픽셀 단위 강제
|
||||||
height: component.size?.height || 40,
|
height: `${heightPx}px`, // ✅ 픽셀 단위 강제
|
||||||
zIndex: component.position?.z || 1000 + index,
|
zIndex: component.position?.z || 1000 + index,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -307,14 +348,15 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground py-12 text-center">화면에 컴포넌트가 없습니다.</div>
|
<div className="text-muted-foreground py-12 text-center">화면에 컴포넌트가 없습니다.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</ResizableDialogContent>
|
||||||
</Dialog>
|
</ResizableDialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,18 +25,44 @@ import {
|
||||||
restoreAbsolutePositions,
|
restoreAbsolutePositions,
|
||||||
} from "@/lib/utils/groupingUtils";
|
} from "@/lib/utils/groupingUtils";
|
||||||
import {
|
import {
|
||||||
calculateGridInfo,
|
|
||||||
snapToGrid,
|
|
||||||
snapSizeToGrid,
|
|
||||||
generateGridLines,
|
|
||||||
updateSizeFromGridColumns,
|
|
||||||
adjustGridColumnsFromSize,
|
adjustGridColumnsFromSize,
|
||||||
alignGroupChildrenToGrid,
|
updateSizeFromGridColumns,
|
||||||
calculateOptimalGroupSize,
|
|
||||||
normalizeGroupChildPositions,
|
|
||||||
calculateWidthFromColumns,
|
calculateWidthFromColumns,
|
||||||
GridSettings as GridUtilSettings,
|
snapSizeToGrid,
|
||||||
|
snapToGrid,
|
||||||
} from "@/lib/utils/gridUtils";
|
} from "@/lib/utils/gridUtils";
|
||||||
|
|
||||||
|
// 10px 단위 스냅 함수
|
||||||
|
const snapTo10px = (value: number): number => {
|
||||||
|
return Math.round(value / 10) * 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapPositionTo10px = (position: Position): Position => {
|
||||||
|
return {
|
||||||
|
x: snapTo10px(position.x),
|
||||||
|
y: snapTo10px(position.y),
|
||||||
|
z: position.z,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapSizeTo10px = (size: { width: number; height: number }): { width: number; height: number } => {
|
||||||
|
return {
|
||||||
|
width: snapTo10px(size.width),
|
||||||
|
height: snapTo10px(size.height),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// calculateGridInfo 더미 함수 (하위 호환성을 위해 유지)
|
||||||
|
const calculateGridInfo = (width: number, height: number, settings: any) => {
|
||||||
|
return {
|
||||||
|
columnWidth: 10,
|
||||||
|
totalWidth: width,
|
||||||
|
totalHeight: height,
|
||||||
|
columns: settings.columns || 12,
|
||||||
|
gap: settings.gap || 0,
|
||||||
|
padding: settings.padding || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
import { GroupingToolbar } from "./GroupingToolbar";
|
import { GroupingToolbar } from "./GroupingToolbar";
|
||||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
@ -57,7 +83,6 @@ import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
||||||
import { ComponentsPanel } from "./panels/ComponentsPanel";
|
import { ComponentsPanel } from "./panels/ComponentsPanel";
|
||||||
import PropertiesPanel from "./panels/PropertiesPanel";
|
import PropertiesPanel from "./panels/PropertiesPanel";
|
||||||
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
||||||
import GridPanel from "./panels/GridPanel";
|
|
||||||
import ResolutionPanel from "./panels/ResolutionPanel";
|
import ResolutionPanel from "./panels/ResolutionPanel";
|
||||||
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||||
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
||||||
|
|
@ -281,55 +306,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 격자 정보 계산
|
// 10px 격자 라인 생성 (시각적 가이드용)
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
|
||||||
|
|
||||||
const gridInfo = useMemo(() => {
|
|
||||||
if (!layout.gridSettings) return null;
|
|
||||||
|
|
||||||
// 캔버스 크기 계산 (해상도 설정 우선)
|
|
||||||
let width = screenResolution.width;
|
|
||||||
let height = screenResolution.height;
|
|
||||||
|
|
||||||
// 해상도가 설정되지 않은 경우 기본값 사용
|
|
||||||
if (!width || !height) {
|
|
||||||
width = canvasSize.width || window.innerWidth - 100;
|
|
||||||
height = canvasSize.height || window.innerHeight - 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newGridInfo = calculateGridInfo(width, height, {
|
|
||||||
columns: layout.gridSettings.columns,
|
|
||||||
gap: layout.gridSettings.gap,
|
|
||||||
padding: layout.gridSettings.padding,
|
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return newGridInfo;
|
|
||||||
}, [layout.gridSettings, screenResolution]);
|
|
||||||
|
|
||||||
// 격자 라인 생성
|
|
||||||
const gridLines = useMemo(() => {
|
const gridLines = useMemo(() => {
|
||||||
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
|
if (!layout.gridSettings?.showGrid) return [];
|
||||||
|
|
||||||
// 캔버스 크기는 해상도 크기 사용
|
|
||||||
const width = screenResolution.width;
|
const width = screenResolution.width;
|
||||||
const height = screenResolution.height;
|
const height = screenResolution.height;
|
||||||
|
const lines: Array<{ type: "vertical" | "horizontal"; position: number }> = [];
|
||||||
|
|
||||||
const lines = generateGridLines(width, height, {
|
// 10px 단위로 격자 라인 생성
|
||||||
columns: layout.gridSettings.columns,
|
for (let x = 0; x <= width; x += 10) {
|
||||||
gap: layout.gridSettings.gap,
|
lines.push({ type: "vertical", position: x });
|
||||||
padding: layout.gridSettings.padding,
|
}
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
for (let y = 0; y <= height; y += 10) {
|
||||||
});
|
lines.push({ type: "horizontal", position: y });
|
||||||
|
}
|
||||||
|
|
||||||
// 수직선과 수평선을 하나의 배열로 합치기
|
return lines;
|
||||||
const allLines = [
|
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
|
||||||
...lines.verticalLines.map((pos) => ({ type: "vertical" as const, position: pos })),
|
|
||||||
...lines.horizontalLines.map((pos) => ({ type: "horizontal" as const, position: pos })),
|
|
||||||
];
|
|
||||||
|
|
||||||
return allLines;
|
|
||||||
}, [gridInfo, layout.gridSettings, screenResolution]);
|
|
||||||
|
|
||||||
// 필터된 테이블 목록
|
// 필터된 테이블 목록
|
||||||
const filteredTables = useMemo(() => {
|
const filteredTables = useMemo(() => {
|
||||||
|
|
@ -527,64 +521,61 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const finalKey = pathParts[pathParts.length - 1];
|
const finalKey = pathParts[pathParts.length - 1];
|
||||||
current[finalKey] = value;
|
current[finalKey] = value;
|
||||||
|
|
||||||
// gridColumns 변경 시 크기 자동 업데이트
|
// gridColumns 변경 시 크기 자동 업데이트 제거 (격자 시스템 제거됨)
|
||||||
if (path === "gridColumns" && gridInfo) {
|
// if (path === "gridColumns" && prevLayout.gridSettings) {
|
||||||
const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
|
// const updatedSize = updateSizeFromGridColumns(newComp, prevLayout.gridSettings as GridUtilSettings);
|
||||||
newComp.size = updatedSize;
|
// newComp.size = updatedSize;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
|
// 크기 변경 시 격자 스냅 적용 제거 (직접 입력 시 불필요)
|
||||||
if (
|
// 드래그/리사이즈 시에는 별도 로직에서 처리됨
|
||||||
(path === "size.width" || path === "size.height") &&
|
// if (
|
||||||
prevLayout.gridSettings?.snapToGrid &&
|
// (path === "size.width" || path === "size.height") &&
|
||||||
gridInfo &&
|
// prevLayout.gridSettings?.snapToGrid &&
|
||||||
newComp.type !== "group"
|
// newComp.type !== "group"
|
||||||
) {
|
// ) {
|
||||||
// 현재 해상도에 맞는 격자 정보로 스냅 적용
|
// const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
// columns: prevLayout.gridSettings.columns,
|
||||||
columns: prevLayout.gridSettings.columns,
|
// gap: prevLayout.gridSettings.gap,
|
||||||
gap: prevLayout.gridSettings.gap,
|
// padding: prevLayout.gridSettings.padding,
|
||||||
padding: prevLayout.gridSettings.padding,
|
// snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
||||||
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
// });
|
||||||
});
|
// const snappedSize = snapSizeToGrid(
|
||||||
const snappedSize = snapSizeToGrid(
|
// newComp.size,
|
||||||
newComp.size,
|
// currentGridInfo,
|
||||||
currentGridInfo,
|
// prevLayout.gridSettings as GridUtilSettings,
|
||||||
prevLayout.gridSettings as GridUtilSettings,
|
// );
|
||||||
);
|
// newComp.size = snappedSize;
|
||||||
newComp.size = snappedSize;
|
//
|
||||||
|
// const adjustedColumns = adjustGridColumnsFromSize(
|
||||||
|
// newComp,
|
||||||
|
// currentGridInfo,
|
||||||
|
// prevLayout.gridSettings as GridUtilSettings,
|
||||||
|
// );
|
||||||
|
// if (newComp.gridColumns !== adjustedColumns) {
|
||||||
|
// newComp.gridColumns = adjustedColumns;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
// 크기 변경 시 gridColumns도 자동 조정
|
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정 제거 (격자 시스템 제거됨)
|
||||||
const adjustedColumns = adjustGridColumnsFromSize(
|
// if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
|
||||||
newComp,
|
// const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||||
currentGridInfo,
|
// columns: prevLayout.gridSettings.columns,
|
||||||
prevLayout.gridSettings as GridUtilSettings,
|
// gap: prevLayout.gridSettings.gap,
|
||||||
);
|
// padding: prevLayout.gridSettings.padding,
|
||||||
if (newComp.gridColumns !== adjustedColumns) {
|
// snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
||||||
newComp.gridColumns = adjustedColumns;
|
// });
|
||||||
}
|
//
|
||||||
}
|
// const newWidth = calculateWidthFromColumns(
|
||||||
|
// newComp.gridColumns,
|
||||||
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정
|
// currentGridInfo,
|
||||||
if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
|
// prevLayout.gridSettings as GridUtilSettings,
|
||||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
// );
|
||||||
columns: prevLayout.gridSettings.columns,
|
// newComp.size = {
|
||||||
gap: prevLayout.gridSettings.gap,
|
// ...newComp.size,
|
||||||
padding: prevLayout.gridSettings.padding,
|
// width: newWidth,
|
||||||
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
// };
|
||||||
});
|
// }
|
||||||
|
|
||||||
// gridColumns에 맞는 정확한 너비 계산
|
|
||||||
const newWidth = calculateWidthFromColumns(
|
|
||||||
newComp.gridColumns,
|
|
||||||
currentGridInfo,
|
|
||||||
prevLayout.gridSettings as GridUtilSettings,
|
|
||||||
);
|
|
||||||
newComp.size = {
|
|
||||||
...newComp.size,
|
|
||||||
width: newWidth,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
|
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
|
||||||
if (
|
if (
|
||||||
|
|
@ -634,7 +625,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
};
|
};
|
||||||
} else if (newComp.type !== "group") {
|
} else if (newComp.type !== "group") {
|
||||||
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
|
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
|
||||||
const snappedPosition = snapToGrid(
|
const snappedPosition = snapPositionTo10px(
|
||||||
newComp.position,
|
newComp.position,
|
||||||
currentGridInfo,
|
currentGridInfo,
|
||||||
layout.gridSettings as GridUtilSettings,
|
layout.gridSettings as GridUtilSettings,
|
||||||
|
|
@ -684,7 +675,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
return newLayout;
|
return newLayout;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[gridInfo, saveToHistory], // 🔧 layout, selectedComponent 제거!
|
[saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 컴포넌트 시스템 초기화
|
// 컴포넌트 시스템 초기화
|
||||||
|
|
@ -899,9 +890,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
layoutToUse = safeMigrateLayout(response, canvasWidth);
|
layoutToUse = safeMigrateLayout(response, canvasWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔄 webTypeConfig를 autoGeneration으로 변환
|
||||||
|
const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter");
|
||||||
|
const convertedComponents = convertLayoutComponents(layoutToUse.components);
|
||||||
|
|
||||||
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
|
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
|
||||||
const layoutWithDefaultGrid = {
|
const layoutWithDefaultGrid = {
|
||||||
...layoutToUse,
|
...layoutToUse,
|
||||||
|
components: convertedComponents, // 변환된 컴포넌트 사용
|
||||||
gridSettings: {
|
gridSettings: {
|
||||||
columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12
|
columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12
|
||||||
gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16
|
gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16
|
||||||
|
|
@ -1088,7 +1084,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
columns: newGridSettings.columns,
|
columns: newGridSettings.columns,
|
||||||
gap: newGridSettings.gap,
|
gap: newGridSettings.gap,
|
||||||
padding: newGridSettings.padding,
|
padding: newGridSettings.padding,
|
||||||
snapToGrid: newGridSettings.snapToGrid,
|
snapToGrid: true, // 항상 10px 스냅 활성화
|
||||||
};
|
};
|
||||||
|
|
||||||
const adjustedComponents = layout.components.map((comp) => {
|
const adjustedComponents = layout.components.map((comp) => {
|
||||||
|
|
@ -1203,7 +1199,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
columns: layout.gridSettings.columns,
|
columns: layout.gridSettings.columns,
|
||||||
gap: layout.gridSettings.gap,
|
gap: layout.gridSettings.gap,
|
||||||
padding: layout.gridSettings.padding,
|
padding: layout.gridSettings.padding,
|
||||||
snapToGrid: layout.gridSettings.snapToGrid,
|
snapToGrid: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
finalComponents = scaledComponents.map((comp) => {
|
finalComponents = scaledComponents.map((comp) => {
|
||||||
|
|
@ -1273,7 +1269,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
columns: layout.gridSettings.columns,
|
columns: layout.gridSettings.columns,
|
||||||
gap: layout.gridSettings.gap,
|
gap: layout.gridSettings.gap,
|
||||||
padding: layout.gridSettings.padding,
|
padding: layout.gridSettings.padding,
|
||||||
snapToGrid: layout.gridSettings.snapToGrid,
|
snapToGrid: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const adjustedComponents = layout.components.map((comp) => {
|
const adjustedComponents = layout.components.map((comp) => {
|
||||||
|
|
@ -1445,7 +1441,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 격자 스냅 적용
|
// 격자 스냅 적용
|
||||||
const finalPosition =
|
const finalPosition =
|
||||||
layout.gridSettings?.snapToGrid && currentGridInfo
|
layout.gridSettings?.snapToGrid && currentGridInfo
|
||||||
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
? snapPositionTo10px(
|
||||||
|
{ x: absoluteX, y: absoluteY, z: 1 },
|
||||||
|
currentGridInfo,
|
||||||
|
layout.gridSettings as GridUtilSettings,
|
||||||
|
)
|
||||||
: { x: absoluteX, y: absoluteY, z: 1 };
|
: { x: absoluteX, y: absoluteY, z: 1 };
|
||||||
|
|
||||||
if (templateComp.type === "container") {
|
if (templateComp.type === "container") {
|
||||||
|
|
@ -1511,7 +1511,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
templateSize: templateComp.size,
|
templateSize: templateComp.size,
|
||||||
calculatedSize,
|
calculatedSize,
|
||||||
hasGridInfo: !!currentGridInfo,
|
hasGridInfo: !!currentGridInfo,
|
||||||
hasGridSettings: !!layout.gridSettings?.snapToGrid,
|
hasGridSettings: !!layout.gridSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -1802,7 +1802,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
||||||
},
|
},
|
||||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
|
[layout, selectedScreen, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 레이아웃 드래그 처리
|
// 레이아웃 드래그 처리
|
||||||
|
|
@ -1811,8 +1811,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
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
|
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)
|
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
||||||
: { x: dropX, y: dropY, z: 1 };
|
: { x: dropX, y: dropY, z: 1 };
|
||||||
|
|
||||||
console.log("🏗️ 레이아웃 드롭:", {
|
console.log("🏗️ 레이아웃 드롭 (줌 보정):", {
|
||||||
|
zoomLevel,
|
||||||
layoutType: layoutData.layoutType,
|
layoutType: layoutData.layoutType,
|
||||||
zonesCount: layoutData.zones.length,
|
zonesCount: layoutData.zones.length,
|
||||||
|
mouseRaw: { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
||||||
dropPosition: { x: dropX, y: dropY },
|
dropPosition: { x: dropX, y: dropY },
|
||||||
snappedPosition,
|
snappedPosition,
|
||||||
});
|
});
|
||||||
|
|
@ -1869,7 +1872,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||||
},
|
},
|
||||||
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory],
|
[layout, screenResolution, saveToHistory, zoomLevel],
|
||||||
);
|
);
|
||||||
|
|
||||||
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
||||||
|
|
@ -1954,32 +1957,47 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const componentWidth = component.defaultSize?.width || 120;
|
const componentWidth = component.defaultSize?.width || 120;
|
||||||
const componentHeight = component.defaultSize?.height || 36;
|
const componentHeight = component.defaultSize?.height || 36;
|
||||||
|
|
||||||
// 방법 1: 마우스 포인터를 컴포넌트 중심으로 (현재 방식)
|
// 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산
|
||||||
const dropX_centered = e.clientX - rect.left - componentWidth / 2;
|
// 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center)
|
||||||
const dropY_centered = e.clientY - rect.top - componentHeight / 2;
|
// 2. 캔버스가 justify-center로 중앙 정렬되어 있음
|
||||||
|
|
||||||
// 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 (사용자가 원할 수도 있는 방식)
|
// 실제 캔버스 논리적 크기
|
||||||
const dropX_topleft = e.clientX - rect.left;
|
const canvasLogicalWidth = screenResolution.width;
|
||||||
const dropY_topleft = e.clientY - rect.top;
|
|
||||||
|
// 화면상 캔버스 실제 크기 (스케일 적용 후)
|
||||||
|
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 dropX = dropX_topleft;
|
||||||
const dropY = dropY_topleft;
|
const dropY = dropY_topleft;
|
||||||
|
|
||||||
console.log("🎯 위치 계산 디버깅:", {
|
console.log("🎯 위치 계산 디버깅 (줌 레벨 + 중앙정렬 반영):", {
|
||||||
"1. 마우스 위치": { clientX: e.clientX, clientY: e.clientY },
|
"1. 줌 레벨": zoomLevel,
|
||||||
"2. 캔버스 위치": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
"2. 마우스 위치 (화면)": { clientX: e.clientX, clientY: e.clientY },
|
||||||
"3. 캔버스 내 상대 위치": { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
"3. 캔버스 위치 (rect)": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||||
"4. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
|
"4. 캔버스 논리적 크기": { width: canvasLogicalWidth, height: screenResolution.height },
|
||||||
"5a. 중심 방식 좌상단": { x: dropX_centered, y: dropY_centered },
|
"5. 캔버스 시각적 크기": { width: canvasVisualWidth, height: screenResolution.height * zoomLevel },
|
||||||
"5b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
|
"6. 마우스 캔버스 내 상대위치 (줌 전)": { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
||||||
"6. 선택된 방식": { dropX, dropY },
|
"7. 마우스 캔버스 내 상대위치 (줌 보정)": { x: mouseXInCanvas, y: mouseYInCanvas },
|
||||||
"7. 예상 컴포넌트 중심": { x: dropX + componentWidth / 2, y: dropY + componentHeight / 2 },
|
"8. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
|
||||||
"8. 마우스와 중심 일치 확인": {
|
"9a. 중심 방식": { x: dropX_centered, y: dropY_centered },
|
||||||
match:
|
"9b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
|
||||||
Math.abs(dropX + componentWidth / 2 - (e.clientX - rect.left)) < 1 &&
|
"10. 최종 선택": { dropX, dropY },
|
||||||
Math.abs(dropY + componentHeight / 2 - (e.clientY - rect.top)) < 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 현재 해상도에 맞는 격자 정보 계산
|
// 현재 해상도에 맞는 격자 정보 계산
|
||||||
|
|
@ -1999,7 +2017,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 격자 스냅 적용
|
// 격자 스냅 적용
|
||||||
const snappedPosition =
|
const snappedPosition =
|
||||||
layout.gridSettings?.snapToGrid && currentGridInfo
|
layout.gridSettings?.snapToGrid && currentGridInfo
|
||||||
? snapToGrid({ x: boundedX, y: boundedY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
? snapPositionTo10px(
|
||||||
|
{ x: boundedX, y: boundedY, z: 1 },
|
||||||
|
currentGridInfo,
|
||||||
|
layout.gridSettings as GridUtilSettings,
|
||||||
|
)
|
||||||
: { x: boundedX, y: boundedY, z: 1 };
|
: { x: boundedX, y: boundedY, z: 1 };
|
||||||
|
|
||||||
console.log("🧩 컴포넌트 드롭:", {
|
console.log("🧩 컴포넌트 드롭:", {
|
||||||
|
|
@ -2108,21 +2130,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
|
// 10px 단위로 너비 스냅
|
||||||
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
if (layout.gridSettings?.snapToGrid) {
|
||||||
// gridColumns에 맞는 정확한 너비 계산
|
|
||||||
const calculatedWidth = calculateWidthFromColumns(
|
|
||||||
gridColumns,
|
|
||||||
gridInfo,
|
|
||||||
layout.gridSettings as GridUtilSettings,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 컴포넌트별 최소 크기 보장
|
|
||||||
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : component.defaultSize.width;
|
|
||||||
|
|
||||||
componentSize = {
|
componentSize = {
|
||||||
...component.defaultSize,
|
...component.defaultSize,
|
||||||
width: Math.max(calculatedWidth, minWidth),
|
width: snapTo10px(component.defaultSize.width),
|
||||||
|
height: snapTo10px(component.defaultSize.height),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2211,7 +2224,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
||||||
},
|
},
|
||||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
|
[layout, selectedScreen, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 드래그 앤 드롭 처리
|
// 드래그 앤 드롭 처리
|
||||||
|
|
@ -2286,74 +2299,44 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
};
|
};
|
||||||
} else if (type === "column") {
|
} else if (type === "column") {
|
||||||
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
|
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
|
||||||
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
|
|
||||||
const currentGridInfo = layout.gridSettings
|
|
||||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
|
||||||
columns: layout.gridSettings.columns,
|
|
||||||
gap: layout.gridSettings.gap,
|
|
||||||
padding: layout.gridSettings.padding,
|
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// 격자 스냅이 활성화된 경우 정확한 격자 크기로 생성, 아니면 기본값
|
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||||
const defaultWidth =
|
const getDefaultWidth = (widgetType: string): number => {
|
||||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
|
||||||
? calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
|
||||||
: 200;
|
|
||||||
|
|
||||||
console.log("🎯 컴포넌트 생성 시 크기 계산:", {
|
|
||||||
screenResolution: `${screenResolution.width}x${screenResolution.height}`,
|
|
||||||
gridSettings: layout.gridSettings,
|
|
||||||
currentGridInfo: currentGridInfo
|
|
||||||
? {
|
|
||||||
columnWidth: currentGridInfo.columnWidth.toFixed(2),
|
|
||||||
totalWidth: currentGridInfo.totalWidth,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
defaultWidth: defaultWidth.toFixed(2),
|
|
||||||
snapToGrid: layout.gridSettings?.snapToGrid,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 웹타입별 기본 그리드 컬럼 수 계산
|
|
||||||
const getDefaultGridColumns = (widgetType: string): number => {
|
|
||||||
const widthMap: Record<string, number> = {
|
const widthMap: Record<string, number> = {
|
||||||
// 텍스트 입력 계열 (넓게)
|
// 텍스트 입력 계열
|
||||||
text: 4, // 1/3 (33%)
|
text: 200,
|
||||||
email: 4, // 1/3 (33%)
|
email: 200,
|
||||||
tel: 3, // 1/4 (25%)
|
tel: 150,
|
||||||
url: 4, // 1/3 (33%)
|
url: 250,
|
||||||
textarea: 6, // 절반 (50%)
|
textarea: 300,
|
||||||
|
|
||||||
// 숫자/날짜 입력 (중간)
|
// 숫자/날짜 입력
|
||||||
number: 2, // 2/12 (16.67%)
|
number: 120,
|
||||||
decimal: 2, // 2/12 (16.67%)
|
decimal: 120,
|
||||||
date: 3, // 1/4 (25%)
|
date: 150,
|
||||||
datetime: 3, // 1/4 (25%)
|
datetime: 180,
|
||||||
time: 2, // 2/12 (16.67%)
|
time: 120,
|
||||||
|
|
||||||
// 선택 입력 (중간)
|
// 선택 입력
|
||||||
select: 3, // 1/4 (25%)
|
select: 180,
|
||||||
radio: 3, // 1/4 (25%)
|
radio: 180,
|
||||||
checkbox: 2, // 2/12 (16.67%)
|
checkbox: 120,
|
||||||
boolean: 2, // 2/12 (16.67%)
|
boolean: 120,
|
||||||
|
|
||||||
// 코드/참조 (넓게)
|
// 코드/참조
|
||||||
code: 3, // 1/4 (25%)
|
code: 180,
|
||||||
entity: 4, // 1/3 (33%)
|
entity: 200,
|
||||||
|
|
||||||
// 파일/이미지 (넓게)
|
// 파일/이미지
|
||||||
file: 4, // 1/3 (33%)
|
file: 250,
|
||||||
image: 3, // 1/4 (25%)
|
image: 200,
|
||||||
|
|
||||||
// 기타
|
// 기타
|
||||||
button: 2, // 2/12 (16.67%)
|
button: 100,
|
||||||
label: 2, // 2/12 (16.67%)
|
label: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultColumns = widthMap[widgetType] || 3; // 기본값 3 (1/4, 25%)
|
return widthMap[widgetType] || 200; // 기본값 200px
|
||||||
console.log("🎯 [ScreenDesigner] getDefaultGridColumns:", { widgetType, defaultColumns });
|
|
||||||
return defaultColumns;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 웹타입별 기본 높이 계산
|
// 웹타입별 기본 높이 계산
|
||||||
|
|
@ -2365,7 +2348,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
file: 240, // 파일 업로드 (40 * 6)
|
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);
|
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||||
// console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
|
// console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
|
||||||
|
|
||||||
// 웹타입별 적절한 gridColumns 계산
|
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||||
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
|
const componentWidth = getDefaultWidth(column.widgetType);
|
||||||
|
|
||||||
// gridColumns에 맞는 실제 너비 계산
|
|
||||||
const componentWidth =
|
|
||||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
|
||||||
? calculateWidthFromColumns(
|
|
||||||
calculatedGridColumns,
|
|
||||||
currentGridInfo,
|
|
||||||
layout.gridSettings as GridUtilSettings,
|
|
||||||
)
|
|
||||||
: defaultWidth;
|
|
||||||
|
|
||||||
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
|
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
|
||||||
widgetType: column.widgetType,
|
widgetType: column.widgetType,
|
||||||
calculatedGridColumns,
|
|
||||||
componentWidth,
|
componentWidth,
|
||||||
defaultWidth,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
|
|
@ -2553,25 +2524,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
gridColumns: calculatedGridColumns,
|
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
column.codeCategory && {
|
column.codeCategory && {
|
||||||
codeCategory: column.codeCategory,
|
codeCategory: column.codeCategory,
|
||||||
}),
|
}),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
labelDisplay: false, // 라벨 숨김
|
||||||
labelFontSize: "12px",
|
labelFontSize: "12px",
|
||||||
labelColor: "#212121",
|
labelColor: "#212121",
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
labelMarginBottom: "6px",
|
labelMarginBottom: "6px",
|
||||||
width: `${(calculatedGridColumns / (layout.gridSettings?.columns || 12)) * 100}%`, // 퍼센트 너비
|
|
||||||
},
|
},
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: componentId, // text-input, number-input 등
|
type: componentId, // text-input, number-input 등
|
||||||
webType: column.widgetType, // 원본 웹타입 보존
|
webType: column.widgetType, // 원본 웹타입 보존
|
||||||
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
||||||
...getDefaultWebTypeConfig(column.widgetType),
|
...getDefaultWebTypeConfig(column.widgetType),
|
||||||
|
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
column.codeCategory && {
|
column.codeCategory && {
|
||||||
|
|
@ -2587,36 +2557,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||||
// console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
|
// console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
|
||||||
|
|
||||||
// 웹타입별 적절한 gridColumns 계산
|
// 웹타입별 기본 너비 계산 (10px 단위 고정)
|
||||||
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
|
const componentWidth = getDefaultWidth(column.widgetType);
|
||||||
|
|
||||||
// gridColumns에 맞는 실제 너비 계산
|
|
||||||
const componentWidth =
|
|
||||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
|
||||||
? calculateWidthFromColumns(
|
|
||||||
calculatedGridColumns,
|
|
||||||
currentGridInfo,
|
|
||||||
layout.gridSettings as GridUtilSettings,
|
|
||||||
)
|
|
||||||
: defaultWidth;
|
|
||||||
|
|
||||||
console.log("🎯 캔버스 컴포넌트 생성:", {
|
console.log("🎯 캔버스 컴포넌트 생성:", {
|
||||||
widgetType: column.widgetType,
|
widgetType: column.widgetType,
|
||||||
calculatedGridColumns,
|
|
||||||
componentWidth,
|
componentWidth,
|
||||||
defaultWidth,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔍 이미지 타입 드래그앤드롭 디버깅
|
|
||||||
// if (column.widgetType === "image") {
|
|
||||||
// console.log("🖼️ 이미지 컬럼 드래그앤드롭:", {
|
|
||||||
// columnName: column.columnName,
|
|
||||||
// widgetType: column.widgetType,
|
|
||||||
// componentId,
|
|
||||||
// column,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
||||||
|
|
@ -2628,25 +2576,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||||
position: { x, y, z: 1 } as Position,
|
position: { x, y, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
gridColumns: calculatedGridColumns,
|
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
column.codeCategory && {
|
column.codeCategory && {
|
||||||
codeCategory: column.codeCategory,
|
codeCategory: column.codeCategory,
|
||||||
}),
|
}),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
|
labelDisplay: false, // 라벨 숨김
|
||||||
labelFontSize: "14px",
|
labelFontSize: "14px",
|
||||||
labelColor: "#000000", // 순수한 검정
|
labelColor: "#000000", // 순수한 검정
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
labelMarginBottom: "8px",
|
labelMarginBottom: "8px",
|
||||||
width: `${(calculatedGridColumns / (layout.gridSettings?.columns || 12)) * 100}%`, // 퍼센트 너비
|
|
||||||
},
|
},
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: componentId, // text-input, number-input 등
|
type: componentId, // text-input, number-input 등
|
||||||
webType: column.widgetType, // 원본 웹타입 보존
|
webType: column.widgetType, // 원본 웹타입 보존
|
||||||
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
||||||
...getDefaultWebTypeConfig(column.widgetType),
|
...getDefaultWebTypeConfig(column.widgetType),
|
||||||
|
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
column.codeCategory && {
|
column.codeCategory && {
|
||||||
|
|
@ -2659,31 +2606,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 격자 스냅 적용 (그룹 컴포넌트 제외)
|
// 10px 단위 스냅 적용 (그룹 컴포넌트 제외)
|
||||||
if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") {
|
if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") {
|
||||||
// 현재 해상도에 맞는 격자 정보 계산
|
newComponent.position = snapPositionTo10px(newComponent.position);
|
||||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
newComponent.size = snapSizeTo10px(newComponent.size);
|
||||||
columns: layout.gridSettings.columns,
|
|
||||||
gap: layout.gridSettings.gap,
|
|
||||||
padding: layout.gridSettings.padding,
|
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const gridUtilSettings = {
|
console.log("🧲 새 컴포넌트 10px 스냅 적용:", {
|
||||||
columns: layout.gridSettings.columns,
|
|
||||||
gap: layout.gridSettings.gap,
|
|
||||||
padding: layout.gridSettings.padding,
|
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
|
||||||
};
|
|
||||||
newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings);
|
|
||||||
newComponent.size = snapSizeToGrid(newComponent.size, currentGridInfo, gridUtilSettings);
|
|
||||||
|
|
||||||
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
|
|
||||||
type: newComponent.type,
|
type: newComponent.type,
|
||||||
resolution: `${screenResolution.width}x${screenResolution.height}`,
|
|
||||||
snappedPosition: newComponent.position,
|
snappedPosition: newComponent.position,
|
||||||
snappedSize: newComponent.size,
|
snappedSize: newComponent.size,
|
||||||
columnWidth: currentGridInfo.columnWidth,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2710,7 +2641,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// console.error("드롭 처리 실패:", error);
|
// console.error("드롭 처리 실패:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[layout, gridInfo, saveToHistory],
|
[layout, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 파일 컴포넌트 업데이트 처리
|
// 파일 컴포넌트 업데이트 처리
|
||||||
|
|
@ -2826,7 +2757,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 컴포넌트 드래그 시작
|
// 컴포넌트 드래그 시작
|
||||||
const startComponentDrag = useCallback(
|
const startComponentDrag = useCallback(
|
||||||
(component: ComponentData, event: React.MouseEvent) => {
|
(component: ComponentData, event: React.MouseEvent | React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
@ -2839,9 +2770,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
|
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
|
||||||
const relativeMouseX = event.clientX - rect.left;
|
// 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요
|
||||||
const relativeMouseY = event.clientY - rect.top;
|
const relativeMouseX = (event.clientX - rect.left) / zoomLevel;
|
||||||
|
const relativeMouseY = (event.clientY - rect.top) / zoomLevel;
|
||||||
|
|
||||||
// 다중 선택된 컴포넌트들 확인
|
// 다중 선택된 컴포넌트들 확인
|
||||||
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
|
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("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
|
||||||
console.log("마우스 위치:", {
|
console.log("마우스 위치 (줌 보정):", {
|
||||||
|
zoomLevel,
|
||||||
clientX: event.clientX,
|
clientX: event.clientX,
|
||||||
clientY: event.clientY,
|
clientY: event.clientY,
|
||||||
rectLeft: rect.left,
|
rectLeft: rect.left,
|
||||||
rectTop: rect.top,
|
rectTop: rect.top,
|
||||||
relativeX: relativeMouseX,
|
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||||
relativeY: relativeMouseY,
|
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
|
||||||
componentX: component.position.x,
|
componentX: component.position.x,
|
||||||
componentY: component.position.y,
|
componentY: component.position.y,
|
||||||
grabOffsetX: relativeMouseX - component.position.x,
|
grabOffsetX: relativeMouseX - component.position.x,
|
||||||
|
|
@ -2906,7 +2839,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
justFinishedDrag: false,
|
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 rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
|
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
|
||||||
const relativeMouseX = event.clientX - rect.left;
|
// 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요
|
||||||
const relativeMouseY = event.clientY - rect.top;
|
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);
|
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,
|
draggedComponentId: dragState.draggedComponent.id,
|
||||||
|
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||||
|
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
|
||||||
oldPosition: dragState.currentPosition,
|
oldPosition: dragState.currentPosition,
|
||||||
newPosition: newPosition,
|
newPosition: newPosition,
|
||||||
});
|
});
|
||||||
|
|
@ -2961,7 +2898,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 실제 레이아웃 업데이트는 endDrag에서 처리
|
// 실제 레이아웃 업데이트는 endDrag에서 처리
|
||||||
// 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시
|
// 속성 패널에서는 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) {
|
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
|
||||||
finalPosition = snapToGrid(
|
finalPosition = snapPositionTo10px(
|
||||||
{
|
{
|
||||||
x: dragState.currentPosition.x,
|
x: dragState.currentPosition.x,
|
||||||
y: dragState.currentPosition.y,
|
y: dragState.currentPosition.y,
|
||||||
|
|
@ -3143,7 +3080,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
justFinishedDrag: false,
|
justFinishedDrag: false,
|
||||||
}));
|
}));
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [dragState, layout, gridInfo, saveToHistory]);
|
}, [dragState, layout, saveToHistory]);
|
||||||
|
|
||||||
// 드래그 선택 시작
|
// 드래그 선택 시작
|
||||||
const startSelectionDrag = useCallback(
|
const startSelectionDrag = useCallback(
|
||||||
|
|
@ -3638,8 +3575,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
console.log("🔧 그룹 생성 시작:", {
|
console.log("🔧 그룹 생성 시작:", {
|
||||||
selectedCount: selectedComponents.length,
|
selectedCount: selectedComponents.length,
|
||||||
snapToGrid: layout.gridSettings?.snapToGrid,
|
snapToGrid: true,
|
||||||
gridInfo: currentGridInfo,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 컴포넌트 크기 조정 기반 그룹 크기 계산
|
// 컴포넌트 크기 조정 기반 그룹 크기 계산
|
||||||
|
|
@ -3803,12 +3739,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
size: optimizedGroupSize,
|
size: optimizedGroupSize,
|
||||||
gridColumns: groupComponent.gridColumns,
|
gridColumns: groupComponent.gridColumns,
|
||||||
componentsScaled: !!scaledComponents.length,
|
componentsScaled: !!scaledComponents.length,
|
||||||
gridAligned: layout.gridSettings?.snapToGrid,
|
gridAligned: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`);
|
toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`);
|
||||||
},
|
},
|
||||||
[layout, saveToHistory, gridInfo],
|
[layout, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 그룹 생성 함수 (다이얼로그 표시)
|
// 그룹 생성 함수 (다이얼로그 표시)
|
||||||
|
|
@ -3904,36 +3840,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
endSelectionDrag,
|
endSelectionDrag,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 캔버스 크기 초기화 및 리사이즈 이벤트 처리
|
|
||||||
useEffect(() => {
|
|
||||||
const updateCanvasSize = () => {
|
|
||||||
if (canvasRef.current) {
|
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
|
||||||
setCanvasSize({ width: rect.width, height: rect.height });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 초기 크기 설정
|
|
||||||
updateCanvasSize();
|
|
||||||
|
|
||||||
// 리사이즈 이벤트 리스너
|
|
||||||
window.addEventListener("resize", updateCanvasSize);
|
|
||||||
|
|
||||||
return () => window.removeEventListener("resize", updateCanvasSize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 컴포넌트 마운트 후 캔버스 크기 업데이트
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (canvasRef.current) {
|
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
|
||||||
setCanvasSize({ width: rect.width, height: rect.height });
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [selectedScreen]);
|
|
||||||
|
|
||||||
// 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단)
|
// 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||||
|
|
@ -4222,7 +4128,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenPreviewProvider isPreviewMode={true}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
<div className="bg-background flex h-full w-full flex-col">
|
<div className="bg-background flex h-full w-full flex-col">
|
||||||
{/* 상단 슬림 툴바 */}
|
{/* 상단 슬림 툴바 */}
|
||||||
<SlimToolbar
|
<SlimToolbar
|
||||||
|
|
@ -4335,7 +4241,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg">
|
<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="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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="14"
|
width="14"
|
||||||
|
|
@ -4416,7 +4322,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
|
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
|
||||||
<div
|
<div
|
||||||
className="flex justify-center"
|
className="flex justify-center"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -4435,7 +4341,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
minHeight: `${screenResolution.height}px`,
|
minHeight: `${screenResolution.height}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
transform: `scale(${zoomLevel})`,
|
transform: `scale(${zoomLevel})`,
|
||||||
transformOrigin: "top center",
|
transformOrigin: "top center", // 중앙 기준으로 스케일
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -752,17 +752,27 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
// console.log("🎨 selectedComponent 전체:", selectedComponent);
|
// console.log("🎨 selectedComponent 전체:", selectedComponent);
|
||||||
|
|
||||||
const handleConfigChange = (newConfig: WebTypeConfig) => {
|
const handleConfigChange = (newConfig: WebTypeConfig) => {
|
||||||
// console.log("🔧 WebTypeConfig 업데이트:", {
|
|
||||||
// widgetType: widget.widgetType,
|
|
||||||
// oldConfig: currentConfig,
|
|
||||||
// newConfig,
|
|
||||||
// componentId: widget.id,
|
|
||||||
// isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig),
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 강제 새 객체 생성으로 React 변경 감지 보장
|
// 강제 새 객체 생성으로 React 변경 감지 보장
|
||||||
const freshConfig = { ...newConfig };
|
const freshConfig = { ...newConfig };
|
||||||
onUpdateProperty(widget.id, "webTypeConfig", freshConfig);
|
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에서 지정된 설정 패널 사용
|
// 1순위: DB에서 지정된 설정 패널 사용
|
||||||
|
|
@ -776,7 +786,13 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
|
|
||||||
if (ConfigPanelComponent) {
|
if (ConfigPanelComponent) {
|
||||||
// console.log(`🎨 ✅ ConfigPanelComponent 렌더링 시작`);
|
// console.log(`🎨 ✅ ConfigPanelComponent 렌더링 시작`);
|
||||||
return <ConfigPanelComponent config={currentConfig} onConfigChange={handleConfigChange} />;
|
return (
|
||||||
|
<ConfigPanelComponent
|
||||||
|
config={currentConfig}
|
||||||
|
onConfigChange={handleConfigChange}
|
||||||
|
tableName={currentTableName} // 화면 테이블명 전달
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// console.log(`🎨 ❌ ConfigPanelComponent가 null - WebTypeConfigPanel 사용`);
|
// console.log(`🎨 ❌ ConfigPanelComponent가 null - WebTypeConfigPanel 사용`);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,20 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap, RefreshCw } from "lucide-react";
|
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
|
||||||
import { GridSettings, ScreenResolution } from "@/types/screen";
|
import { GridSettings, ScreenResolution } from "@/types/screen";
|
||||||
import { calculateGridInfo } from "@/lib/utils/gridUtils";
|
|
||||||
|
|
||||||
interface GridPanelProps {
|
interface GridPanelProps {
|
||||||
gridSettings: GridSettings;
|
gridSettings: GridSettings;
|
||||||
onGridSettingsChange: (settings: GridSettings) => void;
|
onGridSettingsChange: (settings: GridSettings) => void;
|
||||||
onResetGrid: () => void;
|
onResetGrid: () => void;
|
||||||
onForceGridUpdate?: () => void; // 강제 격자 재조정 추가
|
screenResolution?: ScreenResolution;
|
||||||
screenResolution?: ScreenResolution; // 해상도 정보 추가
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GridPanel: React.FC<GridPanelProps> = ({
|
export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
gridSettings,
|
gridSettings,
|
||||||
onGridSettingsChange,
|
onGridSettingsChange,
|
||||||
onResetGrid,
|
onResetGrid,
|
||||||
onForceGridUpdate,
|
|
||||||
screenResolution,
|
screenResolution,
|
||||||
}) => {
|
}) => {
|
||||||
const updateSetting = (key: keyof GridSettings, value: any) => {
|
const updateSetting = (key: keyof GridSettings, value: any) => {
|
||||||
|
|
@ -33,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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
|
|
@ -62,26 +40,11 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
<h3 className="text-sm font-semibold">격자 설정</h3>
|
<h3 className="text-sm font-semibold">격자 설정</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{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">
|
<Button size="sm" variant="outline" onClick={onResetGrid} className="h-7 px-2 text-xs">
|
||||||
<RotateCcw className="mr-1 h-3 w-3" />
|
<RotateCcw className="mr-1 h-3 w-3" />
|
||||||
초기화
|
초기화
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 주요 토글들 */}
|
{/* 주요 토글들 */}
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
|
|
@ -121,82 +84,14 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
|
|
||||||
{/* 설정 영역 */}
|
{/* 설정 영역 */}
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
{/* 격자 구조 */}
|
{/* 10px 단위 스냅 안내 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-xs font-semibold">격자 구조</h4>
|
<h4 className="text-xs font-semibold">격자 시스템</h4>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="bg-muted/50 rounded-md p-3">
|
||||||
<Label htmlFor="columns" className="text-xs font-medium">
|
<p className="text-xs text-muted-foreground">
|
||||||
컬럼 수
|
모든 컴포넌트는 10px 단위로 자동 배치됩니다.
|
||||||
</Label>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
id="columns"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -204,10 +99,10 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
|
|
||||||
{/* 격자 스타일 */}
|
{/* 격자 스타일 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium text-gray-900">격자 스타일</h4>
|
<h4 className="text-xs font-semibold">격자 스타일</h4>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="gridColor" className="text-sm font-medium">
|
<Label htmlFor="gridColor" className="text-xs font-medium">
|
||||||
격자 색상
|
격자 색상
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-1 flex items-center space-x-2">
|
<div className="mt-1 flex items-center space-x-2">
|
||||||
|
|
@ -223,13 +118,13 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
value={gridSettings.gridColor || "#d1d5db"}
|
value={gridSettings.gridColor || "#d1d5db"}
|
||||||
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
||||||
placeholder="#d1d5db"
|
placeholder="#d1d5db"
|
||||||
className="flex-1"
|
className="flex-1 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="gridOpacity" className="mb-2 block text-sm font-medium">
|
<Label htmlFor="gridOpacity" className="mb-2 block text-xs font-medium">
|
||||||
격자 투명도: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
|
격자 투명도: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
|
||||||
</Label>
|
</Label>
|
||||||
<Slider
|
<Slider
|
||||||
|
|
@ -241,7 +136,7 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
onValueChange={([value]) => updateSetting("gridOpacity", value)}
|
onValueChange={([value]) => updateSetting("gridOpacity", value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||||
<span>10%</span>
|
<span>10%</span>
|
||||||
<span>100%</span>
|
<span>100%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -252,38 +147,33 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
|
|
||||||
{/* 미리보기 */}
|
{/* 미리보기 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="font-medium text-gray-900">미리보기</h4>
|
<h4 className="text-xs font-semibold">미리보기</h4>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="rounded-md border border-gray-200 bg-white p-4"
|
className="rounded-md border bg-white p-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: gridSettings.showGrid
|
backgroundImage: gridSettings.showGrid
|
||||||
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
|
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
|
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
|
||||||
: "none",
|
: "none",
|
||||||
backgroundSize: gridSettings.showGrid ? `${100 / gridSettings.columns}% 20px` : "none",
|
backgroundSize: gridSettings.showGrid ? "10px 10px" : "none",
|
||||||
opacity: gridSettings.gridOpacity || 0.5,
|
opacity: gridSettings.gridOpacity || 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-primary/20 flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300">
|
<div className="bg-primary/20 flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300">
|
||||||
<span className="text-primary text-xs">컴포넌트 예시</span>
|
<span className="text-primary text-xs">10px 격자</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 푸터 */}
|
{/* 푸터 */}
|
||||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
<div className="border-t bg-muted/30 p-3">
|
||||||
<div className="text-muted-foreground text-xs">💡 격자 설정은 실시간으로 캔버스에 반영됩니다 </div>
|
{screenResolution && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-semibold">화면 정보</h4>
|
||||||
|
|
||||||
{/* 해상도 및 격자 정보 */}
|
<div className="space-y-2 text-xs">
|
||||||
{screenResolution && actualGridInfo && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="font-medium text-gray-900">격자 정보</h4>
|
|
||||||
|
|
||||||
<div className="space-y-2 text-xs" style={{ fontSize: "12px" }}>
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">해상도:</span>
|
<span className="text-muted-foreground">해상도:</span>
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
|
|
@ -292,28 +182,11 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">컬럼 너비:</span>
|
<span className="text-muted-foreground">격자 단위:</span>
|
||||||
<span className={`font-mono ${isColumnsTooSmall ? "text-destructive" : "text-gray-900"}`}>
|
<span className="font-mono text-primary">10px</span>
|
||||||
{actualGridInfo.columnWidth.toFixed(1)}px
|
</div>
|
||||||
{isColumnsTooSmall && " (너무 작음)"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">사용 가능 너비:</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{(screenResolution.width - gridSettings.padding * 2).toLocaleString()}px
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isColumnsTooSmall && (
|
|
||||||
<div className="rounded-md bg-yellow-50 p-2 text-xs text-yellow-800">
|
|
||||||
💡 컬럼이 너무 작습니다. 컬럼 수를 줄이거나 간격을 줄여보세요.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,22 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||||
onDragStart,
|
onDragStart,
|
||||||
placedColumns = new Set(),
|
placedColumns = new Set(),
|
||||||
}) => {
|
}) => {
|
||||||
// 이미 배치된 컬럼을 제외한 테이블 정보 생성
|
// 시스템 컬럼 목록 (숨김 처리)
|
||||||
|
const systemColumns = new Set([
|
||||||
|
'id',
|
||||||
|
'created_date',
|
||||||
|
'updated_date',
|
||||||
|
'writer',
|
||||||
|
'company_code'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
|
||||||
const tablesWithAvailableColumns = tables.map((table) => ({
|
const tablesWithAvailableColumns = tables.map((table) => ({
|
||||||
...table,
|
...table,
|
||||||
columns: table.columns.filter((col) => {
|
columns: table.columns.filter((col) => {
|
||||||
const columnKey = `${table.tableName}.${col.columnName}`;
|
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 { webTypes } = useWebTypes({ active: "Y" });
|
||||||
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
||||||
|
|
||||||
// 높이 입력 로컬 상태 (격자 스냅 방지)
|
// 높이/너비 입력 로컬 상태 (자유 입력 허용)
|
||||||
const [localHeight, setLocalHeight] = useState<string>("");
|
const [localHeight, setLocalHeight] = useState<string>("");
|
||||||
|
const [localWidth, setLocalWidth] = useState<string>("");
|
||||||
|
|
||||||
// 새로운 컴포넌트 시스템의 webType 동기화
|
// 새로운 컴포넌트 시스템의 webType 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -125,6 +126,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
}
|
}
|
||||||
}, [selectedComponent?.size?.height, selectedComponent?.id]);
|
}, [selectedComponent?.size?.height, selectedComponent?.id]);
|
||||||
|
|
||||||
|
// 너비 값 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedComponent?.size?.width !== undefined) {
|
||||||
|
setLocalWidth(String(selectedComponent.size.width));
|
||||||
|
}
|
||||||
|
}, [selectedComponent?.size?.width, selectedComponent?.id]);
|
||||||
|
|
||||||
// 격자 설정 업데이트 함수 (early return 이전에 정의)
|
// 격자 설정 업데이트 함수 (early return 이전에 정의)
|
||||||
const updateGridSetting = (key: string, value: any) => {
|
const updateGridSetting = (key: string, value: any) => {
|
||||||
if (onGridSettingsChange && gridSettings) {
|
if (onGridSettingsChange && gridSettings) {
|
||||||
|
|
@ -139,6 +147,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
const renderGridSettings = () => {
|
const renderGridSettings = () => {
|
||||||
if (!gridSettings || !onGridSettingsChange) return null;
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|
@ -180,65 +195,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 수 */}
|
{/* 10px 단위 스냅 안내 */}
|
||||||
<div className="space-y-1">
|
<div className="bg-muted/50 rounded-md p-2">
|
||||||
<Label htmlFor="columns" className="text-xs font-medium">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
컬럼 수
|
모든 컴포넌트는 10px 단위로 자동 배치됩니다.
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
id="columns"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
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 이상의 숫자를 입력하세요
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 간격 */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="gap" className="text-xs font-medium">
|
|
||||||
간격: <span className="text-primary">{gridSettings.gap}px</span>
|
|
||||||
</Label>
|
|
||||||
<Slider
|
|
||||||
id="gap"
|
|
||||||
min={0}
|
|
||||||
max={40}
|
|
||||||
step={2}
|
|
||||||
value={[gridSettings.gap]}
|
|
||||||
onValueChange={([value]) => updateGridSetting("gap", value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 여백 */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="padding" className="text-xs font-medium">
|
|
||||||
여백: <span className="text-primary">{gridSettings.padding}px</span>
|
|
||||||
</Label>
|
|
||||||
<Slider
|
|
||||||
id="padding"
|
|
||||||
min={0}
|
|
||||||
max={60}
|
|
||||||
step={4}
|
|
||||||
value={[gridSettings.padding]}
|
|
||||||
onValueChange={([value]) => updateGridSetting("padding", value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -374,22 +336,26 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
type="number"
|
type="number"
|
||||||
value={localHeight}
|
value={localHeight}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
// 입력 중에는 로컬 상태만 업데이트 (격자 스냅 방지)
|
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
|
||||||
setLocalHeight(e.target.value);
|
setLocalHeight(e.target.value);
|
||||||
}}
|
}}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
// 포커스를 잃을 때만 실제로 업데이트
|
// 포커스를 잃을 때 10px 단위로 스냅
|
||||||
const value = parseInt(e.target.value) || 0;
|
const value = parseInt(e.target.value) || 0;
|
||||||
if (value >= 1) {
|
if (value >= 10) {
|
||||||
handleUpdate("size.height", value);
|
const snappedValue = Math.round(value / 10) * 10;
|
||||||
|
handleUpdate("size.height", snappedValue);
|
||||||
|
setLocalHeight(String(snappedValue));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Enter 키를 누르면 즉시 적용
|
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
const value = parseInt(e.currentTarget.value) || 0;
|
const value = parseInt(e.currentTarget.value) || 0;
|
||||||
if (value >= 1) {
|
if (value >= 10) {
|
||||||
handleUpdate("size.height", value);
|
const snappedValue = Math.round(value / 10) * 10;
|
||||||
|
handleUpdate("size.height", snappedValue);
|
||||||
|
setLocalHeight(String(snappedValue));
|
||||||
}
|
}
|
||||||
e.currentTarget.blur(); // 포커스 제거
|
e.currentTarget.blur(); // 포커스 제거
|
||||||
}
|
}
|
||||||
|
|
@ -447,38 +413,47 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Grid Columns + Z-Index (같은 행) */}
|
{/* Width + Z-Index (같은 행) */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{(selectedComponent as any).gridColumns !== undefined && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">차지 컬럼 수</Label>
|
<Label className="text-xs">너비 (px)</Label>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={10}
|
||||||
max={gridSettings?.columns || 12}
|
max={3840}
|
||||||
step="1"
|
step="1"
|
||||||
value={(selectedComponent as any).gridColumns || 1}
|
value={localWidth}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
|
||||||
|
setLocalWidth(e.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// 포커스를 잃을 때 10px 단위로 스냅
|
||||||
const value = parseInt(e.target.value, 10);
|
const value = parseInt(e.target.value, 10);
|
||||||
const maxColumns = gridSettings?.columns || 12;
|
if (!isNaN(value) && value >= 10) {
|
||||||
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
|
const snappedValue = Math.round(value / 10) * 10;
|
||||||
handleUpdate("gridColumns", value);
|
handleUpdate("size.width", snappedValue);
|
||||||
|
setLocalWidth(String(snappedValue));
|
||||||
// width를 퍼센트로 계산하여 업데이트
|
}
|
||||||
const widthPercent = (value / maxColumns) * 100;
|
}}
|
||||||
handleUpdate("style.width", `${widthPercent}%`);
|
onKeyDown={(e) => {
|
||||||
|
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const value = parseInt(e.currentTarget.value, 10);
|
||||||
|
if (!isNaN(value) && value >= 10) {
|
||||||
|
const snappedValue = Math.round(value / 10) * 10;
|
||||||
|
handleUpdate("size.width", snappedValue);
|
||||||
|
setLocalWidth(String(snappedValue));
|
||||||
|
}
|
||||||
|
e.currentTarget.blur(); // 포커스 제거
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="h-6 w-full px-2 py-0 text-xs"
|
className="h-6 w-full px-2 py-0 text-xs"
|
||||||
style={{ fontSize: "12px" }}
|
style={{ fontSize: "12px" }}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
|
|
||||||
/{gridSettings?.columns || 12}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Z-Index</Label>
|
<Label className="text-xs">Z-Index</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,24 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { TextTypeConfig } from "@/types/screen";
|
import { TextTypeConfig } from "@/types/screen";
|
||||||
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
|
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
||||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||||
|
|
||||||
interface TextTypeConfigPanelProps {
|
interface TextTypeConfigPanelProps {
|
||||||
config: TextTypeConfig;
|
config: TextTypeConfig;
|
||||||
onConfigChange: (config: TextTypeConfig) => void;
|
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 사용
|
// 기본값이 설정된 config 사용
|
||||||
const safeConfig = {
|
const safeConfig = {
|
||||||
minLength: undefined,
|
minLength: undefined,
|
||||||
|
|
@ -54,16 +63,46 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
||||||
// 채번 규칙 목록 로드
|
// 채번 규칙 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadRules = async () => {
|
const loadRules = async () => {
|
||||||
|
console.log("🔄 채번 규칙 로드 시작:", {
|
||||||
|
autoValueType: localValues.autoValueType,
|
||||||
|
tableName,
|
||||||
|
hasTableName: !!tableName,
|
||||||
|
});
|
||||||
|
|
||||||
setLoadingRules(true);
|
setLoadingRules(true);
|
||||||
try {
|
try {
|
||||||
// TODO: 현재 메뉴 objid를 화면 정보에서 가져와야 함
|
let response;
|
||||||
// 지금은 menuObjid 없이 호출 (global 규칙만 조회)
|
|
||||||
const response = await getAvailableNumberingRules();
|
// 테이블명이 있으면 테이블 기반 필터링 사용
|
||||||
|
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) {
|
if (response.success && response.data) {
|
||||||
setNumberingRules(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) {
|
} catch (error) {
|
||||||
console.error("채번 규칙 목록 로드 실패:", error);
|
console.error("❌ 채번 규칙 목록 로드 실패:", error);
|
||||||
|
setNumberingRules([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRules(false);
|
setLoadingRules(false);
|
||||||
}
|
}
|
||||||
|
|
@ -71,9 +110,12 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
||||||
|
|
||||||
// autoValueType이 numbering_rule일 때만 로드
|
// autoValueType이 numbering_rule일 때만 로드
|
||||||
if (localValues.autoValueType === "numbering_rule") {
|
if (localValues.autoValueType === "numbering_rule") {
|
||||||
|
console.log("✅ autoValueType === 'numbering_rule', 규칙 로드 시작");
|
||||||
loadRules();
|
loadRules();
|
||||||
|
} else {
|
||||||
|
console.log("⏭️ autoValueType !== 'numbering_rule', 규칙 로드 스킵:", localValues.autoValueType);
|
||||||
}
|
}
|
||||||
}, [localValues.autoValueType]);
|
}, [localValues.autoValueType, tableName]);
|
||||||
|
|
||||||
// config가 변경될 때 로컬 상태 동기화
|
// config가 변경될 때 로컬 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,17 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||||
required,
|
required,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
isDesignMode = false, // 디자인 모드 플래그
|
||||||
|
...restProps
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = () => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
// 디자인 모드에서는 아무것도 하지 않고 그냥 이벤트 전파
|
||||||
|
if (isDesignMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
|
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
|
||||||
// console.log("Button clicked:", config);
|
console.log("Button clicked:", config);
|
||||||
|
|
||||||
// onChange를 통해 클릭 이벤트 전달
|
// onChange를 통해 클릭 이벤트 전달
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
|
|
@ -25,6 +32,25 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단
|
||||||
|
if (isDesignMode) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파
|
||||||
|
className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white ${className || ""} `}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
cursor: "pointer", // 선택 가능하도록 포인터 표시
|
||||||
|
}}
|
||||||
|
title={config?.tooltip || placeholder}
|
||||||
|
>
|
||||||
|
{config?.label || config?.text || value || placeholder || "버튼"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ interface ResizableDialogContentProps
|
||||||
modalId?: string; // localStorage 저장용 고유 ID
|
modalId?: string; // localStorage 저장용 고유 ID
|
||||||
userId?: string; // 사용자별 저장용
|
userId?: string; // 사용자별 저장용
|
||||||
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
|
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
|
||||||
|
disableFlexLayout?: boolean; // 🆕 flex 레이아웃 비활성화 (absolute 레이아웃용)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResizableDialogContent = React.forwardRef<
|
const ResizableDialogContent = React.forwardRef<
|
||||||
|
|
@ -74,6 +75,7 @@ const ResizableDialogContent = React.forwardRef<
|
||||||
modalId,
|
modalId,
|
||||||
userId = "guest",
|
userId = "guest",
|
||||||
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
|
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
|
||||||
|
disableFlexLayout = false, // 🆕 flex 레이아웃 비활성화
|
||||||
style: userStyle,
|
style: userStyle,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -373,7 +375,11 @@ const ResizableDialogContent = React.forwardRef<
|
||||||
minHeight: `${minHeight}px`,
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConf
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴별 사용 가능한 채번 규칙 조회
|
* 메뉴별 사용 가능한 채번 규칙 조회 (기존 방식, 하위 호환성 유지)
|
||||||
* @param menuObjid 현재 메뉴의 objid (선택)
|
* @param menuObjid 현재 메뉴의 objid (선택)
|
||||||
* @returns 사용 가능한 채번 규칙 목록
|
* @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>> {
|
export async function getNumberingRuleById(ruleId: string): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/numbering-rules/${ruleId}`);
|
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 tableName = (component as any).tableName;
|
||||||
const columnName = (component as any).columnName;
|
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이 있는 경우만
|
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||||
if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
||||||
console.log("✅ 카테고리 타입 감지 → CategorySelectComponent 렌더링");
|
|
||||||
try {
|
try {
|
||||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||||
const fieldName = columnName || component.id;
|
const fieldName = columnName || component.id;
|
||||||
|
|
@ -303,14 +292,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
componentType === "split-panel-layout" ||
|
componentType === "split-panel-layout" ||
|
||||||
componentType?.includes("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 || {};
|
const { height: _height, ...styleWithoutHeight } = component.style || {};
|
||||||
|
|
||||||
// 숨김 값 추출
|
// 숨김 값 추출
|
||||||
|
|
|
||||||
|
|
@ -528,14 +528,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// 공통 버튼 스타일
|
||||||
<>
|
const buttonElementStyle: React.CSSProperties = {
|
||||||
<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%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: "40px",
|
minHeight: "40px",
|
||||||
|
|
@ -562,14 +556,36 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
...(isInteractive && component.style ? Object.fromEntries(
|
...(isInteractive && component.style ? Object.fromEntries(
|
||||||
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
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}>
|
||||||
|
{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}
|
onClick={handleClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
{/* 🔧 빈 문자열도 허용 (undefined일 때만 기본값 적용) */}
|
{buttonContent}
|
||||||
{processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"}
|
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
||||||
|
|
|
||||||
|
|
@ -74,20 +74,12 @@ export const CategorySelectComponent: React.FC<
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("📦 카테고리 값 조회:", { tableName, columnName });
|
|
||||||
|
|
||||||
const response = await getCategoryValues(tableName, columnName);
|
const response = await getCategoryValues(tableName, columnName);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// 활성화된 값만 필터링
|
// 활성화된 값만 필터링
|
||||||
const activeValues = response.data.filter((v) => v.isActive !== false);
|
const activeValues = response.data.filter((v) => v.isActive !== false);
|
||||||
setCategoryValues(activeValues);
|
setCategoryValues(activeValues);
|
||||||
|
|
||||||
console.log("✅ 카테고리 값 조회 성공:", {
|
|
||||||
total: response.data.length,
|
|
||||||
active: activeValues.length,
|
|
||||||
values: activeValues,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setError("카테고리 값을 불러올 수 없습니다");
|
setError("카테고리 값을 불러올 수 없습니다");
|
||||||
console.error("❌ 카테고리 값 조회 실패:", response);
|
console.error("❌ 카테고리 값 조회 실패:", response);
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
// daterange 타입 전용 UI
|
// daterange 타입 전용 UI
|
||||||
if (webType === "daterange") {
|
if (webType === "daterange") {
|
||||||
return (
|
return (
|
||||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
<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(
|
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",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||||
|
|
@ -325,7 +325,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
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)
|
// year 타입 전용 UI (number input with YYYY format)
|
||||||
if (webType === "year") {
|
if (webType === "year") {
|
||||||
return (
|
return (
|
||||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
<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(
|
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",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||||
|
|
@ -380,7 +380,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
<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}
|
required={componentConfig.required || false}
|
||||||
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
||||||
className={cn(
|
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",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,24 @@ interface NumberingRuleWrapperProps {
|
||||||
config: NumberingRuleComponentConfig;
|
config: NumberingRuleComponentConfig;
|
||||||
onChange?: (config: NumberingRuleComponentConfig) => void;
|
onChange?: (config: NumberingRuleComponentConfig) => void;
|
||||||
isPreview?: boolean;
|
isPreview?: boolean;
|
||||||
|
tableName?: string; // 현재 화면의 테이블명
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
|
export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isPreview = false,
|
isPreview = false,
|
||||||
|
tableName,
|
||||||
}) => {
|
}) => {
|
||||||
|
console.log("📋 NumberingRuleWrapper: 테이블명 전달", { tableName, config });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<NumberingRuleDesigner
|
<NumberingRuleDesigner
|
||||||
maxRules={config.maxRules || 6}
|
maxRules={config.maxRules || 6}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
currentTableName={tableName} // 테이블명 전달
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// searchTerm 제거 - 클라이언트 사이드에서 필터링
|
// 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);
|
const hierarchicalData = buildHierarchy(result.data);
|
||||||
setLeftData(hierarchicalData);
|
setLeftData(hierarchicalData);
|
||||||
|
|
@ -173,7 +183,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingLeft(false);
|
setIsLoadingLeft(false);
|
||||||
}
|
}
|
||||||
}, [componentConfig.leftPanel?.tableName, isDesignMode, toast, buildHierarchy]);
|
}, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, buildHierarchy]);
|
||||||
|
|
||||||
// 우측 데이터 로드
|
// 우측 데이터 로드
|
||||||
const loadRightData = useCallback(
|
const loadRightData = useCallback(
|
||||||
|
|
@ -293,9 +303,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 추가 버튼 핸들러
|
// 추가 버튼 핸들러
|
||||||
const handleAddClick = useCallback((panel: "left" | "right") => {
|
const handleAddClick = useCallback((panel: "left" | "right") => {
|
||||||
setAddModalPanel(panel);
|
setAddModalPanel(panel);
|
||||||
|
|
||||||
|
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
|
||||||
|
if (panel === "right" && selectedLeftItem && componentConfig.leftPanel?.leftColumn && componentConfig.rightPanel?.rightColumn) {
|
||||||
|
const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn];
|
||||||
|
setAddModalFormData({
|
||||||
|
[componentConfig.rightPanel.rightColumn]: leftColumnValue
|
||||||
|
});
|
||||||
|
} else {
|
||||||
setAddModalFormData({});
|
setAddModalFormData({});
|
||||||
|
}
|
||||||
|
|
||||||
setShowAddModal(true);
|
setShowAddModal(true);
|
||||||
}, []);
|
}, [selectedLeftItem, componentConfig]);
|
||||||
|
|
||||||
// 수정 버튼 핸들러
|
// 수정 버튼 핸들러
|
||||||
const handleEditClick = useCallback((panel: "left" | "right", item: any) => {
|
const handleEditClick = useCallback((panel: "left" | "right", item: any) => {
|
||||||
|
|
@ -1316,10 +1336,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
return modalColumns?.map((col, index) => {
|
return modalColumns?.map((col, index) => {
|
||||||
// 항목별 추가 버튼으로 열렸을 때, parentColumn은 미리 채워져 있고 수정 불가
|
// 항목별 추가 버튼으로 열렸을 때, parentColumn은 미리 채워져 있고 수정 불가
|
||||||
const isPreFilled = addModalPanel === "left-item"
|
const isItemAddPreFilled = addModalPanel === "left-item"
|
||||||
&& componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name
|
&& componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name
|
||||||
&& addModalFormData[col.name];
|
&& addModalFormData[col.name];
|
||||||
|
|
||||||
|
// 우측 패널 추가 시, 조인 컬럼(rightColumn)은 미리 채워져 있고 수정 불가
|
||||||
|
const isRightJoinPreFilled = addModalPanel === "right"
|
||||||
|
&& componentConfig.rightPanel?.rightColumn === col.name
|
||||||
|
&& addModalFormData[col.name];
|
||||||
|
|
||||||
|
const isPreFilled = isItemAddPreFilled || isRightJoinPreFilled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<Label htmlFor={col.name} className="text-xs sm:text-sm">
|
<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 { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SplitPanelLayoutConfig } from "./types";
|
import { SplitPanelLayoutConfig } from "./types";
|
||||||
|
|
@ -284,7 +285,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
console.log(" - availableRightTables:", availableRightTables.length, "개");
|
console.log(" - availableRightTables:", availableRightTables.length, "개");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{/* 관계 타입 선택 */}
|
{/* 관계 타입 선택 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">패널 관계 타입</h3>
|
<h3 className="text-sm font-semibold">패널 관계 타입</h3>
|
||||||
|
|
@ -324,9 +325,14 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 좌측 패널 설정 (마스터) */}
|
{/* 좌측 패널 설정 (Accordion) */}
|
||||||
<div className="space-y-4">
|
<Accordion type="single" collapsible defaultValue="left-panel" className="w-full">
|
||||||
<h3 className="text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
<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">
|
<div className="space-y-2">
|
||||||
<Label>패널 제목</Label>
|
<Label>패널 제목</Label>
|
||||||
|
|
@ -808,10 +814,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
{/* 우측 패널 설정 */}
|
{/* 우측 패널 설정 (Accordion) */}
|
||||||
<div className="space-y-4">
|
<Accordion type="single" collapsible defaultValue="right-panel" className="w-full">
|
||||||
<h3 className="text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})</h3>
|
<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">
|
<div className="space-y-2">
|
||||||
<Label>패널 제목</Label>
|
<Label>패널 제목</Label>
|
||||||
|
|
@ -1358,10 +1372,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
{/* 레이아웃 설정 */}
|
{/* 레이아웃 설정 (Accordion) */}
|
||||||
<div className="space-y-4">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
<h3 className="text-sm font-semibold">레이아웃 설정</h3>
|
<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">
|
<div className="space-y-2">
|
||||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||||
|
|
@ -1390,6 +1412,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -323,16 +323,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return reordered;
|
return reordered;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📊 초기 화면 표시 데이터 전달:", { count: initialData.length, firstRow: initialData[0] });
|
|
||||||
|
|
||||||
// 전역 저장소에 데이터 저장
|
// 전역 저장소에 데이터 저장
|
||||||
if (tableConfig.selectedTable) {
|
if (tableConfig.selectedTable) {
|
||||||
|
// 컬럼 라벨 매핑 생성
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
visibleColumns.forEach((col) => {
|
||||||
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
||||||
|
});
|
||||||
|
|
||||||
tableDisplayStore.setTableData(
|
tableDisplayStore.setTableData(
|
||||||
tableConfig.selectedTable,
|
tableConfig.selectedTable,
|
||||||
initialData,
|
initialData,
|
||||||
parsedOrder.filter((col) => col !== "__checkbox__"),
|
parsedOrder.filter((col) => col !== "__checkbox__"),
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
{
|
||||||
|
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
|
||||||
|
searchTerm: searchTerm || undefined,
|
||||||
|
visibleColumns: visibleColumns.map((col) => col.columnName),
|
||||||
|
columnLabels: labels,
|
||||||
|
currentPage: currentPage,
|
||||||
|
pageSize: localPageSize,
|
||||||
|
totalItems: totalItems,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -624,33 +637,44 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const hasEntityJoins = entityJoinColumns.length > 0;
|
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||||
|
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||||
let response;
|
|
||||||
if (hasEntityJoins) {
|
|
||||||
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
|
||||||
page,
|
page,
|
||||||
size: pageSize,
|
size: pageSize,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
search: filters,
|
search: filters,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
additionalJoinColumns: entityJoinColumns,
|
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
response = await tableTypeApi.getTableData(tableConfig.selectedTable, {
|
|
||||||
page,
|
|
||||||
size: pageSize,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
search: filters,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(response.data || []);
|
setData(response.data || []);
|
||||||
setTotalPages(response.totalPages || 0);
|
setTotalPages(response.totalPages || 0);
|
||||||
setTotalItems(response.total || 0);
|
setTotalItems(response.total || 0);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// 🎯 Store에 필터 조건 저장 (엑셀 다운로드용)
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
visibleColumns.forEach((col) => {
|
||||||
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
||||||
|
});
|
||||||
|
|
||||||
|
tableDisplayStore.setTableData(
|
||||||
|
tableConfig.selectedTable,
|
||||||
|
response.data || [],
|
||||||
|
visibleColumns.map((col) => col.columnName),
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
{
|
||||||
|
filterConditions: filters,
|
||||||
|
searchTerm: search,
|
||||||
|
visibleColumns: visibleColumns.map((col) => col.columnName),
|
||||||
|
columnLabels: labels,
|
||||||
|
currentPage: page,
|
||||||
|
pageSize: pageSize,
|
||||||
|
totalItems: response.total || 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("데이터 가져오기 실패:", err);
|
console.error("데이터 가져오기 실패:", err);
|
||||||
setData([]);
|
setData([]);
|
||||||
|
|
@ -788,12 +812,28 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const cleanColumnOrder = (
|
const cleanColumnOrder = (
|
||||||
columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName)
|
columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName)
|
||||||
).filter((col) => col !== "__checkbox__");
|
).filter((col) => col !== "__checkbox__");
|
||||||
|
|
||||||
|
// 컬럼 라벨 정보도 함께 저장
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
visibleColumns.forEach((col) => {
|
||||||
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
||||||
|
});
|
||||||
|
|
||||||
tableDisplayStore.setTableData(
|
tableDisplayStore.setTableData(
|
||||||
tableConfig.selectedTable,
|
tableConfig.selectedTable,
|
||||||
reorderedData,
|
reorderedData,
|
||||||
cleanColumnOrder,
|
cleanColumnOrder,
|
||||||
newSortColumn,
|
newSortColumn,
|
||||||
newSortDirection,
|
newSortDirection,
|
||||||
|
{
|
||||||
|
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
|
||||||
|
searchTerm: searchTerm || undefined,
|
||||||
|
visibleColumns: visibleColumns.map((col) => col.columnName),
|
||||||
|
columnLabels: labels,
|
||||||
|
currentPage: currentPage,
|
||||||
|
pageSize: localPageSize,
|
||||||
|
totalItems: totalItems,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1062,6 +1102,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
|
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
|
||||||
if (value === null || value === undefined) return "-";
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
|
||||||
|
if (column.columnName === "writer" && rowData && rowData.writer_name) {
|
||||||
|
return rowData.writer_name;
|
||||||
|
}
|
||||||
|
|
||||||
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
|
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
|
||||||
if (column.entityDisplayConfig && rowData) {
|
if (column.entityDisplayConfig && rowData) {
|
||||||
// displayColumns 또는 selectedColumns 둘 다 체크
|
// displayColumns 또는 selectedColumns 둘 다 체크
|
||||||
|
|
@ -1155,6 +1200,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 날짜 타입 포맷팅 (yyyy-mm-dd)
|
||||||
|
if (inputType === "date" || inputType === "datetime") {
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
const date = new Date(value);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
// 숫자 타입 포맷팅
|
// 숫자 타입 포맷팅
|
||||||
if (inputType === "number" || inputType === "decimal") {
|
if (inputType === "number" || inputType === "decimal") {
|
||||||
if (value !== null && value !== undefined && value !== "") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
|
@ -1179,7 +1240,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (value) {
|
if (value) {
|
||||||
try {
|
try {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return date.toLocaleDateString("ko-KR");
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
@ -2144,7 +2208,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12",
|
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleRowClick(row, index, e)}
|
onClick={(e) => handleRowClick(row, index, e)}
|
||||||
>
|
>
|
||||||
|
|
@ -2173,8 +2237,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<td
|
<td
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
"text-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap font-normal sm:text-sm",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
||||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -2210,7 +2274,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12",
|
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleRowClick(row, index, e)}
|
onClick={(e) => handleRowClick(row, index, e)}
|
||||||
>
|
>
|
||||||
|
|
@ -2239,8 +2303,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<td
|
<td
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
"text-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap font-normal sm:text-sm",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
||||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -100,16 +100,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
const currentFormValue = formData?.[component.columnName];
|
const currentFormValue = formData?.[component.columnName];
|
||||||
const currentComponentValue = component.value;
|
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) {
|
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,20 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { TextInputConfig } from "./types";
|
import { TextInputConfig } from "./types";
|
||||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
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";
|
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||||
|
|
||||||
export interface TextInputConfigPanelProps {
|
export interface TextInputConfigPanelProps {
|
||||||
config: TextInputConfig;
|
config: TextInputConfig;
|
||||||
onChange: (config: Partial<TextInputConfig>) => void;
|
onChange: (config: Partial<TextInputConfig>) => void;
|
||||||
|
screenTableName?: string; // 🆕 현재 화면의 테이블명
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TextInput 설정 패널
|
* TextInput 설정 패널
|
||||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||||
*/
|
*/
|
||||||
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange }) => {
|
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange, screenTableName }) => {
|
||||||
// 채번 규칙 목록 상태
|
// 채번 규칙 목록 상태
|
||||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
const [loadingRules, setLoadingRules] = useState(false);
|
const [loadingRules, setLoadingRules] = useState(false);
|
||||||
|
|
@ -30,9 +31,20 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
||||||
const loadRules = async () => {
|
const loadRules = async () => {
|
||||||
setLoadingRules(true);
|
setLoadingRules(true);
|
||||||
try {
|
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) {
|
if (response.success && response.data) {
|
||||||
setNumberingRules(response.data);
|
setNumberingRules(response.data);
|
||||||
|
console.log("✅ 채번 규칙 로드 완료:", response.data.length, "개");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("채번 규칙 목록 로드 실패:", error);
|
console.error("채번 규칙 목록 로드 실패:", error);
|
||||||
|
|
@ -45,7 +57,7 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
||||||
if (config.autoGeneration?.type === "numbering_rule") {
|
if (config.autoGeneration?.type === "numbering_rule") {
|
||||||
loadRules();
|
loadRules();
|
||||||
}
|
}
|
||||||
}, [config.autoGeneration?.type]);
|
}, [config.autoGeneration?.type, screenTableName]);
|
||||||
|
|
||||||
const handleChange = (key: keyof TextInputConfig, value: any) => {
|
const handleChange = (key: keyof TextInputConfig, value: any) => {
|
||||||
onChange({ [key]: value });
|
onChange({ [key]: value });
|
||||||
|
|
@ -174,7 +186,12 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
||||||
) : (
|
) : (
|
||||||
numberingRules.map((rule) => (
|
numberingRules.map((rule) => (
|
||||||
<SelectItem key={rule.ruleId} value={rule.ruleId}>
|
<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>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,16 @@ export interface ButtonActionContext {
|
||||||
sortOrder?: "asc" | "desc"; // 정렬 방향
|
sortOrder?: "asc" | "desc"; // 정렬 방향
|
||||||
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
|
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
|
||||||
tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨)
|
tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨)
|
||||||
|
|
||||||
|
// 🆕 엑셀 다운로드 개선을 위한 추가 필드
|
||||||
|
filterConditions?: Record<string, any>; // 필터 조건 (예: { status: "active", dept: "dev" })
|
||||||
|
searchTerm?: string; // 검색어
|
||||||
|
searchColumn?: string; // 검색 대상 컬럼
|
||||||
|
visibleColumns?: string[]; // 화면에 표시 중인 컬럼 목록 (순서 포함)
|
||||||
|
columnLabels?: Record<string, string>; // 컬럼명 → 라벨명 매핑 (한글)
|
||||||
|
currentPage?: number; // 현재 페이지
|
||||||
|
pageSize?: number; // 페이지 크기
|
||||||
|
totalItems?: number; // 전체 항목 수
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1936,162 +1946,74 @@ export class ButtonActionExecutor {
|
||||||
*/
|
*/
|
||||||
private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
console.log("📥 엑셀 다운로드 시작:", { config, context });
|
|
||||||
console.log("🔍 context.columnOrder 확인:", {
|
|
||||||
hasColumnOrder: !!context.columnOrder,
|
|
||||||
columnOrderLength: context.columnOrder?.length,
|
|
||||||
columnOrder: context.columnOrder,
|
|
||||||
});
|
|
||||||
console.log("🔍 context.tableDisplayData 확인:", {
|
|
||||||
hasTableDisplayData: !!context.tableDisplayData,
|
|
||||||
tableDisplayDataLength: context.tableDisplayData?.length,
|
|
||||||
tableDisplayDataFirstRow: context.tableDisplayData?.[0],
|
|
||||||
tableDisplayDataColumns: context.tableDisplayData?.[0] ? Object.keys(context.tableDisplayData[0]) : [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 동적 import로 엑셀 유틸리티 로드
|
// 동적 import로 엑셀 유틸리티 로드
|
||||||
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||||
|
|
||||||
let dataToExport: any[] = [];
|
let dataToExport: any[] = [];
|
||||||
|
|
||||||
// 1순위: 선택된 행 데이터
|
// ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기
|
||||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
if (context.tableName) {
|
||||||
dataToExport = context.selectedRowsData;
|
|
||||||
console.log("✅ 선택된 행 데이터 사용:", dataToExport.length);
|
|
||||||
|
|
||||||
// 선택된 행도 정렬 적용
|
|
||||||
if (context.sortBy) {
|
|
||||||
console.log("🔄 선택된 행 데이터 정렬 적용:", {
|
|
||||||
sortBy: context.sortBy,
|
|
||||||
sortOrder: context.sortOrder,
|
|
||||||
});
|
|
||||||
|
|
||||||
dataToExport = [...dataToExport].sort((a, b) => {
|
|
||||||
const aVal = a[context.sortBy!];
|
|
||||||
const bVal = b[context.sortBy!];
|
|
||||||
|
|
||||||
// null/undefined 처리
|
|
||||||
if (aVal == null && bVal == null) return 0;
|
|
||||||
if (aVal == null) return 1;
|
|
||||||
if (bVal == null) return -1;
|
|
||||||
|
|
||||||
// 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교)
|
|
||||||
const aNum = Number(aVal);
|
|
||||||
const bNum = Number(bVal);
|
|
||||||
|
|
||||||
// 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우
|
|
||||||
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") {
|
|
||||||
return context.sortOrder === "desc" ? bNum - aNum : aNum - bNum;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬)
|
|
||||||
const aStr = String(aVal).toLowerCase();
|
|
||||||
const bStr = String(bVal).toLowerCase();
|
|
||||||
|
|
||||||
// 자연스러운 정렬 (숫자 포함 문자열)
|
|
||||||
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: 'base' });
|
|
||||||
return context.sortOrder === "desc" ? -comparison : comparison;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("✅ 정렬 완료:", {
|
|
||||||
firstRow: dataToExport[0],
|
|
||||||
lastRow: dataToExport[dataToExport.length - 1],
|
|
||||||
firstSortValue: dataToExport[0]?.[context.sortBy],
|
|
||||||
lastSortValue: dataToExport[dataToExport.length - 1]?.[context.sortBy],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2순위: 화면 표시 데이터 (컬럼 순서 포함, 정렬 적용됨)
|
|
||||||
else if (context.tableDisplayData && context.tableDisplayData.length > 0) {
|
|
||||||
dataToExport = context.tableDisplayData;
|
|
||||||
console.log("✅ 화면 표시 데이터 사용 (context):", {
|
|
||||||
count: dataToExport.length,
|
|
||||||
firstRow: dataToExport[0],
|
|
||||||
columns: Object.keys(dataToExport[0] || {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 2.5순위: 전역 저장소에서 화면 표시 데이터 조회
|
|
||||||
else if (context.tableName) {
|
|
||||||
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
||||||
const storedData = tableDisplayStore.getTableData(context.tableName);
|
const storedData = tableDisplayStore.getTableData(context.tableName);
|
||||||
|
|
||||||
if (storedData && storedData.data.length > 0) {
|
// 필터 조건은 저장소 또는 context에서 가져오기
|
||||||
dataToExport = storedData.data;
|
const filterConditions = storedData?.filterConditions || context.filterConditions;
|
||||||
console.log("✅ 화면 표시 데이터 사용 (전역 저장소):", {
|
const searchTerm = storedData?.searchTerm || context.searchTerm;
|
||||||
tableName: context.tableName,
|
|
||||||
count: dataToExport.length,
|
|
||||||
firstRow: dataToExport[0],
|
|
||||||
lastRow: dataToExport[dataToExport.length - 1],
|
|
||||||
columns: Object.keys(dataToExport[0] || {}),
|
|
||||||
columnOrder: storedData.columnOrder,
|
|
||||||
sortBy: storedData.sortBy,
|
|
||||||
sortOrder: storedData.sortOrder,
|
|
||||||
// 정렬 컬럼의 첫/마지막 값 확인
|
|
||||||
firstSortValue: storedData.sortBy ? dataToExport[0]?.[storedData.sortBy] : undefined,
|
|
||||||
lastSortValue: storedData.sortBy ? dataToExport[dataToExport.length - 1]?.[storedData.sortBy] : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 3순위: 테이블 전체 데이터 (API 호출)
|
|
||||||
else {
|
|
||||||
console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName);
|
|
||||||
console.log("📊 정렬 정보:", {
|
|
||||||
sortBy: context.sortBy,
|
|
||||||
sortOrder: context.sortOrder,
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const { dynamicFormApi } = await import("@/lib/api/dynamicForm");
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
const response = await dynamicFormApi.getTableData(context.tableName, {
|
|
||||||
|
const apiParams = {
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10000, // 최대 10,000개 행
|
size: 10000, // 최대 10,000개
|
||||||
sortBy: context.sortBy || "id", // 화면 정렬 또는 기본 정렬
|
sortBy: context.sortBy || storedData?.sortBy || "id",
|
||||||
sortOrder: context.sortOrder || "asc", // 화면 정렬 방향 또는 오름차순
|
sortOrder: (context.sortOrder || storedData?.sortOrder || "asc") as "asc" | "desc",
|
||||||
});
|
search: filterConditions, // ✅ 필터 조건
|
||||||
|
enableEntityJoin: true, // ✅ Entity 조인
|
||||||
|
autoFilter: true, // ✅ company_code 자동 필터링 (멀티테넌시)
|
||||||
|
};
|
||||||
|
|
||||||
console.log("📦 API 응답 구조:", {
|
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
|
||||||
response,
|
const response = await entityJoinApi.getTableDataWithJoins(context.tableName, apiParams);
|
||||||
responseSuccess: response.success,
|
|
||||||
responseData: response.data,
|
|
||||||
responseDataType: typeof response.data,
|
|
||||||
responseDataIsArray: Array.isArray(response.data),
|
|
||||||
responseDataLength: Array.isArray(response.data) ? response.data.length : "N/A",
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
dataToExport = response.data;
|
||||||
console.log("✅ 테이블 전체 데이터 조회 완료:", {
|
|
||||||
count: dataToExport.length,
|
|
||||||
firstRow: dataToExport[0],
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ API 응답에 데이터가 없습니다:", response);
|
console.error("❌ 예상치 못한 응답 형식:", response);
|
||||||
|
toast.error("데이터를 가져오는데 실패했습니다.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
console.error("엑셀 다운로드: 데이터 조회 실패:", error);
|
||||||
|
toast.error("데이터를 가져오는데 실패했습니다.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// 폴백: 폼 데이터
|
||||||
// 4순위: 폼 데이터
|
|
||||||
else if (context.formData && Object.keys(context.formData).length > 0) {
|
else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||||
dataToExport = [context.formData];
|
dataToExport = [context.formData];
|
||||||
console.log("✅ 폼 데이터 사용:", dataToExport);
|
|
||||||
}
|
}
|
||||||
|
// 테이블명도 없고 폼 데이터도 없으면 에러
|
||||||
console.log("📊 최종 다운로드 데이터:", {
|
else {
|
||||||
selectedRowsData: context.selectedRowsData,
|
toast.error("다운로드할 데이터 소스가 없습니다.");
|
||||||
selectedRowsLength: context.selectedRowsData?.length,
|
return false;
|
||||||
formData: context.formData,
|
}
|
||||||
tableName: context.tableName,
|
|
||||||
dataToExport,
|
|
||||||
dataToExportType: typeof dataToExport,
|
|
||||||
dataToExportIsArray: Array.isArray(dataToExport),
|
|
||||||
dataToExportLength: Array.isArray(dataToExport) ? dataToExport.length : "N/A",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 배열이 아니면 배열로 변환
|
// 배열이 아니면 배열로 변환
|
||||||
if (!Array.isArray(dataToExport)) {
|
if (!Array.isArray(dataToExport)) {
|
||||||
console.warn("⚠️ dataToExport가 배열이 아닙니다. 변환 시도:", dataToExport);
|
|
||||||
|
|
||||||
// 객체인 경우 배열로 감싸기
|
|
||||||
if (typeof dataToExport === "object" && dataToExport !== null) {
|
if (typeof dataToExport === "object" && dataToExport !== null) {
|
||||||
dataToExport = [dataToExport];
|
dataToExport = [dataToExport];
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2110,66 +2032,196 @@ export class ButtonActionExecutor {
|
||||||
const sheetName = config.excelSheetName || "Sheet1";
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||||
|
|
||||||
// 🆕 컬럼 순서 재정렬 (화면에 표시된 순서대로)
|
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
||||||
let columnOrder: string[] | undefined = context.columnOrder;
|
let visibleColumns: string[] | undefined = undefined;
|
||||||
|
let columnLabels: Record<string, string> | undefined = undefined;
|
||||||
|
|
||||||
// columnOrder가 없으면 tableDisplayData에서 추출 시도
|
try {
|
||||||
if (!columnOrder && context.tableDisplayData && context.tableDisplayData.length > 0) {
|
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
||||||
columnOrder = Object.keys(context.tableDisplayData[0]);
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
console.log("📊 tableDisplayData에서 컬럼 순서 추출:", columnOrder);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (columnOrder && columnOrder.length > 0 && dataToExport.length > 0) {
|
// 테이블 리스트 컴포넌트 찾기
|
||||||
console.log("🔄 컬럼 순서 재정렬 시작:", {
|
const findTableListComponent = (components: any[]): any => {
|
||||||
columnOrder,
|
if (!Array.isArray(components)) return null;
|
||||||
originalColumns: Object.keys(dataToExport[0] || {}),
|
|
||||||
|
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 }
|
||||||
});
|
});
|
||||||
|
|
||||||
dataToExport = dataToExport.map((row: any) => {
|
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||||
const reorderedRow: any = {};
|
let columnData = columnsResponse.data.data;
|
||||||
|
|
||||||
// 1. columnOrder에 있는 컬럼들을 순서대로 추가
|
// data가 객체이고 columns 필드가 있으면 추출
|
||||||
columnOrder!.forEach((colName: string) => {
|
if (columnData.columns && Array.isArray(columnData.columns)) {
|
||||||
if (colName in row) {
|
columnData = columnData.columns;
|
||||||
reorderedRow[colName] = row[colName];
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// 2. columnOrder에 없는 나머지 컬럼들 추가 (끝에 배치)
|
}
|
||||||
Object.keys(row).forEach((key) => {
|
} catch (error) {
|
||||||
if (!(key in reorderedRow)) {
|
// 실패 시 컴포넌트 설정의 displayName 사용
|
||||||
reorderedRow[key] = row[key];
|
columnLabels = {};
|
||||||
|
columns.forEach((col: any) => {
|
||||||
|
if (col.columnName) {
|
||||||
|
columnLabels![col.columnName] = col.displayName || col.label || col.columnName;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return reorderedRow;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("✅ 컬럼 순서 재정렬 완료:", {
|
|
||||||
reorderedColumns: Object.keys(dataToExport[0] || {}),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.log("⏭️ 컬럼 순서 재정렬 스킵:", {
|
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
|
||||||
hasColumnOrder: !!columnOrder,
|
}
|
||||||
columnOrderLength: columnOrder?.length,
|
}
|
||||||
hasTableDisplayData: !!context.tableDisplayData,
|
} catch (error) {
|
||||||
dataToExportLength: dataToExport.length,
|
console.error("❌ 화면 레이아웃 조회 실패:", error);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📥 엑셀 다운로드 실행:", {
|
|
||||||
fileName,
|
// 🎨 카테고리 값들 조회 (한 번만)
|
||||||
sheetName,
|
const categoryMap: Record<string, Record<string, string>> = {};
|
||||||
includeHeaders,
|
let categoryColumns: string[] = [];
|
||||||
dataCount: dataToExport.length,
|
|
||||||
firstRow: dataToExport[0],
|
// 백엔드에서 카테고리 컬럼 정보 가져오기
|
||||||
columnOrder: context.columnOrder,
|
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 (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
|
||||||
|
dataToExport = dataToExport.map((row: any) => {
|
||||||
|
const filteredRow: Record<string, any> = {};
|
||||||
|
|
||||||
|
visibleColumns.forEach((columnName: string) => {
|
||||||
|
// __checkbox__ 컬럼은 제외
|
||||||
|
if (columnName === "__checkbox__") return;
|
||||||
|
|
||||||
|
if (columnName in row) {
|
||||||
|
// 라벨 우선 사용, 없으면 컬럼명 사용
|
||||||
|
const label = columnLabels?.[columnName] || columnName;
|
||||||
|
|
||||||
|
// 🎯 Entity 조인된 값 우선 사용
|
||||||
|
let value = row[columnName];
|
||||||
|
|
||||||
|
// writer → writer_name 사용
|
||||||
|
if (columnName === 'writer' && row['writer_name']) {
|
||||||
|
value = row['writer_name'];
|
||||||
|
}
|
||||||
|
// 다른 엔티티 필드들도 _name 우선 사용
|
||||||
|
else if (row[`${columnName}_name`]) {
|
||||||
|
value = row[`${columnName}_name`];
|
||||||
|
}
|
||||||
|
// 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만)
|
||||||
|
else if (categoryMap[columnName] && typeof value === 'string' && categoryMap[columnName][value]) {
|
||||||
|
value = categoryMap[columnName][value];
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredRow[label] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최대 행 수 제한
|
||||||
|
const MAX_ROWS = 10000;
|
||||||
|
if (dataToExport.length > MAX_ROWS) {
|
||||||
|
toast.warning(`최대 ${MAX_ROWS.toLocaleString()}개 행까지만 다운로드됩니다.`);
|
||||||
|
dataToExport = dataToExport.slice(0, MAX_ROWS);
|
||||||
|
}
|
||||||
|
|
||||||
// 엑셀 다운로드 실행
|
// 엑셀 다운로드 실행
|
||||||
await exportToExcel(dataToExport, fileName, sheetName, includeHeaders);
|
await exportToExcel(dataToExport, fileName, sheetName, includeHeaders);
|
||||||
|
|
||||||
toast.success(config.successMessage || "엑셀 파일이 다운로드되었습니다.");
|
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 엑셀 다운로드 실패:", error);
|
console.error("❌ 엑셀 다운로드 실패:", error);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import { DashboardConfigPanel } from "@/components/screen/config-panels/Dashboar
|
||||||
export type ConfigPanelComponent = React.ComponentType<{
|
export type ConfigPanelComponent = React.ComponentType<{
|
||||||
config: any;
|
config: any;
|
||||||
onConfigChange: (config: any) => void;
|
onConfigChange: (config: any) => void;
|
||||||
|
tableName?: string; // 화면 테이블명 (선택)
|
||||||
|
menuObjid?: number; // 메뉴 objid (선택)
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// ButtonConfigPanel 래퍼 (config/onConfigChange → component/onUpdateProperty 변환)
|
// ButtonConfigPanel 래퍼 (config/onConfigChange → component/onUpdateProperty 변환)
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,28 @@ export function calculateGridInfo(
|
||||||
containerHeight: number,
|
containerHeight: number,
|
||||||
gridSettings: GridSettings,
|
gridSettings: GridSettings,
|
||||||
): GridInfo {
|
): 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 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 totalGaps = (columns - 1) * gap;
|
||||||
const columnWidth = (availableWidth - totalGaps) / columns;
|
const columnWidth = (availableWidth - totalGaps) / columns;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
|
columnWidth: Math.max(columnWidth, MIN_COLUMN_WIDTH),
|
||||||
totalWidth: containerWidth,
|
totalWidth: containerWidth,
|
||||||
totalHeight: containerHeight,
|
totalHeight: containerHeight,
|
||||||
};
|
};
|
||||||
|
|
@ -96,9 +107,9 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri
|
||||||
const rowHeight = 10;
|
const rowHeight = 10;
|
||||||
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
|
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
|
||||||
|
|
||||||
console.log(
|
// console.log(
|
||||||
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
// `📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||||
);
|
// );
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: Math.max(columnWidth, snappedWidth),
|
width: Math.max(columnWidth, snappedWidth),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
sortBy: string | null;
|
||||||
sortOrder: "asc" | "desc";
|
sortOrder: "asc" | "desc";
|
||||||
tableName: string;
|
tableName: string;
|
||||||
|
|
||||||
|
// 🆕 엑셀 다운로드 개선을 위한 추가 필드
|
||||||
|
filterConditions?: Record<string, any>; // 필터 조건
|
||||||
|
searchTerm?: string; // 검색어
|
||||||
|
visibleColumns?: string[]; // 화면 표시 컬럼
|
||||||
|
columnLabels?: Record<string, string>; // 컬럼 라벨
|
||||||
|
currentPage?: number; // 현재 페이지
|
||||||
|
pageSize?: number; // 페이지 크기
|
||||||
|
totalItems?: number; // 전체 항목 수
|
||||||
}
|
}
|
||||||
|
|
||||||
class TableDisplayStore {
|
class TableDisplayStore {
|
||||||
|
|
@ -22,13 +31,23 @@ class TableDisplayStore {
|
||||||
* @param columnOrder 컬럼 순서
|
* @param columnOrder 컬럼 순서
|
||||||
* @param sortBy 정렬 컬럼
|
* @param sortBy 정렬 컬럼
|
||||||
* @param sortOrder 정렬 방향
|
* @param sortOrder 정렬 방향
|
||||||
|
* @param options 추가 옵션 (필터, 페이징 등)
|
||||||
*/
|
*/
|
||||||
setTableData(
|
setTableData(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
data: any[],
|
data: any[],
|
||||||
columnOrder: string[],
|
columnOrder: string[],
|
||||||
sortBy: string | null,
|
sortBy: string | null,
|
||||||
sortOrder: "asc" | "desc"
|
sortOrder: "asc" | "desc",
|
||||||
|
options?: {
|
||||||
|
filterConditions?: Record<string, any>;
|
||||||
|
searchTerm?: string;
|
||||||
|
visibleColumns?: string[];
|
||||||
|
columnLabels?: Record<string, string>;
|
||||||
|
currentPage?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
totalItems?: number;
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
this.state.set(tableName, {
|
this.state.set(tableName, {
|
||||||
data,
|
data,
|
||||||
|
|
@ -36,15 +55,7 @@ class TableDisplayStore {
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
tableName,
|
tableName,
|
||||||
});
|
...options,
|
||||||
|
|
||||||
console.log("📦 [TableDisplayStore] 데이터 저장:", {
|
|
||||||
tableName,
|
|
||||||
dataCount: data.length,
|
|
||||||
columnOrderLength: columnOrder.length,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
firstRow: data[0],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
|
|
@ -55,15 +66,7 @@ class TableDisplayStore {
|
||||||
* @param tableName 테이블명
|
* @param tableName 테이블명
|
||||||
*/
|
*/
|
||||||
getTableData(tableName: string): TableDisplayState | undefined {
|
getTableData(tableName: string): TableDisplayState | undefined {
|
||||||
const state = this.state.get(tableName);
|
return this.state.get(tableName);
|
||||||
|
|
||||||
console.log("📤 [TableDisplayStore] 데이터 조회:", {
|
|
||||||
tableName,
|
|
||||||
found: !!state,
|
|
||||||
dataCount: state?.data.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,16 @@ export type AutoGenerationType = "table" | "form" | "mixed";
|
||||||
*/
|
*/
|
||||||
export interface AutoGenerationConfig {
|
export interface AutoGenerationConfig {
|
||||||
type: AutoGenerationType;
|
type: AutoGenerationType;
|
||||||
|
enabled?: boolean;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
includeSearch?: boolean;
|
includeSearch?: boolean;
|
||||||
includePagination?: 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