parent
8c0489e954
commit
ce4aefe12e
|
|
@ -3,6 +3,13 @@
|
||||||
"Framelink Figma MCP": {
|
"Framelink Figma MCP": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
|
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
|
||||||
|
},
|
||||||
|
"notion": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@notionhq/notion-mcp-server"],
|
||||||
|
"env": {
|
||||||
|
"OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer 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\",\"Notion-Version\":\"2022-06-28\"}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,394 +0,0 @@
|
||||||
# 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. **철저한 마무리** ✨
|
|
||||||
- 로그 제거, 테스트, 명확한 설명
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 화면관리 시스템 위젯 개발 가이드
|
|
||||||
|
|
||||||
### 위젯 크기 설정의 핵심 원칙
|
|
||||||
|
|
||||||
화면관리 시스템에서 위젯을 개발할 때, **크기 제어는 상위 컨테이너(`RealtimePreviewDynamic`)가 담당**합니다.
|
|
||||||
|
|
||||||
#### ✅ 올바른 크기 설정 패턴
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 위젯 컴포넌트 내부
|
|
||||||
export function YourWidget({ component }: YourWidgetProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex h-full w-full items-center justify-between gap-2"
|
|
||||||
style={{
|
|
||||||
padding: component.style?.padding || "0.75rem",
|
|
||||||
backgroundColor: component.style?.backgroundColor,
|
|
||||||
// ❌ width, height, minHeight 등 크기 관련 속성은 제거!
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 위젯 내용 */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### ❌ 잘못된 크기 설정 패턴
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 이렇게 하면 안 됩니다!
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: component.style?.width || "100%", // ❌ 상위에서 이미 제어함
|
|
||||||
height: component.style?.height || "80px", // ❌ 상위에서 이미 제어함
|
|
||||||
minHeight: "80px", // ❌ 내부 컨텐츠가 줄어듦
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 이유
|
|
||||||
|
|
||||||
1. **`RealtimePreviewDynamic`**이 `baseStyle`로 이미 크기를 제어:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const baseStyle = {
|
|
||||||
left: `${position.x}px`,
|
|
||||||
top: `${position.y}px`,
|
|
||||||
width: getWidth(), // size.width 사용
|
|
||||||
height: getHeight(), // size.height 사용
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 위젯 내부에서 크기를 다시 설정하면:
|
|
||||||
- 중복 설정으로 인한 충돌
|
|
||||||
- 내부 컨텐츠가 설정한 크기보다 작게 표시됨
|
|
||||||
- 편집기에서 설정한 크기와 실제 렌더링 크기 불일치
|
|
||||||
|
|
||||||
### 위젯이 관리해야 할 스타일
|
|
||||||
|
|
||||||
위젯 컴포넌트는 **위젯 고유의 스타일**만 관리합니다:
|
|
||||||
|
|
||||||
- ✅ `padding`: 내부 여백
|
|
||||||
- ✅ `backgroundColor`: 배경색
|
|
||||||
- ✅ `border`, `borderRadius`: 테두리
|
|
||||||
- ✅ `gap`: 자식 요소 간격
|
|
||||||
- ✅ `flexDirection`, `alignItems`: 레이아웃 방향
|
|
||||||
|
|
||||||
### 위젯 등록 시 defaultSize
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
ComponentRegistry.registerComponent({
|
|
||||||
id: "your-widget",
|
|
||||||
name: "위젯 이름",
|
|
||||||
category: "utility",
|
|
||||||
defaultSize: { width: 1200, height: 80 }, // 픽셀 단위 (필수)
|
|
||||||
component: YourWidget,
|
|
||||||
defaultProps: {
|
|
||||||
style: {
|
|
||||||
padding: "0.75rem",
|
|
||||||
// width, height는 defaultSize로 제어되므로 여기 불필요
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 레이아웃 구조
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 전체 높이를 차지하고 내부 요소를 정렬
|
|
||||||
<div className="flex h-full w-full items-center justify-between gap-2">
|
|
||||||
{/* 왼쪽 컨텐츠 */}
|
|
||||||
<div className="flex items-center gap-3">{/* ... */}</div>
|
|
||||||
|
|
||||||
{/* 오른쪽 버튼들 */}
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{/* flex-shrink-0으로 버튼이 줄어들지 않도록 보장 */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 체크리스트
|
|
||||||
|
|
||||||
위젯 개발 시 다음을 확인하세요:
|
|
||||||
|
|
||||||
- [ ] 위젯 루트 요소에 `h-full w-full` 클래스 사용
|
|
||||||
- [ ] `width`, `height`, `minHeight` 인라인 스타일 **제거**
|
|
||||||
- [ ] `padding`, `backgroundColor` 등 위젯 고유 스타일만 관리
|
|
||||||
- [ ] `defaultSize`에 적절한 기본 크기 설정
|
|
||||||
- [ ] 양끝 정렬이 필요하면 `justify-between` 사용
|
|
||||||
- [ ] 줄어들면 안 되는 요소에 `flex-shrink-0` 적용
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
|
||||||
|
|
@ -1,471 +0,0 @@
|
||||||
---
|
|
||||||
description: 스크롤 문제 디버깅 및 해결 가이드 - Flexbox 레이아웃에서 스크롤이 작동하지 않을 때 체계적인 진단과 해결 방법
|
|
||||||
---
|
|
||||||
|
|
||||||
# 스크롤 문제 디버깅 및 해결 가이드
|
|
||||||
|
|
||||||
React/Next.js 프로젝트에서 Flexbox 레이아웃의 스크롤이 작동하지 않을 때 사용하는 체계적인 디버깅 및 해결 방법입니다.
|
|
||||||
|
|
||||||
## 1. 스크롤 문제의 일반적인 원인
|
|
||||||
|
|
||||||
### 근본 원인: Flexbox의 높이 계산 실패
|
|
||||||
|
|
||||||
Flexbox 레이아웃에서 스크롤이 작동하지 않는 이유:
|
|
||||||
|
|
||||||
1. **부모 컨테이너의 높이가 확정되지 않음**: `h-full`은 부모가 명시적인 높이를 가져야만 작동
|
|
||||||
2. **`minHeight: auto` 기본값**: Flex item은 콘텐츠 크기만큼 늘어나려고 함
|
|
||||||
3. **`overflow` 속성 누락**: 부모가 `overflow: hidden`이 없으면 자식이 부모를 밀어냄
|
|
||||||
4. **`display: flex` 누락**: Flex container가 명시적으로 선언되지 않음
|
|
||||||
|
|
||||||
## 2. 디버깅 프로세스
|
|
||||||
|
|
||||||
### 단계 1: 시각적 디버깅 (컬러 테두리)
|
|
||||||
|
|
||||||
문제가 발생한 컴포넌트에 **컬러 테두리**를 추가하여 각 레이어의 실제 크기를 확인:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 최상위 컨테이너 (빨간색)
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%",
|
|
||||||
border: "3px solid red", // 🔍 디버그
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 헤더 (파란색) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
height: "64px",
|
|
||||||
border: "3px solid blue", // 🔍 디버그
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
헤더
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 스크롤 영역 (초록색) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
overflowY: "auto",
|
|
||||||
border: "3px solid green", // 🔍 디버그
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
콘텐츠
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**브라우저에서 확인할 사항:**
|
|
||||||
|
|
||||||
- 🔴 빨간색 테두리가 화면 전체 높이를 차지하는가?
|
|
||||||
- 🔵 파란색 테두리가 고정된 높이를 유지하는가?
|
|
||||||
- 🟢 초록색 테두리가 남은 공간을 차지하는가?
|
|
||||||
|
|
||||||
### 단계 2: 부모 체인 추적
|
|
||||||
|
|
||||||
스크롤이 작동하지 않으면 **부모 컨테이너부터 역순으로 추적**:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ❌ 문제 예시
|
|
||||||
<div className="flex flex-col"> {/* 높이가 확정되지 않음 */}
|
|
||||||
<div className="flex-1"> {/* flex-1이 작동하지 않음 */}
|
|
||||||
<ComponentWithScroll /> {/* 스크롤 실패 */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// ✅ 해결
|
|
||||||
<div className="flex flex-col h-screen"> {/* 높이 확정 */}
|
|
||||||
<div className="flex-1 overflow-hidden"> {/* overflow 제한 */}
|
|
||||||
<ComponentWithScroll /> {/* 스크롤 성공 */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 단계 3: 개발자 도구로 Computed Style 확인
|
|
||||||
|
|
||||||
브라우저 개발자 도구에서 확인:
|
|
||||||
|
|
||||||
1. **Height**: `auto`가 아닌 구체적인 px 값이 있는가?
|
|
||||||
2. **Display**: `flex`가 제대로 적용되었는가?
|
|
||||||
3. **Overflow**: `overflow-y: auto` 또는 `scroll`이 적용되었는가?
|
|
||||||
4. **Min-height**: `minHeight: 0`이 적용되었는가? (Flex item의 경우)
|
|
||||||
|
|
||||||
## 3. 해결 패턴
|
|
||||||
|
|
||||||
### 패턴 A: 최상위 Fixed/Absolute 컨테이너
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 페이지 레벨 (예: dataflow/page.tsx)
|
|
||||||
<div className="fixed inset-0 z-50 bg-background">
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
{/* 헤더 (고정) */}
|
|
||||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
|
||||||
헤더
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 에디터 (flex-1) */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{" "}
|
|
||||||
{/* ⚠️ overflow-hidden 필수! */}
|
|
||||||
<FlowEditor />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**핵심 포인트:**
|
|
||||||
|
|
||||||
- `fixed inset-0`: 뷰포트 전체 차지
|
|
||||||
- `flex h-full flex-col`: Flex column 레이아웃
|
|
||||||
- `flex-1 overflow-hidden`: 자식이 부모를 넘지 못하게 제한
|
|
||||||
|
|
||||||
### 패턴 B: 중첩된 Flex 컨테이너
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 컴포넌트 레벨 (예: FlowEditor.tsx)
|
|
||||||
<div
|
|
||||||
className="flex h-full w-full"
|
|
||||||
style={{ height: "100%", overflow: "hidden" }} // ⚠️ 인라인 스타일로 강제
|
|
||||||
>
|
|
||||||
{/* 좌측 사이드바 */}
|
|
||||||
<div className="h-full w-[300px] border-r bg-white">사이드바</div>
|
|
||||||
|
|
||||||
{/* 중앙 캔버스 */}
|
|
||||||
<div className="relative flex-1">캔버스</div>
|
|
||||||
|
|
||||||
{/* 우측 속성 패널 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "350px",
|
|
||||||
display: "flex", // ⚠️ Flex 컨테이너 명시
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
className="border-l bg-white"
|
|
||||||
>
|
|
||||||
<PropertiesPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**핵심 포인트:**
|
|
||||||
|
|
||||||
- 인라인 스타일 `height: '100%'`: Tailwind보다 우선순위 높음
|
|
||||||
- `display: "flex"`: Flex 컨테이너 명시
|
|
||||||
- `overflow: 'hidden'`: 자식 크기 제한
|
|
||||||
|
|
||||||
### 패턴 C: 스크롤 가능 영역
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 스크롤 영역 (예: PropertiesPanel.tsx)
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
overflow: "hidden", // ⚠️ 최상위는 overflow hidden
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 헤더 (고정) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flexShrink: 0, // ⚠️ 축소 방지
|
|
||||||
height: "64px", // ⚠️ 명시적 높이
|
|
||||||
}}
|
|
||||||
className="flex items-center justify-between border-b bg-white p-4"
|
|
||||||
>
|
|
||||||
헤더
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 스크롤 영역 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1, // ⚠️ 남은 공간 차지
|
|
||||||
minHeight: 0, // ⚠️ 핵심! Flex item 축소 허용
|
|
||||||
overflowY: "auto", // ⚠️ 세로 스크롤
|
|
||||||
overflowX: "hidden", // ⚠️ 가로 스크롤 방지
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 실제 콘텐츠 */}
|
|
||||||
<PropertiesContent />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**핵심 포인트:**
|
|
||||||
|
|
||||||
- `flexShrink: 0`: 헤더가 축소되지 않도록 고정
|
|
||||||
- `minHeight: 0`: **가장 중요!** Flex item이 축소되도록 허용
|
|
||||||
- `flex: 1`: 남은 공간 모두 차지
|
|
||||||
- `overflowY: 'auto'`: 콘텐츠가 넘치면 스크롤 생성
|
|
||||||
|
|
||||||
## 4. 왜 `minHeight: 0`이 필요한가?
|
|
||||||
|
|
||||||
### Flexbox의 기본 동작
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Flexbox의 기본값 */
|
|
||||||
.flex-item {
|
|
||||||
min-height: auto; /* 콘텐츠 크기만큼 늘어남 */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**문제:**
|
|
||||||
|
|
||||||
- Flex item은 **콘텐츠 크기만큼 늘어나려고 함**
|
|
||||||
- `flex: 1`만으로는 **스크롤이 생기지 않고 부모를 밀어냄**
|
|
||||||
- 결과: 스크롤 영역이 화면 밖으로 넘어감
|
|
||||||
|
|
||||||
**해결:**
|
|
||||||
|
|
||||||
```css
|
|
||||||
.flex-item {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0; /* 축소 허용 → 스크롤 발생 */
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Tailwind vs 인라인 스타일
|
|
||||||
|
|
||||||
### 언제 인라인 스타일을 사용하는가?
|
|
||||||
|
|
||||||
**Tailwind가 작동하지 않을 때:**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ❌ Tailwind가 작동하지 않음
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
|
|
||||||
// ✅ 인라인 스타일로 강제
|
|
||||||
<div
|
|
||||||
className="flex flex-col"
|
|
||||||
style={{ height: '100%', overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
**이유:**
|
|
||||||
|
|
||||||
1. **CSS 특이성**: 인라인 스타일이 가장 높은 우선순위
|
|
||||||
2. **동적 계산**: 브라우저가 직접 해석
|
|
||||||
3. **디버깅 쉬움**: 개발자 도구에서 바로 확인 가능
|
|
||||||
|
|
||||||
## 6. 체크리스트
|
|
||||||
|
|
||||||
스크롤 문제 발생 시 확인할 사항:
|
|
||||||
|
|
||||||
### 레이아웃 체크
|
|
||||||
|
|
||||||
- [ ] 최상위 컨테이너: `fixed` 또는 `absolute`로 높이 확정
|
|
||||||
- [ ] 부모: `flex flex-col h-full`
|
|
||||||
- [ ] 중간 컨테이너: `flex-1 overflow-hidden`
|
|
||||||
- [ ] 스크롤 컨테이너 부모: `display: flex, flexDirection: column, height: 100%`
|
|
||||||
|
|
||||||
### 스크롤 영역 체크
|
|
||||||
|
|
||||||
- [ ] 헤더: `flexShrink: 0` + 명시적 높이
|
|
||||||
- [ ] 스크롤 영역: `flex: 1, minHeight: 0, overflowY: auto`
|
|
||||||
- [ ] 콘텐츠: 자연스러운 높이 (height 제약 없음)
|
|
||||||
|
|
||||||
### 디버깅 체크
|
|
||||||
|
|
||||||
- [ ] 컬러 테두리로 각 레이어의 크기 확인
|
|
||||||
- [ ] 개발자 도구로 Computed Style 확인
|
|
||||||
- [ ] 부모 체인을 역순으로 추적
|
|
||||||
- [ ] `minHeight: 0` 적용 확인
|
|
||||||
|
|
||||||
## 7. 일반적인 실수
|
|
||||||
|
|
||||||
### 실수 1: 부모의 높이 미확정
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ❌ 부모의 높이가 auto
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex-1">
|
|
||||||
<ScrollComponent /> {/* 작동 안 함 */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// ✅ 부모의 높이 확정
|
|
||||||
<div className="flex flex-col h-screen">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<ScrollComponent /> {/* 작동 */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 실수 2: overflow-hidden 누락
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ❌ overflow-hidden 없음
|
|
||||||
<div className="flex-1">
|
|
||||||
<ScrollComponent /> {/* 부모를 밀어냄 */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// ✅ overflow-hidden 추가
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<ScrollComponent /> {/* 제한됨 */}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 실수 3: minHeight: 0 누락
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ❌ minHeight: 0 없음
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
|
||||||
{/* 스크롤 안 됨 */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// ✅ minHeight: 0 추가
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
|
||||||
{/* 스크롤 됨 */}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 실수 4: display: flex 누락
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ❌ Flex 컨테이너 미지정
|
|
||||||
<div style={{ height: '100%', width: '350px' }}>
|
|
||||||
<PropertiesPanel /> {/* flex-1이 작동 안 함 */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// ✅ Flex 컨테이너 명시
|
|
||||||
<div style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '350px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}>
|
|
||||||
<PropertiesPanel /> {/* 작동 */}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 완전한 예시
|
|
||||||
|
|
||||||
### 전체 레이아웃 구조
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 페이지 (dataflow/page.tsx)
|
|
||||||
<div className="fixed inset-0 z-50 bg-background">
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
|
||||||
헤더
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 에디터 */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<FlowEditor />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// 에디터 (FlowEditor.tsx)
|
|
||||||
<div
|
|
||||||
className="flex h-full w-full"
|
|
||||||
style={{ height: '100%', overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
{/* 사이드바 */}
|
|
||||||
<div className="h-full w-[300px] border-r">
|
|
||||||
사이드바
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 캔버스 */}
|
|
||||||
<div className="relative flex-1">
|
|
||||||
캔버스
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 속성 패널 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "350px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
className="border-l bg-white"
|
|
||||||
>
|
|
||||||
<PropertiesPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// 속성 패널 (PropertiesPanel.tsx)
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
height: '64px'
|
|
||||||
}}
|
|
||||||
className="flex items-center justify-between border-b bg-white p-4"
|
|
||||||
>
|
|
||||||
헤더
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 스크롤 영역 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 콘텐츠 */}
|
|
||||||
<PropertiesContent />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. 요약
|
|
||||||
|
|
||||||
### 핵심 원칙
|
|
||||||
|
|
||||||
1. **높이 확정**: 부모 체인의 모든 요소가 명시적인 높이를 가져야 함
|
|
||||||
2. **overflow 제어**: 중간 컨테이너는 `overflow-hidden`으로 자식 제한
|
|
||||||
3. **Flex 명시**: `display: flex` + `flexDirection: column` 명시
|
|
||||||
4. **minHeight: 0**: 스크롤 영역의 Flex item은 반드시 `minHeight: 0` 적용
|
|
||||||
5. **인라인 스타일**: Tailwind가 작동하지 않으면 인라인 스타일 사용
|
|
||||||
|
|
||||||
### 디버깅 순서
|
|
||||||
|
|
||||||
1. 🎨 **컬러 테두리** 추가로 시각적 확인
|
|
||||||
2. 🔍 **개발자 도구**로 Computed Style 확인
|
|
||||||
3. 🔗 **부모 체인** 역순으로 추적
|
|
||||||
4. ✅ **체크리스트** 항목 확인
|
|
||||||
5. 🔧 **패턴 적용** 및 테스트
|
|
||||||
|
|
||||||
### 최종 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
페이지 (fixed inset-0)
|
|
||||||
└─ flex flex-col h-full
|
|
||||||
├─ 헤더 (고정)
|
|
||||||
└─ 컨테이너 (flex-1 overflow-hidden)
|
|
||||||
└─ 에디터 (height: 100%, overflow: hidden)
|
|
||||||
└─ flex row
|
|
||||||
└─ 패널 (display: flex, flexDirection: column)
|
|
||||||
└─ 패널 내부 (height: 100%)
|
|
||||||
├─ 헤더 (flexShrink: 0, height: 64px)
|
|
||||||
└─ 스크롤 (flex: 1, minHeight: 0, overflowY: auto)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 10. 참고 자료
|
|
||||||
|
|
||||||
이 가이드는 다음 파일을 기반으로 작성되었습니다:
|
|
||||||
|
|
||||||
- [dataflow/page.tsx](<mdc:frontend/app/(main)/admin/dataflow/page.tsx>)
|
|
||||||
- [FlowEditor.tsx](mdc:frontend/components/dataflow/node-editor/FlowEditor.tsx)
|
|
||||||
- [PropertiesPanel.tsx](mdc:frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx)
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
# TableListComponent 개발 가이드
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다.
|
|
||||||
|
|
||||||
**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 핵심 기능 목록
|
|
||||||
|
|
||||||
### 1. 인라인 편집 (Inline Editing)
|
|
||||||
|
|
||||||
- 셀 더블클릭 또는 F2 키로 편집 모드 진입
|
|
||||||
- 직접 타이핑으로도 편집 모드 진입 가능
|
|
||||||
- Enter로 저장, Escape로 취소
|
|
||||||
- **컬럼별 편집 가능 여부 설정** (`editable` 속성)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ColumnConfig에서 editable 속성 사용
|
|
||||||
interface ColumnConfig {
|
|
||||||
editable?: boolean; // false면 해당 컬럼 인라인 편집 불가
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**편집 불가 컬럼 체크 필수 위치**:
|
|
||||||
1. `handleCellDoubleClick` - 더블클릭 편집
|
|
||||||
2. `onKeyDown` F2 케이스 - 키보드 편집
|
|
||||||
3. `onKeyDown` default 케이스 - 직접 타이핑 편집
|
|
||||||
4. 컨텍스트 메뉴 "셀 편집" 옵션
|
|
||||||
|
|
||||||
### 2. 배치 편집 (Batch Editing)
|
|
||||||
|
|
||||||
- 여러 셀 수정 후 일괄 저장/취소
|
|
||||||
- `pendingChanges` Map으로 변경사항 추적
|
|
||||||
- 저장 전 유효성 검증
|
|
||||||
|
|
||||||
### 3. 데이터 유효성 검증 (Validation)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type ValidationRule = {
|
|
||||||
required?: boolean;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
minLength?: number;
|
|
||||||
maxLength?: number;
|
|
||||||
pattern?: RegExp;
|
|
||||||
customMessage?: string;
|
|
||||||
validate?: (value: any, row: any) => string | null;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 컬럼 헤더 필터 (Header Filter)
|
|
||||||
|
|
||||||
- 각 컬럼 헤더에 필터 아이콘
|
|
||||||
- 고유값 목록에서 다중 선택 필터링
|
|
||||||
- `headerFilters` Map으로 필터 상태 관리
|
|
||||||
|
|
||||||
### 5. 필터 빌더 (Filter Builder)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface FilterCondition {
|
|
||||||
id: string;
|
|
||||||
column: string;
|
|
||||||
operator: "equals" | "notEquals" | "contains" | "notContains" |
|
|
||||||
"startsWith" | "endsWith" | "greaterThan" | "lessThan" |
|
|
||||||
"greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty";
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterGroup {
|
|
||||||
id: string;
|
|
||||||
logic: "AND" | "OR";
|
|
||||||
conditions: FilterCondition[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 검색 패널 (Search Panel)
|
|
||||||
|
|
||||||
- 전체 데이터 검색
|
|
||||||
- 검색어 하이라이팅
|
|
||||||
- `searchHighlights` Map으로 하이라이트 위치 관리
|
|
||||||
|
|
||||||
### 7. 엑셀 내보내기 (Excel Export)
|
|
||||||
|
|
||||||
- `xlsx` 라이브러리 사용
|
|
||||||
- 현재 표시 데이터 또는 전체 데이터 내보내기
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
|
|
||||||
// 사용 예시
|
|
||||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
|
||||||
const workbook = XLSX.utils.book_new();
|
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
|
|
||||||
XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. 클립보드 복사 (Copy to Clipboard)
|
|
||||||
|
|
||||||
- 선택된 행 또는 전체 데이터 복사
|
|
||||||
- 탭 구분자로 엑셀 붙여넣기 호환
|
|
||||||
|
|
||||||
### 9. 컨텍스트 메뉴 (Context Menu)
|
|
||||||
|
|
||||||
- 우클릭으로 메뉴 표시
|
|
||||||
- 셀 편집, 행 복사, 행 삭제 등 옵션
|
|
||||||
- 편집 불가 컬럼은 "(잠김)" 표시
|
|
||||||
|
|
||||||
### 10. 키보드 네비게이션
|
|
||||||
|
|
||||||
| 키 | 동작 |
|
|
||||||
|---|---|
|
|
||||||
| Arrow Keys | 셀 이동 |
|
|
||||||
| Tab | 다음 셀 |
|
|
||||||
| Shift+Tab | 이전 셀 |
|
|
||||||
| F2 | 편집 모드 |
|
|
||||||
| Enter | 저장 후 아래로 이동 |
|
|
||||||
| Escape | 편집 취소 |
|
|
||||||
| Ctrl+C | 복사 |
|
|
||||||
| Delete | 셀 값 삭제 |
|
|
||||||
|
|
||||||
### 11. 컬럼 리사이징
|
|
||||||
|
|
||||||
- 컬럼 헤더 경계 드래그로 너비 조절
|
|
||||||
- `columnWidths` 상태로 관리
|
|
||||||
- localStorage에 저장
|
|
||||||
|
|
||||||
### 12. 컬럼 순서 변경
|
|
||||||
|
|
||||||
- 드래그 앤 드롭으로 컬럼 순서 변경
|
|
||||||
- `columnOrder` 상태로 관리
|
|
||||||
- localStorage에 저장
|
|
||||||
|
|
||||||
### 13. 상태 영속성 (State Persistence)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// localStorage 키 패턴
|
|
||||||
const stateKey = `tableState_${tableName}_${userId}`;
|
|
||||||
|
|
||||||
// 저장되는 상태
|
|
||||||
interface TableState {
|
|
||||||
columnWidths: Record<string, number>;
|
|
||||||
columnOrder: string[];
|
|
||||||
sortBy: string;
|
|
||||||
sortOrder: "asc" | "desc";
|
|
||||||
frozenColumns: string[];
|
|
||||||
columnVisibility: Record<string, boolean>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 14. 그룹화 및 그룹 소계
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface GroupedData {
|
|
||||||
groupKey: string;
|
|
||||||
groupValues: Record<string, any>;
|
|
||||||
items: any[];
|
|
||||||
count: number;
|
|
||||||
summary?: Record<string, { sum: number; avg: number; count: number }>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 15. 총계 요약 (Total Summary)
|
|
||||||
|
|
||||||
- 숫자 컬럼의 합계, 평균, 개수 표시
|
|
||||||
- 테이블 하단에 요약 행 렌더링
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 캐싱 전략
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 테이블 컬럼 캐시
|
|
||||||
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
|
|
||||||
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
|
||||||
|
|
||||||
// API 호출 디바운싱
|
|
||||||
const debouncedApiCall = <T extends any[], R>(
|
|
||||||
key: string,
|
|
||||||
fn: (...args: T) => Promise<R>,
|
|
||||||
delay: number = 300
|
|
||||||
) => { ... };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 필수 Import
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
|
||||||
import { TableListConfig, ColumnConfig } from "./types";
|
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
|
||||||
import { codeCache } from "@/lib/caching/codeCache";
|
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 주요 상태 (State)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 데이터 관련
|
|
||||||
const [tableData, setTableData] = useState<any[]>([]);
|
|
||||||
const [filteredData, setFilteredData] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// 편집 관련
|
|
||||||
const [editingCell, setEditingCell] = useState<{
|
|
||||||
rowIndex: number;
|
|
||||||
colIndex: number;
|
|
||||||
columnName: string;
|
|
||||||
originalValue: any;
|
|
||||||
} | null>(null);
|
|
||||||
const [editingValue, setEditingValue] = useState<string>("");
|
|
||||||
const [pendingChanges, setPendingChanges] = useState<Map<string, Map<string, any>>>(new Map());
|
|
||||||
const [validationErrors, setValidationErrors] = useState<Map<string, Map<string, string>>>(new Map());
|
|
||||||
|
|
||||||
// 필터 관련
|
|
||||||
const [headerFilters, setHeaderFilters] = useState<Map<string, Set<string>>>(new Map());
|
|
||||||
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
|
|
||||||
const [globalSearchText, setGlobalSearchText] = useState("");
|
|
||||||
const [searchHighlights, setSearchHighlights] = useState<Map<string, number[]>>(new Map());
|
|
||||||
|
|
||||||
// 컬럼 관련
|
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
|
||||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({});
|
|
||||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// 선택 관련
|
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
|
||||||
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
|
||||||
|
|
||||||
// 정렬 관련
|
|
||||||
const [sortBy, setSortBy] = useState<string>("");
|
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
|
||||||
|
|
||||||
// 페이지네이션
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(20);
|
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 편집 불가 컬럼 구현 체크리스트
|
|
||||||
|
|
||||||
새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요:
|
|
||||||
|
|
||||||
- [ ] `column.editable === false` 체크 추가
|
|
||||||
- [ ] 편집 불가 시 `toast.warning()` 메시지 표시
|
|
||||||
- [ ] `return` 또는 `break`로 편집 모드 진입 방지
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 표준 편집 불가 체크 패턴
|
|
||||||
const column = visibleColumns.find((col) => col.columnName === columnName);
|
|
||||||
if (column?.editable === false) {
|
|
||||||
toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 시각적 표시
|
|
||||||
|
|
||||||
### 편집 불가 컬럼 표시
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 헤더에 잠금 아이콘
|
|
||||||
{column.editable === false && (
|
|
||||||
<Lock className="ml-1 h-3 w-3 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
// 셀 배경색
|
|
||||||
className={cn(
|
|
||||||
column.editable === false && "bg-gray-50 dark:bg-gray-900/30"
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 성능 최적화
|
|
||||||
|
|
||||||
1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값
|
|
||||||
2. **useCallback 사용**: 이벤트 핸들러 함수들
|
|
||||||
3. **디바운싱**: API 호출, 검색, 필터링
|
|
||||||
4. **캐싱**: 테이블 컬럼 정보, 코드 데이터
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 주의사항
|
|
||||||
|
|
||||||
1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함
|
|
||||||
2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인
|
|
||||||
3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성
|
|
||||||
4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 관련 파일
|
|
||||||
|
|
||||||
- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의
|
|
||||||
- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널
|
|
||||||
- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달
|
|
||||||
- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블
|
|
||||||
|
|
@ -1,592 +0,0 @@
|
||||||
# 테이블 타입 관리 SQL 작성 가이드
|
|
||||||
|
|
||||||
테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다.
|
|
||||||
|
|
||||||
## 핵심 원칙
|
|
||||||
|
|
||||||
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)`
|
|
||||||
2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등
|
|
||||||
3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수
|
|
||||||
4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 테이블 생성 DDL 템플릿
|
|
||||||
|
|
||||||
### 기본 구조
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE "테이블명" (
|
|
||||||
-- 시스템 기본 컬럼 (자동 포함)
|
|
||||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
||||||
"created_date" timestamp DEFAULT now(),
|
|
||||||
"updated_date" timestamp DEFAULT now(),b
|
|
||||||
"writer" varchar(500) DEFAULT NULL,
|
|
||||||
"company_code" varchar(500),
|
|
||||||
|
|
||||||
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
|
|
||||||
"컬럼1" varchar(500),
|
|
||||||
"컬럼2" varchar(500),
|
|
||||||
"컬럼3" varchar(500)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 예시: 고객 테이블 생성
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE "customer_info" (
|
|
||||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
||||||
"created_date" timestamp DEFAULT now(),
|
|
||||||
"updated_date" timestamp DEFAULT now(),
|
|
||||||
"writer" varchar(500) DEFAULT NULL,
|
|
||||||
"company_code" varchar(500),
|
|
||||||
|
|
||||||
"customer_name" varchar(500),
|
|
||||||
"customer_code" varchar(500),
|
|
||||||
"phone" varchar(500),
|
|
||||||
"email" varchar(500),
|
|
||||||
"address" varchar(500),
|
|
||||||
"status" varchar(500),
|
|
||||||
"registration_date" varchar(500)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 메타데이터 테이블 등록
|
|
||||||
|
|
||||||
테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다.
|
|
||||||
|
|
||||||
### 2.1 table_labels (테이블 메타데이터)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
|
||||||
VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now())
|
|
||||||
ON CONFLICT (table_name)
|
|
||||||
DO UPDATE SET
|
|
||||||
table_label = EXCLUDED.table_label,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
updated_date = now();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 table_type_columns (컬럼 타입 정보)
|
|
||||||
|
|
||||||
**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order`
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 기본 컬럼 등록 (display_order: -5 ~ -1)
|
|
||||||
INSERT INTO table_type_columns (
|
|
||||||
table_name, column_name, company_code, input_type, detail_settings,
|
|
||||||
is_nullable, display_order, created_date, updated_date
|
|
||||||
) VALUES
|
|
||||||
('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
|
|
||||||
('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
|
|
||||||
('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
|
|
||||||
('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
|
|
||||||
('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
|
|
||||||
ON CONFLICT (table_name, column_name, company_code)
|
|
||||||
DO UPDATE SET
|
|
||||||
input_type = EXCLUDED.input_type,
|
|
||||||
display_order = EXCLUDED.display_order,
|
|
||||||
updated_date = now();
|
|
||||||
|
|
||||||
-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작)
|
|
||||||
INSERT INTO table_type_columns (
|
|
||||||
table_name, column_name, company_code, input_type, detail_settings,
|
|
||||||
is_nullable, display_order, created_date, updated_date
|
|
||||||
) VALUES
|
|
||||||
('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()),
|
|
||||||
('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()),
|
|
||||||
('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now())
|
|
||||||
ON CONFLICT (table_name, column_name, company_code)
|
|
||||||
DO UPDATE SET
|
|
||||||
input_type = EXCLUDED.input_type,
|
|
||||||
detail_settings = EXCLUDED.detail_settings,
|
|
||||||
display_order = EXCLUDED.display_order,
|
|
||||||
updated_date = now();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 column_labels (레거시 호환용 - 필수)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 기본 컬럼 등록
|
|
||||||
INSERT INTO column_labels (
|
|
||||||
table_name, column_name, column_label, input_type, detail_settings,
|
|
||||||
description, display_order, is_visible, created_date, updated_date
|
|
||||||
) VALUES
|
|
||||||
('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()),
|
|
||||||
('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
|
|
||||||
('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
|
|
||||||
('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
|
|
||||||
('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, 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,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
display_order = EXCLUDED.display_order,
|
|
||||||
is_visible = EXCLUDED.is_visible,
|
|
||||||
updated_date = now();
|
|
||||||
|
|
||||||
-- 사용자 정의 컬럼 등록
|
|
||||||
INSERT INTO column_labels (
|
|
||||||
table_name, column_name, column_label, input_type, detail_settings,
|
|
||||||
description, display_order, is_visible, created_date, updated_date
|
|
||||||
) VALUES
|
|
||||||
('테이블명', '컬럼1', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()),
|
|
||||||
('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, 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,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
display_order = EXCLUDED.display_order,
|
|
||||||
is_visible = EXCLUDED.is_visible,
|
|
||||||
updated_date = now();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Input Type 정의
|
|
||||||
|
|
||||||
### 지원되는 Input Type 목록
|
|
||||||
|
|
||||||
| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 |
|
|
||||||
| ---------- | ------------- | ------------ | -------------------- |
|
|
||||||
| `text` | 텍스트 입력 | VARCHAR(500) | Input |
|
|
||||||
| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) |
|
|
||||||
| `date` | 날짜/시간 | VARCHAR(500) | DatePicker |
|
|
||||||
| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) |
|
|
||||||
| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) |
|
|
||||||
| `select` | 선택 목록 | VARCHAR(500) | Select |
|
|
||||||
| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox |
|
|
||||||
| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup |
|
|
||||||
| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea |
|
|
||||||
| `file` | 파일 업로드 | VARCHAR(500) | FileUpload |
|
|
||||||
|
|
||||||
### WebType → InputType 변환 규칙
|
|
||||||
|
|
||||||
```
|
|
||||||
text, textarea, email, tel, url, password → text
|
|
||||||
number, decimal → number
|
|
||||||
date, datetime, time → date
|
|
||||||
select, dropdown → select
|
|
||||||
checkbox, boolean → checkbox
|
|
||||||
radio → radio
|
|
||||||
code → code
|
|
||||||
entity → entity
|
|
||||||
file → text
|
|
||||||
button → text
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Detail Settings 설정
|
|
||||||
|
|
||||||
### 4.1 Code 타입 (공통코드 참조)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"codeCategory": "코드_카테고리_ID"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```sql
|
|
||||||
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
|
|
||||||
VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Entity 타입 (테이블 참조)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"referenceTable": "참조_테이블명",
|
|
||||||
"referenceColumn": "참조_컬럼명(보통 id)",
|
|
||||||
"displayColumn": "표시할_컬럼명"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```sql
|
|
||||||
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
|
|
||||||
VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 Select 타입 (정적 옵션)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"options": [
|
|
||||||
{ "label": "옵션1", "value": "value1" },
|
|
||||||
{ "label": "옵션2", "value": "value2" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 전체 예시: 주문 테이블 생성
|
|
||||||
|
|
||||||
### Step 1: DDL 실행
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE "order_info" (
|
|
||||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
||||||
"created_date" timestamp DEFAULT now(),
|
|
||||||
"updated_date" timestamp DEFAULT now(),
|
|
||||||
"writer" varchar(500) DEFAULT NULL,
|
|
||||||
"company_code" varchar(500),
|
|
||||||
|
|
||||||
"order_no" varchar(500),
|
|
||||||
"order_date" varchar(500),
|
|
||||||
"customer_id" varchar(500),
|
|
||||||
"total_amount" varchar(500),
|
|
||||||
"status" varchar(500),
|
|
||||||
"notes" varchar(500)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: table_labels 등록
|
|
||||||
|
|
||||||
```sql
|
|
||||||
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
|
||||||
VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now())
|
|
||||||
ON CONFLICT (table_name)
|
|
||||||
DO UPDATE SET
|
|
||||||
table_label = EXCLUDED.table_label,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
updated_date = now();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: table_type_columns 등록
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 기본 컬럼
|
|
||||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
|
||||||
VALUES
|
|
||||||
('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
|
|
||||||
('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
|
|
||||||
('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
|
|
||||||
('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
|
|
||||||
('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
|
|
||||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
|
|
||||||
|
|
||||||
-- 사용자 정의 컬럼
|
|
||||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
|
||||||
VALUES
|
|
||||||
('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()),
|
|
||||||
('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()),
|
|
||||||
('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()),
|
|
||||||
('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()),
|
|
||||||
('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()),
|
|
||||||
('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now())
|
|
||||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: column_labels 등록 (레거시 호환)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 기본 컬럼
|
|
||||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
|
||||||
VALUES
|
|
||||||
('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()),
|
|
||||||
('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
|
|
||||||
('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
|
|
||||||
('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
|
|
||||||
('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
|
|
||||||
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
|
|
||||||
|
|
||||||
-- 사용자 정의 컬럼
|
|
||||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
|
||||||
VALUES
|
|
||||||
('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()),
|
|
||||||
('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()),
|
|
||||||
('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()),
|
|
||||||
('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()),
|
|
||||||
('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()),
|
|
||||||
('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, 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, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 컬럼 추가 시
|
|
||||||
|
|
||||||
### DDL
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 메타데이터 등록
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- table_type_columns
|
|
||||||
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
|
|
||||||
VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now())
|
|
||||||
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
|
|
||||||
|
|
||||||
-- column_labels
|
|
||||||
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
|
|
||||||
VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now())
|
|
||||||
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 로그 테이블 생성 (선택사항)
|
|
||||||
|
|
||||||
변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다.
|
|
||||||
|
|
||||||
### 7.1 로그 테이블 DDL 템플릿
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 로그 테이블 생성
|
|
||||||
CREATE TABLE 테이블명_log (
|
|
||||||
log_id SERIAL PRIMARY KEY,
|
|
||||||
operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE
|
|
||||||
original_id VARCHAR(100), -- 원본 테이블 PK 값
|
|
||||||
changed_column VARCHAR(100), -- 변경된 컬럼명
|
|
||||||
old_value TEXT, -- 변경 전 값
|
|
||||||
new_value TEXT, -- 변경 후 값
|
|
||||||
changed_by VARCHAR(50), -- 변경자 ID
|
|
||||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
|
|
||||||
ip_address VARCHAR(50), -- 변경 요청 IP
|
|
||||||
user_agent TEXT, -- User Agent
|
|
||||||
full_row_before JSONB, -- 변경 전 전체 행
|
|
||||||
full_row_after JSONB -- 변경 후 전체 행
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 인덱스 생성
|
|
||||||
CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id);
|
|
||||||
CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at);
|
|
||||||
CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type);
|
|
||||||
|
|
||||||
-- 코멘트 추가
|
|
||||||
COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 트리거 함수 DDL 템플릿
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_column_name TEXT;
|
|
||||||
v_old_value TEXT;
|
|
||||||
v_new_value TEXT;
|
|
||||||
v_user_id VARCHAR(50);
|
|
||||||
v_ip_address VARCHAR(50);
|
|
||||||
BEGIN
|
|
||||||
v_user_id := current_setting('app.user_id', TRUE);
|
|
||||||
v_ip_address := current_setting('app.ip_address', TRUE);
|
|
||||||
|
|
||||||
IF (TG_OP = 'INSERT') THEN
|
|
||||||
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after)
|
|
||||||
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
|
|
||||||
RETURN NEW;
|
|
||||||
|
|
||||||
ELSIF (TG_OP = 'UPDATE') THEN
|
|
||||||
FOR v_column_name IN
|
|
||||||
SELECT column_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = '테이블명'
|
|
||||||
AND table_schema = 'public'
|
|
||||||
LOOP
|
|
||||||
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
|
||||||
INTO v_old_value, v_new_value
|
|
||||||
USING OLD, NEW;
|
|
||||||
|
|
||||||
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
|
||||||
INSERT INTO 테이블명_log (
|
|
||||||
operation_type, original_id, changed_column, old_value, new_value,
|
|
||||||
changed_by, ip_address, full_row_before, full_row_after
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value,
|
|
||||||
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
END LOOP;
|
|
||||||
RETURN NEW;
|
|
||||||
|
|
||||||
ELSIF (TG_OP = 'DELETE') THEN
|
|
||||||
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before)
|
|
||||||
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
|
|
||||||
RETURN OLD;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 트리거 DDL 템플릿
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TRIGGER 테이블명_audit_trigger
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON 테이블명
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 로그 설정 등록
|
|
||||||
|
|
||||||
```sql
|
|
||||||
INSERT INTO table_log_config (
|
|
||||||
original_table_name, log_table_name, trigger_name,
|
|
||||||
trigger_function_name, is_active, created_by, created_at
|
|
||||||
) VALUES (
|
|
||||||
'테이블명', '테이블명_log', '테이블명_audit_trigger',
|
|
||||||
'테이블명_log_trigger_func', 'Y', '생성자ID', now()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.5 table_labels에 use_log_table 플래그 설정
|
|
||||||
|
|
||||||
```sql
|
|
||||||
UPDATE table_labels
|
|
||||||
SET use_log_table = 'Y', updated_date = now()
|
|
||||||
WHERE table_name = '테이블명';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.6 전체 예시: order_info 로그 테이블 생성
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Step 1: 로그 테이블 생성
|
|
||||||
CREATE TABLE order_info_log (
|
|
||||||
log_id SERIAL PRIMARY KEY,
|
|
||||||
operation_type VARCHAR(10) NOT NULL,
|
|
||||||
original_id VARCHAR(100),
|
|
||||||
changed_column VARCHAR(100),
|
|
||||||
old_value TEXT,
|
|
||||||
new_value TEXT,
|
|
||||||
changed_by VARCHAR(50),
|
|
||||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
ip_address VARCHAR(50),
|
|
||||||
user_agent TEXT,
|
|
||||||
full_row_before JSONB,
|
|
||||||
full_row_after JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id);
|
|
||||||
CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at);
|
|
||||||
CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type);
|
|
||||||
|
|
||||||
COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력';
|
|
||||||
|
|
||||||
-- Step 2: 트리거 함수 생성
|
|
||||||
CREATE OR REPLACE FUNCTION order_info_log_trigger_func()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_column_name TEXT;
|
|
||||||
v_old_value TEXT;
|
|
||||||
v_new_value TEXT;
|
|
||||||
v_user_id VARCHAR(50);
|
|
||||||
v_ip_address VARCHAR(50);
|
|
||||||
BEGIN
|
|
||||||
v_user_id := current_setting('app.user_id', TRUE);
|
|
||||||
v_ip_address := current_setting('app.ip_address', TRUE);
|
|
||||||
|
|
||||||
IF (TG_OP = 'INSERT') THEN
|
|
||||||
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after)
|
|
||||||
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
|
|
||||||
RETURN NEW;
|
|
||||||
ELSIF (TG_OP = 'UPDATE') THEN
|
|
||||||
FOR v_column_name IN
|
|
||||||
SELECT column_name FROM information_schema.columns
|
|
||||||
WHERE table_name = 'order_info' AND table_schema = 'public'
|
|
||||||
LOOP
|
|
||||||
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
|
||||||
INTO v_old_value, v_new_value USING OLD, NEW;
|
|
||||||
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
|
||||||
INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
|
|
||||||
VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
|
|
||||||
END IF;
|
|
||||||
END LOOP;
|
|
||||||
RETURN NEW;
|
|
||||||
ELSIF (TG_OP = 'DELETE') THEN
|
|
||||||
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before)
|
|
||||||
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
|
|
||||||
RETURN OLD;
|
|
||||||
END IF;
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Step 3: 트리거 생성
|
|
||||||
CREATE TRIGGER order_info_audit_trigger
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON order_info
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func();
|
|
||||||
|
|
||||||
-- Step 4: 로그 설정 등록
|
|
||||||
INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at)
|
|
||||||
VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now());
|
|
||||||
|
|
||||||
-- Step 5: table_labels 플래그 업데이트
|
|
||||||
UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.7 로그 테이블 삭제
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 트리거 삭제
|
|
||||||
DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명;
|
|
||||||
|
|
||||||
-- 트리거 함수 삭제
|
|
||||||
DROP FUNCTION IF EXISTS 테이블명_log_trigger_func();
|
|
||||||
|
|
||||||
-- 로그 테이블 삭제
|
|
||||||
DROP TABLE IF EXISTS 테이블명_log;
|
|
||||||
|
|
||||||
-- 로그 설정 삭제
|
|
||||||
DELETE FROM table_log_config WHERE original_table_name = '테이블명';
|
|
||||||
|
|
||||||
-- table_labels 플래그 업데이트
|
|
||||||
UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 체크리스트
|
|
||||||
|
|
||||||
### 테이블 생성/수정 시 반드시 확인할 사항:
|
|
||||||
|
|
||||||
- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
|
|
||||||
- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
|
|
||||||
- [ ] `table_labels`에 테이블 메타데이터 등록
|
|
||||||
- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*')
|
|
||||||
- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환)
|
|
||||||
- [ ] 기본 컬럼 display_order: -5 ~ -1
|
|
||||||
- [ ] 사용자 정의 컬럼 display_order: 0부터 순차
|
|
||||||
- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
|
|
||||||
- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
|
|
||||||
|
|
||||||
### 로그 테이블 생성 시 확인할 사항 (선택):
|
|
||||||
|
|
||||||
- [ ] 로그 테이블 생성 (`테이블명_log`)
|
|
||||||
- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type)
|
|
||||||
- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`)
|
|
||||||
- [ ] 트리거 생성 (`테이블명_audit_trigger`)
|
|
||||||
- [ ] `table_log_config`에 로그 설정 등록
|
|
||||||
- [ ] `table_labels.use_log_table = 'Y'` 업데이트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 금지 사항
|
|
||||||
|
|
||||||
1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
|
|
||||||
2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
|
|
||||||
3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수
|
|
||||||
4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수
|
|
||||||
5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 참조 파일
|
|
||||||
|
|
||||||
- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
|
|
||||||
- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스
|
|
||||||
- `backend-node/src/types/ddl.ts`: DDL 타입 정의
|
|
||||||
- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
|
|
||||||
- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러
|
|
||||||
|
|
@ -1,343 +0,0 @@
|
||||||
# 고정 헤더 테이블 표준 가이드
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
스크롤 가능한 테이블에서 헤더를 상단에 고정하는 표준 구조입니다.
|
|
||||||
플로우 위젯의 스텝 데이터 리스트 테이블을 참조 기준으로 합니다.
|
|
||||||
|
|
||||||
## 필수 구조
|
|
||||||
|
|
||||||
### 1. 기본 HTML 구조
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
|
||||||
<Table noWrapper>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
|
||||||
헤더 1
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
|
||||||
헤더 2
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>{/* 데이터 행들 */}</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 필수 클래스 설명
|
|
||||||
|
|
||||||
#### 스크롤 컨테이너 (외부 div)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
className="relative overflow-auto"
|
|
||||||
style={{ height: "450px" }}
|
|
||||||
```
|
|
||||||
|
|
||||||
**필수 요소:**
|
|
||||||
|
|
||||||
- `relative`: sticky positioning의 기준점
|
|
||||||
- `overflow-auto`: 스크롤 활성화
|
|
||||||
- `height`: 고정 높이 (인라인 스타일 또는 Tailwind 클래스)
|
|
||||||
|
|
||||||
#### Table 컴포넌트
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Table noWrapper>
|
|
||||||
```
|
|
||||||
|
|
||||||
**필수 props:**
|
|
||||||
|
|
||||||
- `noWrapper`: Table 컴포넌트의 내부 wrapper 제거 (매우 중요!)
|
|
||||||
- 이것이 없으면 sticky header가 작동하지 않음
|
|
||||||
|
|
||||||
#### TableHead (헤더 셀)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
className =
|
|
||||||
"bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
|
|
||||||
```
|
|
||||||
|
|
||||||
**필수 클래스:**
|
|
||||||
|
|
||||||
- `bg-background`: 배경색 (스크롤 시 데이터가 보이지 않도록)
|
|
||||||
- `sticky top-0`: 상단 고정
|
|
||||||
- `z-10`: 다른 요소 위에 표시
|
|
||||||
- `border-b`: 하단 테두리
|
|
||||||
- `shadow-[0_1px_0_0_rgb(0,0,0,0.1)]`: 얇은 그림자 (헤더와 본문 구분)
|
|
||||||
|
|
||||||
### 3. 왼쪽 열 고정 (체크박스 등)
|
|
||||||
|
|
||||||
첫 번째 열도 고정하려면:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<TableHead className="bg-background sticky top-0 left-0 z-20 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
|
||||||
<Checkbox />
|
|
||||||
</TableHead>
|
|
||||||
```
|
|
||||||
|
|
||||||
**z-index 규칙:**
|
|
||||||
|
|
||||||
- 왼쪽+상단 고정: `z-20`
|
|
||||||
- 상단만 고정: `z-10`
|
|
||||||
- 왼쪽만 고정: `z-10`
|
|
||||||
- 일반 셀: z-index 없음
|
|
||||||
|
|
||||||
### 4. 완전한 예제 (체크박스 포함)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
|
||||||
<Table noWrapper>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
{/* 왼쪽 고정 체크박스 열 */}
|
|
||||||
<TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
|
||||||
<Checkbox checked={allSelected} onCheckedChange={handleSelectAll} />
|
|
||||||
</TableHead>
|
|
||||||
|
|
||||||
{/* 일반 헤더 열들 */}
|
|
||||||
{columns.map((col) => (
|
|
||||||
<TableHead
|
|
||||||
key={col}
|
|
||||||
className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm"
|
|
||||||
>
|
|
||||||
{col}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{data.map((row, index) => (
|
|
||||||
<TableRow key={index}>
|
|
||||||
{/* 왼쪽 고정 체크박스 */}
|
|
||||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedRows.has(index)}
|
|
||||||
onCheckedChange={() => toggleRow(index)}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 데이터 셀들 */}
|
|
||||||
{columns.map((col) => (
|
|
||||||
<TableCell key={col} className="border-b px-3 py-2">
|
|
||||||
{row[col]}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 반응형 대응
|
|
||||||
|
|
||||||
### 모바일: 카드 뷰
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
{
|
|
||||||
/* 모바일: 카드 뷰 */
|
|
||||||
}
|
|
||||||
<div className="overflow-y-auto sm:hidden" style={{ height: "450px" }}>
|
|
||||||
<div className="space-y-2 p-3">
|
|
||||||
{data.map((item, index) => (
|
|
||||||
<div key={index} className="bg-card rounded-md border p-3">
|
|
||||||
{/* 카드 내용 */}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>;
|
|
||||||
|
|
||||||
{
|
|
||||||
/* 데스크톱: 테이블 뷰 */
|
|
||||||
}
|
|
||||||
<div
|
|
||||||
className="relative hidden overflow-auto sm:block"
|
|
||||||
style={{ height: "450px" }}
|
|
||||||
>
|
|
||||||
<Table noWrapper>{/* 위의 테이블 구조 */}</Table>
|
|
||||||
</div>;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 자주하는 실수
|
|
||||||
|
|
||||||
### ❌ 잘못된 예시
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
{
|
|
||||||
/* 1. noWrapper 없음 - sticky 작동 안함 */
|
|
||||||
}
|
|
||||||
<Table>
|
|
||||||
<TableHeader>...</TableHeader>
|
|
||||||
</Table>;
|
|
||||||
|
|
||||||
{
|
|
||||||
/* 2. 배경색 없음 - 스크롤 시 데이터가 보임 */
|
|
||||||
}
|
|
||||||
<TableHead className="sticky top-0">헤더</TableHead>;
|
|
||||||
|
|
||||||
{
|
|
||||||
/* 3. relative 없음 - sticky 기준점 없음 */
|
|
||||||
}
|
|
||||||
<div className="overflow-auto">
|
|
||||||
<Table noWrapper>...</Table>
|
|
||||||
</div>;
|
|
||||||
|
|
||||||
{
|
|
||||||
/* 4. 고정 높이 없음 - 스크롤 발생 안함 */
|
|
||||||
}
|
|
||||||
<div className="relative overflow-auto">
|
|
||||||
<Table noWrapper>...</Table>
|
|
||||||
</div>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ 올바른 예시
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
{
|
|
||||||
/* 모든 필수 요소 포함 */
|
|
||||||
}
|
|
||||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
|
||||||
<Table noWrapper>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
|
||||||
헤더
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>...</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 높이 설정 가이드
|
|
||||||
|
|
||||||
### 권장 높이값
|
|
||||||
|
|
||||||
- **소형 리스트**: `300px` ~ `400px`
|
|
||||||
- **중형 리스트**: `450px` ~ `600px` (플로우 위젯 기준)
|
|
||||||
- **대형 리스트**: `calc(100vh - 200px)` (화면 높이 기준)
|
|
||||||
|
|
||||||
### 동적 높이 계산
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 화면 높이의 60%
|
|
||||||
style={{ height: "60vh" }}
|
|
||||||
|
|
||||||
// 화면 높이 - 헤더/푸터 제외
|
|
||||||
style={{ height: "calc(100vh - 250px)" }}
|
|
||||||
|
|
||||||
// 부모 요소 기준
|
|
||||||
className="h-full overflow-auto"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 성능 최적화
|
|
||||||
|
|
||||||
### 1. 가상 스크롤 (대량 데이터)
|
|
||||||
|
|
||||||
데이터가 1000건 이상인 경우 `react-virtual` 사용 권장:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
|
||||||
count: data.length,
|
|
||||||
getScrollElement: () => parentRef.current,
|
|
||||||
estimateSize: () => 50, // 행 높이
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 페이지네이션
|
|
||||||
|
|
||||||
대량 데이터는 페이지 단위로 렌더링:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const paginatedData = data.slice((page - 1) * pageSize, page * pageSize);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 접근성
|
|
||||||
|
|
||||||
### ARIA 레이블
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<div
|
|
||||||
className="relative overflow-auto"
|
|
||||||
style={{ height: "450px" }}
|
|
||||||
role="region"
|
|
||||||
aria-label="스크롤 가능한 데이터 테이블"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<Table noWrapper aria-label="데이터 목록">
|
|
||||||
{/* 테이블 내용 */}
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 키보드 네비게이션
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<TableRow
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
handleRowClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 행 내용 */}
|
|
||||||
</TableRow>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 다크 모드 대응
|
|
||||||
|
|
||||||
### 배경색
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
{
|
|
||||||
/* 라이트/다크 모드 모두 대응 */
|
|
||||||
}
|
|
||||||
className = "bg-background"; // ✅ 권장
|
|
||||||
|
|
||||||
{
|
|
||||||
/* 고정 색상 - 다크 모드 문제 */
|
|
||||||
}
|
|
||||||
className = "bg-white"; // ❌ 비권장
|
|
||||||
```
|
|
||||||
|
|
||||||
### 그림자
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
{
|
|
||||||
/* 다크 모드에서도 보이는 그림자 */
|
|
||||||
}
|
|
||||||
className = "shadow-[0_1px_0_0_hsl(var(--border))]";
|
|
||||||
|
|
||||||
{
|
|
||||||
/* 또는 */
|
|
||||||
}
|
|
||||||
className = "shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참조 파일
|
|
||||||
|
|
||||||
- **구현 예시**: `frontend/components/screen/widgets/FlowWidget.tsx` (line 760-820)
|
|
||||||
- **Table 컴포넌트**: `frontend/components/ui/table.tsx`
|
|
||||||
|
|
||||||
## 체크리스트
|
|
||||||
|
|
||||||
테이블 구현 시 다음을 확인하세요:
|
|
||||||
|
|
||||||
- [ ] 외부 div에 `relative overflow-auto` 적용
|
|
||||||
- [ ] 외부 div에 고정 높이 설정
|
|
||||||
- [ ] `<Table noWrapper>` 사용
|
|
||||||
- [ ] TableHead에 `bg-background sticky top-0 z-10` 적용
|
|
||||||
- [ ] TableHead에 `border-b shadow-[...]` 적용
|
|
||||||
- [ ] 왼쪽 고정 열은 `z-20` 사용
|
|
||||||
- [ ] 모바일 반응형 대응 (카드 뷰)
|
|
||||||
- [ ] 다크 모드 호환 색상 사용
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,342 @@
|
||||||
|
/**
|
||||||
|
* 기본 템플릿으로 예시 리포트 16건 생성 스크립트
|
||||||
|
* 실행: cd backend-node && node scripts/create-sample-reports.js
|
||||||
|
*
|
||||||
|
* - 한글 카테고리 사용
|
||||||
|
* - 작성자/작성일 다양하게 구성
|
||||||
|
* - 8가지 기본 템플릿을 활용하여 각 2건씩 총 16건
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
function apiRequest(method, path, token, body = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const bodyStr = body ? JSON.stringify(body) : null;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
if (bodyStr) headers["Content-Length"] = Buffer.byteLength(bodyStr);
|
||||||
|
const req = http.request(
|
||||||
|
{ hostname: "localhost", port: 8080, path, method, headers },
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk) => (data += chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
if (bodyStr) req.write(bodyStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getToken() {
|
||||||
|
const body = JSON.stringify({ userId: "wace", password: "qlalfqjsgh11" });
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: "localhost", port: 8080, path: "/api/auth/login", method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (c) => (data += c));
|
||||||
|
res.on("end", () => {
|
||||||
|
try { resolve(JSON.parse(data).data?.token); } catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 작성자 풀 (user_id) ───────────────────────────────────────────────────────
|
||||||
|
const AUTHORS = ["wace", "drkim", "admin"];
|
||||||
|
|
||||||
|
// ─── 작성일 풀 (다양한 날짜) ────────────────────────────────────────────────────
|
||||||
|
const DATES = [
|
||||||
|
"2025-11-15", "2025-12-03", "2025-12-22",
|
||||||
|
"2026-01-08", "2026-01-19", "2026-01-28",
|
||||||
|
"2026-02-05", "2026-02-14", "2026-02-21",
|
||||||
|
"2026-02-28", "2026-03-01", "2026-03-03",
|
||||||
|
"2026-03-05", "2026-03-07", "2026-03-08", "2026-03-10",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 16건 예시 리포트 정의 ──────────────────────────────────────────────────────
|
||||||
|
const SAMPLE_REPORTS = [
|
||||||
|
// 견적서 x2
|
||||||
|
{
|
||||||
|
reportNameKor: "표준 견적서",
|
||||||
|
reportNameEng: "Standard Quotation",
|
||||||
|
reportType: "견적서",
|
||||||
|
description: "수신자/공급자 정보, 품목 테이블, 공급가액/세액/합계 자동 계산 포함",
|
||||||
|
templateType: "QUOTATION",
|
||||||
|
author: AUTHORS[0], date: DATES[15],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reportNameKor: "해외 견적서 (영문)",
|
||||||
|
reportNameEng: "Export Quotation",
|
||||||
|
reportType: "견적서",
|
||||||
|
description: "해외 거래처용 영문 견적서 양식 (FOB/CIF 조건 포함)",
|
||||||
|
templateType: "EXPORT_QUOTATION",
|
||||||
|
author: AUTHORS[1], date: DATES[10],
|
||||||
|
},
|
||||||
|
// 발주서 x2
|
||||||
|
{
|
||||||
|
reportNameKor: "자재 발주서",
|
||||||
|
reportNameEng: "Material Purchase Order",
|
||||||
|
reportType: "발주서",
|
||||||
|
description: "발주처/자사 정보, 품목 테이블, 발주조건 포함 (4단계 결재)",
|
||||||
|
templateType: "PURCHASE_ORDER",
|
||||||
|
author: AUTHORS[0], date: DATES[14],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reportNameKor: "외주 가공 발주서",
|
||||||
|
reportNameEng: "Outsourcing Purchase Order",
|
||||||
|
reportType: "발주서",
|
||||||
|
description: "외주 협력업체 가공 의뢰용 발주서 양식",
|
||||||
|
templateType: "PURCHASE_ORDER",
|
||||||
|
author: AUTHORS[2], date: DATES[7],
|
||||||
|
},
|
||||||
|
// 수주 확인서 x2
|
||||||
|
{
|
||||||
|
reportNameKor: "수주 확인서",
|
||||||
|
reportNameEng: "Sales Order Confirmation",
|
||||||
|
reportType: "수주확인서",
|
||||||
|
description: "발주처/자사 정보, 품목 테이블, 수주 합계금액, 납품조건 포함",
|
||||||
|
templateType: "SALES_ORDER",
|
||||||
|
author: AUTHORS[1], date: DATES[13],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reportNameKor: "긴급 수주 확인서",
|
||||||
|
reportNameEng: "Urgent Sales Order Confirmation",
|
||||||
|
reportType: "수주확인서",
|
||||||
|
description: "긴급 납기 대응용 수주 확인서 (단납기 조건 포함)",
|
||||||
|
templateType: "SALES_ORDER",
|
||||||
|
author: AUTHORS[0], date: DATES[5],
|
||||||
|
},
|
||||||
|
// 거래명세서 x2
|
||||||
|
{
|
||||||
|
reportNameKor: "거래 명세서",
|
||||||
|
reportNameEng: "Transaction Statement",
|
||||||
|
reportType: "거래명세서",
|
||||||
|
description: "공급자/공급받는자 정보, 품목 테이블, 합계금액, 인수 서명란 포함",
|
||||||
|
templateType: "DELIVERY_NOTE",
|
||||||
|
author: AUTHORS[0], date: DATES[12],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reportNameKor: "월별 거래 명세서",
|
||||||
|
reportNameEng: "Monthly Transaction Statement",
|
||||||
|
reportType: "거래명세서",
|
||||||
|
description: "월간 거래 내역 합산 명세서 양식",
|
||||||
|
templateType: "DELIVERY_NOTE",
|
||||||
|
author: AUTHORS[2], date: DATES[3],
|
||||||
|
},
|
||||||
|
// 작업지시서 x2
|
||||||
|
{
|
||||||
|
reportNameKor: "생산 작업지시서",
|
||||||
|
reportNameEng: "Production Work Order",
|
||||||
|
reportType: "작업지시서",
|
||||||
|
description: "작업지시/제품 정보, 자재 소요 내역, 작업 공정, 바코드/QR 포함",
|
||||||
|
templateType: "WORK_ORDER",
|
||||||
|
author: AUTHORS[1], date: DATES[11],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reportNameKor: "조립 작업지시서",
|
||||||
|
reportNameEng: "Assembly Work Order",
|
||||||
|
reportType: "작업지시서",
|
||||||
|
description: "조립 라인 전용 작업지시서 (공정순서/부품목록 포함)",
|
||||||
|
templateType: "WORK_ORDER",
|
||||||
|
author: AUTHORS[0], date: DATES[6],
|
||||||
|
},
|
||||||
|
// 검사 성적서 x2
|
||||||
|
{
|
||||||
|
reportNameKor: "품질 검사 성적서",
|
||||||
|
reportNameEng: "Quality Inspection Report",
|
||||||
|
reportType: "검사성적서",
|
||||||
|
description: "검사/제품 정보, 검사항목 테이블, 종합 판정(합격/불합격), 서명란 포함",
|
||||||
|
templateType: "INSPECTION_REPORT",
|
||||||
|
author: AUTHORS[0], date: DATES[9],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reportNameKor: "수입검사 성적서",
|
||||||
|
reportNameEng: "Incoming Inspection Report",
|
||||||
|
reportType: "검사성적서",
|
||||||
|
description: "수입 자재/부품 품질 검사 성적서 양식",
|
||||||
|
templateType: "INSPECTION_REPORT",
|
||||||
|
author: AUTHORS[2], date: DATES[1],
|
||||||
|
},
|
||||||
|
// 세금계산서 x2
|
||||||
|
{
|
||||||
|
reportNameKor: "전자 세금계산서",
|
||||||
|
reportNameEng: "Electronic Tax Invoice",
|
||||||
|
reportType: "세금계산서",
|
||||||
|
description: "승인번호/작성일자, 공급자/공급받는자 그리드, 품목 테이블, 결제방법 포함",
|
||||||
|
templateType: "TAX_INVOICE",
|
||||||
|
author: AUTHORS[1], date: DATES[8],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reportNameKor: "수정 세금계산서",
|
||||||
|
reportNameEng: "Amended Tax Invoice",
|
||||||
|
reportType: "세금계산서",
|
||||||
|
description: "기존 발행 세금계산서의 수정 발행용 양식",
|
||||||
|
templateType: "TAX_INVOICE",
|
||||||
|
author: AUTHORS[0], date: DATES[2],
|
||||||
|
},
|
||||||
|
// 생산계획 현황표 x2
|
||||||
|
{
|
||||||
|
reportNameKor: "월간 생산계획 현황표",
|
||||||
|
reportNameEng: "Monthly Production Plan Status",
|
||||||
|
reportType: "생산현황",
|
||||||
|
description: "계획/생산수량 요약 카드, 생산계획 테이블, 상태 범례, 비고 포함",
|
||||||
|
templateType: "PRODUCTION_PLAN",
|
||||||
|
author: AUTHORS[0], date: DATES[4],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reportNameKor: "주간 생산실적 현황표",
|
||||||
|
reportNameEng: "Weekly Production Performance",
|
||||||
|
reportType: "생산현황",
|
||||||
|
description: "주간 단위 생산실적 집계 및 달성률 현황표",
|
||||||
|
templateType: "PRODUCTION_PLAN",
|
||||||
|
author: AUTHORS[2], date: DATES[0],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 메인 실행 ─────────────────────────────────────────────────────────────────
|
||||||
|
async function main() {
|
||||||
|
console.log("로그인 중...");
|
||||||
|
let token;
|
||||||
|
try {
|
||||||
|
token = await getToken();
|
||||||
|
console.log("로그인 성공\n");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("로그인 실패:", e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 기존 리포트 모두 삭제
|
||||||
|
console.log("기존 리포트 삭제 중...");
|
||||||
|
let allReports = [];
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const resp = await apiRequest("GET", `/api/admin/reports?page=${page}&limit=50`, token);
|
||||||
|
const items = resp.data?.items || [];
|
||||||
|
if (items.length === 0) break;
|
||||||
|
allReports = allReports.concat(items);
|
||||||
|
if (allReports.length >= (resp.data?.total || 0)) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
console.log(` ${allReports.length}건 발견`);
|
||||||
|
for (const rpt of allReports) {
|
||||||
|
await apiRequest("DELETE", `/api/admin/reports/${rpt.report_id}`, token);
|
||||||
|
console.log(` 삭제: ${rpt.report_name_kor}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 템플릿 매핑
|
||||||
|
console.log("\n템플릿 조회 중...");
|
||||||
|
const tplResp = await apiRequest("GET", "/api/admin/reports/templates", token);
|
||||||
|
const allTpls = [...(tplResp.data?.system || []), ...(tplResp.data?.custom || [])];
|
||||||
|
const tplMap = {};
|
||||||
|
for (const t of allTpls) {
|
||||||
|
if (!tplMap[t.template_type]) tplMap[t.template_type] = t;
|
||||||
|
}
|
||||||
|
console.log(` ${allTpls.length}건 발견\n`);
|
||||||
|
|
||||||
|
// 3. 16건 리포트 생성
|
||||||
|
console.log("예시 리포트 16건 생성 시작...");
|
||||||
|
const createdIds = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < SAMPLE_REPORTS.length; i++) {
|
||||||
|
const s = SAMPLE_REPORTS[i];
|
||||||
|
const tpl = tplMap[s.templateType];
|
||||||
|
|
||||||
|
const reportData = {
|
||||||
|
reportNameKor: s.reportNameKor,
|
||||||
|
reportNameEng: s.reportNameEng,
|
||||||
|
reportType: s.reportType,
|
||||||
|
description: s.description,
|
||||||
|
templateId: tpl?.template_id || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiRequest("POST", "/api/admin/reports", token, reportData);
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(` [${i + 1}] 실패: ${s.reportNameKor} - ${result.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportId = result.data?.reportId;
|
||||||
|
createdIds.push({ reportId, author: s.author, date: s.date, name: s.reportNameKor });
|
||||||
|
|
||||||
|
// 레이아웃 저장
|
||||||
|
if (tpl) {
|
||||||
|
const raw = typeof tpl.layout_config === "string"
|
||||||
|
? JSON.parse(tpl.layout_config) : tpl.layout_config || {};
|
||||||
|
const components = raw.components || [];
|
||||||
|
const ps = raw.pageSettings || {};
|
||||||
|
|
||||||
|
await apiRequest("PUT", `/api/admin/reports/${reportId}/layout`, token, {
|
||||||
|
layoutConfig: {
|
||||||
|
pages: [{
|
||||||
|
page_id: `pg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
page_name: "페이지 1", page_order: 0,
|
||||||
|
width: ps.width || 210, height: ps.height || 297,
|
||||||
|
orientation: ps.orientation || "portrait",
|
||||||
|
margins: ps.margins || { top: 10, bottom: 10, left: 10, right: 10 },
|
||||||
|
background_color: "#ffffff",
|
||||||
|
components,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
queries: [], menuObjids: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` [${i + 1}] ${s.reportNameKor} (${s.reportType}) - ${s.author} / ${s.date}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. DB 직접 업데이트 (작성자 / 작성일 변경)
|
||||||
|
console.log("\n작성자/작성일 DB 업데이트 중...");
|
||||||
|
for (const item of createdIds) {
|
||||||
|
await apiRequest("PUT", `/api/admin/reports/${item.reportId}`, token, {
|
||||||
|
// 이 API는 updateReport 이므로 직접 필드 업데이트 가능한지 확인 필요
|
||||||
|
// 그렇지 않으면 별도 SQL 필요
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateReport API로 created_by/created_at 을 변경할 수 없으므로
|
||||||
|
// 직접 DB 업데이트 스크립트를 별도 실행
|
||||||
|
console.log("\nDB 업데이트 SQL 생성...");
|
||||||
|
const sqlStatements = createdIds.map((item) => {
|
||||||
|
return `UPDATE report_master SET created_by = '${item.author}', created_at = '${item.date} 09:00:00+09' WHERE report_id = '${item.reportId}';`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// DB 직접 접근으로 업데이트
|
||||||
|
try {
|
||||||
|
const { Pool } = require("pg");
|
||||||
|
require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") });
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of createdIds) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE report_master SET created_by = $1, created_at = $2 WHERE report_id = $3`,
|
||||||
|
[item.author, `${item.date} 09:00:00+09`, item.reportId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
|
console.log(` ${createdIds.length}건 업데이트 완료`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(" DB 직접 연결 실패, SQL을 수동으로 실행하세요:");
|
||||||
|
sqlStatements.forEach((sql) => console.log(" " + sql));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n완료! ${createdIds.length}건 생성`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
/**
|
import { Response, NextFunction } from "express";
|
||||||
* 리포트 관리 컨트롤러
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import reportService from "../services/reportService";
|
import reportService from "../services/reportService";
|
||||||
import {
|
import {
|
||||||
CreateReportRequest,
|
CreateReportRequest,
|
||||||
UpdateReportRequest,
|
UpdateReportRequest,
|
||||||
SaveLayoutRequest,
|
SaveLayoutRequest,
|
||||||
CreateTemplateRequest,
|
CreateTemplateRequest,
|
||||||
|
GetReportsParams,
|
||||||
} from "../types/report";
|
} from "../types/report";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,92 +34,91 @@ import {
|
||||||
import { WatermarkConfig } from "../types/report";
|
import { WatermarkConfig } from "../types/report";
|
||||||
import bwipjs from "bwip-js";
|
import bwipjs from "bwip-js";
|
||||||
|
|
||||||
|
function getUserInfo(req: AuthenticatedRequest) {
|
||||||
|
return {
|
||||||
|
userId: req.user?.userId || "SYSTEM",
|
||||||
|
companyCode: req.user?.companyCode || "*",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class ReportController {
|
export class ReportController {
|
||||||
/**
|
async getReports(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 목록 조회
|
|
||||||
* GET /api/admin/reports
|
|
||||||
*/
|
|
||||||
async getReports(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { companyCode } = getUserInfo(req);
|
||||||
const {
|
const {
|
||||||
page = "1",
|
page = "1", limit = "20", searchText = "", searchField,
|
||||||
limit = "20",
|
startDate, endDate, reportType = "", useYn = "Y",
|
||||||
searchText = "",
|
sortBy = "created_at", sortOrder = "DESC",
|
||||||
reportType = "",
|
|
||||||
useYn = "Y",
|
|
||||||
sortBy = "created_at",
|
|
||||||
sortOrder = "DESC",
|
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
const result = await reportService.getReports({
|
const result = await reportService.getReports({
|
||||||
page: parseInt(page as string, 10),
|
page: parseInt(page as string, 10),
|
||||||
limit: parseInt(limit as string, 10),
|
limit: parseInt(limit as string, 10),
|
||||||
searchText: searchText as string,
|
searchText: searchText as string,
|
||||||
|
searchField: searchField as GetReportsParams["searchField"],
|
||||||
|
startDate: startDate as string | undefined,
|
||||||
|
endDate: endDate as string | undefined,
|
||||||
reportType: reportType as string,
|
reportType: reportType as string,
|
||||||
useYn: useYn as string,
|
useYn: useYn as string,
|
||||||
sortBy: sortBy as string,
|
sortBy: sortBy as string,
|
||||||
sortOrder: sortOrder as "ASC" | "DESC",
|
sortOrder: sortOrder as "ASC" | "DESC",
|
||||||
});
|
}, companyCode);
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, data: result });
|
||||||
success: true,
|
|
||||||
data: result,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getReportById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 상세 조회
|
|
||||||
* GET /api/admin/reports/:reportId
|
|
||||||
*/
|
|
||||||
async getReportById(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
|
|
||||||
const report = await reportService.getReportById(reportId);
|
const report = await reportService.getReportById(reportId, companyCode);
|
||||||
|
|
||||||
if (!report) {
|
if (!report) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||||
success: false,
|
|
||||||
message: "리포트를 찾을 수 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, data: report });
|
||||||
success: true,
|
|
||||||
data: report,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getReportsByMenuObjid(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 생성
|
|
||||||
* POST /api/admin/reports
|
|
||||||
*/
|
|
||||||
async createReport(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const data: CreateReportRequest = req.body;
|
const { companyCode } = getUserInfo(req);
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
const { menuObjid } = req.params;
|
||||||
|
const menuObjidNum = parseInt(menuObjid, 10);
|
||||||
|
|
||||||
// 필수 필드 검증
|
if (isNaN(menuObjidNum)) {
|
||||||
if (!data.reportNameKor || !data.reportType) {
|
return res.status(400).json({ success: false, message: "menuObjid는 숫자여야 합니다." });
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "리포트명과 리포트 타입은 필수입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await reportService.getReportsByMenuObjid(menuObjidNum, companyCode);
|
||||||
|
return res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { userId, companyCode } = getUserInfo(req);
|
||||||
|
const data: CreateReportRequest = req.body;
|
||||||
|
|
||||||
|
if (!data.reportNameKor || !data.reportType) {
|
||||||
|
return res.status(400).json({ success: false, message: "리포트명과 리포트 타입은 필수입니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
data.companyCode = companyCode;
|
||||||
const reportId = await reportService.createReport(data, userId);
|
const reportId = await reportService.createReport(data, userId);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { reportId },
|
||||||
reportId,
|
|
||||||
},
|
|
||||||
message: "리포트가 생성되었습니다.",
|
message: "리포트가 생성되었습니다.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -128,83 +126,56 @@ export class ReportController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async updateReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 수정
|
|
||||||
* PUT /api/admin/reports/:reportId
|
|
||||||
*/
|
|
||||||
async updateReport(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { userId, companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
const data: UpdateReportRequest = req.body;
|
const data: UpdateReportRequest = req.body;
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
|
||||||
|
|
||||||
const success = await reportService.updateReport(reportId, data, userId);
|
const success = await reportService.updateReport(reportId, data, userId, companyCode);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "수정할 내용이 없습니다." });
|
||||||
success: false,
|
|
||||||
message: "수정할 내용이 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, message: "리포트가 수정되었습니다." });
|
||||||
success: true,
|
|
||||||
message: "리포트가 수정되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async deleteReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 삭제
|
|
||||||
* DELETE /api/admin/reports/:reportId
|
|
||||||
*/
|
|
||||||
async deleteReport(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
|
|
||||||
const success = await reportService.deleteReport(reportId);
|
const success = await reportService.deleteReport(reportId, companyCode);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||||
success: false,
|
|
||||||
message: "리포트를 찾을 수 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, message: "리포트가 삭제되었습니다." });
|
||||||
success: true,
|
|
||||||
message: "리포트가 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async copyReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 복사
|
|
||||||
* POST /api/admin/reports/:reportId/copy
|
|
||||||
*/
|
|
||||||
async copyReport(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { userId, companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
const { newName } = req.body;
|
||||||
|
|
||||||
const newReportId = await reportService.copyReport(reportId, userId);
|
const newReportId = await reportService.copyReport(reportId, userId, companyCode, newName);
|
||||||
|
|
||||||
if (!newReportId) {
|
if (!newReportId) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||||
success: false,
|
|
||||||
message: "리포트를 찾을 수 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { reportId: newReportId },
|
||||||
reportId: newReportId,
|
|
||||||
},
|
|
||||||
message: "리포트가 복사되었습니다.",
|
message: "리포트가 복사되었습니다.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -212,132 +183,92 @@ export class ReportController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 레이아웃 조회
|
|
||||||
* GET /api/admin/reports/:reportId/layout
|
|
||||||
*/
|
|
||||||
async getLayout(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
|
|
||||||
const layout = await reportService.getLayout(reportId);
|
const layout = await reportService.getLayout(reportId, companyCode);
|
||||||
|
|
||||||
if (!layout) {
|
if (!layout) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, message: "레이아웃을 찾을 수 없습니다." });
|
||||||
success: false,
|
|
||||||
message: "레이아웃을 찾을 수 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// components 컬럼에서 JSON 파싱
|
const storedData = layout.components;
|
||||||
const parsedComponents = layout.components
|
|
||||||
? JSON.parse(layout.components)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
let layoutData;
|
let layoutData;
|
||||||
// 새 구조 (layoutConfig.pages)인지 확인
|
|
||||||
if (
|
if (
|
||||||
parsedComponents &&
|
storedData &&
|
||||||
parsedComponents.pages &&
|
typeof storedData === "object" &&
|
||||||
Array.isArray(parsedComponents.pages)
|
!Array.isArray(storedData) &&
|
||||||
|
Array.isArray((storedData as Record<string, unknown>).pages)
|
||||||
) {
|
) {
|
||||||
// pages 배열을 직접 포함하여 반환
|
const parsed = storedData as Record<string, unknown>;
|
||||||
layoutData = {
|
layoutData = {
|
||||||
...layout,
|
...layout,
|
||||||
pages: parsedComponents.pages,
|
pages: parsed.pages,
|
||||||
components: [], // 호환성을 위해 빈 배열
|
watermark: parsed.watermark,
|
||||||
|
components: storedData,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 기존 구조: components 배열
|
layoutData = { ...layout, components: storedData || [] };
|
||||||
layoutData = {
|
|
||||||
...layout,
|
|
||||||
components: parsedComponents || [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, data: layoutData });
|
||||||
success: true,
|
|
||||||
data: layoutData,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async saveLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 레이아웃 저장
|
|
||||||
* PUT /api/admin/reports/:reportId/layout
|
|
||||||
*/
|
|
||||||
async saveLayout(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { userId, companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
const data: SaveLayoutRequest = req.body;
|
const data: SaveLayoutRequest = req.body;
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
|
||||||
|
|
||||||
// 필수 필드 검증 (페이지 기반 구조)
|
if (!data.layoutConfig?.pages?.length) {
|
||||||
if (
|
return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다." });
|
||||||
!data.layoutConfig ||
|
|
||||||
!data.layoutConfig.pages ||
|
|
||||||
data.layoutConfig.pages.length === 0
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "레이아웃 설정이 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await reportService.saveLayout(reportId, data, userId);
|
await reportService.saveLayout(reportId, data, userId, companyCode);
|
||||||
|
return res.json({ success: true, message: "레이아웃이 저장되었습니다." });
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: "레이아웃이 저장되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getTemplates(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 템플릿 목록 조회
|
|
||||||
* GET /api/admin/reports/templates
|
|
||||||
*/
|
|
||||||
async getTemplates(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const templates = await reportService.getTemplates();
|
const templates = await reportService.getTemplates();
|
||||||
|
return res.json({ success: true, data: templates });
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: templates,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 템플릿 생성
|
|
||||||
* POST /api/admin/reports/templates
|
|
||||||
*/
|
|
||||||
async createTemplate(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const data: CreateTemplateRequest = req.body;
|
const categories = await reportService.getCategories();
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
return res.json({ success: true, data: categories });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { userId } = getUserInfo(req);
|
||||||
|
const data: CreateTemplateRequest = req.body;
|
||||||
|
|
||||||
// 필수 필드 검증
|
|
||||||
if (!data.templateNameKor || !data.templateType) {
|
if (!data.templateNameKor || !data.templateType) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "템플릿명과 템플릿 타입은 필수입니다." });
|
||||||
success: false,
|
|
||||||
message: "템플릿명과 템플릿 타입은 필수입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateId = await reportService.createTemplate(data, userId);
|
const templateId = await reportService.createTemplate(data, userId);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { templateId },
|
||||||
templateId,
|
|
||||||
},
|
|
||||||
message: "템플릿이 생성되었습니다.",
|
message: "템플릿이 생성되었습니다.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -345,37 +276,23 @@ export class ReportController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async saveAsTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 현재 리포트를 템플릿으로 저장
|
|
||||||
* POST /api/admin/reports/:reportId/save-as-template
|
|
||||||
*/
|
|
||||||
async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { userId } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
const { templateNameKor, templateNameEng, description } = req.body;
|
const { templateNameKor, templateNameEng, description } = req.body;
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
|
||||||
|
|
||||||
// 필수 필드 검증
|
|
||||||
if (!templateNameKor) {
|
if (!templateNameKor) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||||
success: false,
|
|
||||||
message: "템플릿명은 필수입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateId = await reportService.saveAsTemplate(
|
const templateId = await reportService.saveAsTemplate(
|
||||||
reportId,
|
reportId, templateNameKor, templateNameEng, description, userId
|
||||||
templateNameKor,
|
|
||||||
templateNameEng,
|
|
||||||
description,
|
|
||||||
userId
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { templateId },
|
||||||
templateId,
|
|
||||||
},
|
|
||||||
message: "템플릿이 저장되었습니다.",
|
message: "템플릿이 저장되었습니다.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -383,39 +300,20 @@ export class ReportController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async createTemplateFromLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
|
||||||
* POST /api/admin/reports/templates/create-from-layout
|
|
||||||
*/
|
|
||||||
async createTemplateFromLayout(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
|
const { userId } = getUserInfo(req);
|
||||||
const {
|
const {
|
||||||
templateNameKor,
|
templateNameKor, templateNameEng, templateType,
|
||||||
templateNameEng,
|
description, layoutConfig, defaultQueries = [],
|
||||||
templateType,
|
|
||||||
description,
|
|
||||||
layoutConfig,
|
|
||||||
defaultQueries = [],
|
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
|
||||||
|
|
||||||
// 필수 필드 검증
|
|
||||||
if (!templateNameKor) {
|
if (!templateNameKor) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||||
success: false,
|
|
||||||
message: "템플릿명은 필수입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!layoutConfig) {
|
if (!layoutConfig) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "레이아웃 설정은 필수입니다." });
|
||||||
success: false,
|
|
||||||
message: "레이아웃 설정은 필수입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateId = await reportService.createTemplateFromLayout(
|
const templateId = await reportService.createTemplateFromLayout(
|
||||||
|
|
@ -440,78 +338,47 @@ export class ReportController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async deleteTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 템플릿 삭제
|
|
||||||
* DELETE /api/admin/reports/templates/:templateId
|
|
||||||
*/
|
|
||||||
async deleteTemplate(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const { templateId } = req.params;
|
const { templateId } = req.params;
|
||||||
|
|
||||||
const success = await reportService.deleteTemplate(templateId);
|
const success = await reportService.deleteTemplate(templateId);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다." });
|
||||||
success: false,
|
|
||||||
message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, message: "템플릿이 삭제되었습니다." });
|
||||||
success: true,
|
|
||||||
message: "템플릿이 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async executeQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 쿼리 실행
|
|
||||||
* POST /api/admin/reports/:reportId/queries/:queryId/execute
|
|
||||||
*/
|
|
||||||
async executeQuery(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const { reportId, queryId } = req.params;
|
const { reportId, queryId } = req.params;
|
||||||
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
|
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
|
||||||
|
|
||||||
const result = await reportService.executeQuery(
|
const result = await reportService.executeQuery(
|
||||||
reportId,
|
reportId, queryId, parameters, sqlQuery, externalConnectionId
|
||||||
queryId,
|
|
||||||
parameters,
|
|
||||||
sqlQuery,
|
|
||||||
externalConnectionId
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, data: result });
|
||||||
success: true,
|
} catch (error: unknown) {
|
||||||
data: result,
|
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
|
||||||
});
|
return res.status(400).json({ success: false, message });
|
||||||
} catch (error: any) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: error.message || "쿼리 실행에 실패했습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getExternalConnections(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 외부 DB 연결 목록 조회 (활성화된 것만)
|
|
||||||
* GET /api/admin/reports/external-connections
|
|
||||||
*/
|
|
||||||
async getExternalConnections(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
|
const { companyCode } = getUserInfo(req);
|
||||||
const { ExternalDbConnectionService } = await import(
|
const { ExternalDbConnectionService } = await import(
|
||||||
"../services/externalDbConnectionService"
|
"../services/externalDbConnectionService"
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await ExternalDbConnectionService.getConnections({
|
const result = await ExternalDbConnectionService.getConnections({
|
||||||
is_active: "Y",
|
is_active: "Y",
|
||||||
company_code: req.body.companyCode || "",
|
company_code: companyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
|
|
@ -520,52 +387,34 @@ export class ReportController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async uploadImage(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 이미지 파일 업로드
|
|
||||||
* POST /api/admin/reports/upload-image
|
|
||||||
*/
|
|
||||||
async uploadImage(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "이미지 파일이 필요합니다." });
|
||||||
success: false,
|
|
||||||
message: "이미지 파일이 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyCode = req.body.companyCode || "SYSTEM";
|
const { companyCode } = getUserInfo(req);
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
|
|
||||||
// 파일 저장 경로 생성
|
const uploadDir = path.join(process.cwd(), "uploads", `company_${companyCode}`, "reports");
|
||||||
const uploadDir = path.join(
|
|
||||||
process.cwd(),
|
|
||||||
"uploads",
|
|
||||||
`company_${companyCode}`,
|
|
||||||
"reports"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 디렉토리가 없으면 생성
|
|
||||||
if (!fs.existsSync(uploadDir)) {
|
if (!fs.existsSync(uploadDir)) {
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 고유한 파일명 생성 (타임스탬프 + 원본 파일명)
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
|
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
const fileName = `${timestamp}_${safeFileName}`;
|
const fileName = `${timestamp}_${safeFileName}`;
|
||||||
const filePath = path.join(uploadDir, fileName);
|
const filePath = path.join(uploadDir, fileName);
|
||||||
|
|
||||||
// 파일 저장
|
|
||||||
fs.writeFileSync(filePath, file.buffer);
|
fs.writeFileSync(filePath, file.buffer);
|
||||||
|
|
||||||
// 웹에서 접근 가능한 URL 반환
|
|
||||||
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
|
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
fileName,
|
fileName, fileUrl,
|
||||||
fileUrl,
|
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
|
|
@ -576,11 +425,7 @@ export class ReportController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async exportToWord(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 컴포넌트 데이터를 WORD(DOCX)로 변환
|
|
||||||
* POST /api/admin/reports/export-word
|
|
||||||
*/
|
|
||||||
async exportToWord(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const { layoutConfig, queryResults, fileName = "리포트" } = req.body;
|
const { layoutConfig, queryResults, fileName = "리포트" } = req.body;
|
||||||
|
|
||||||
|
|
@ -591,22 +436,15 @@ export class ReportController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// mm를 twip으로 변환
|
|
||||||
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
|
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
|
||||||
|
const MM_TO_PX = 4; // 프론트엔드와 동일, 1mm = 56.692913386 twip (docx)
|
||||||
// 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값)
|
|
||||||
const MM_TO_PX = 4;
|
|
||||||
// 1mm = 56.692913386 twip (docx 라이브러리 기준)
|
|
||||||
// px를 twip으로 변환: px -> mm -> twip
|
|
||||||
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
|
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
|
||||||
|
|
||||||
// 쿼리 결과 맵
|
|
||||||
const queryResultsMap: Record<
|
const queryResultsMap: Record<
|
||||||
string,
|
string,
|
||||||
{ fields: string[]; rows: Record<string, unknown>[] }
|
{ fields: string[]; rows: Record<string, unknown>[] }
|
||||||
> = queryResults || {};
|
> = queryResults || {};
|
||||||
|
|
||||||
// 컴포넌트 값 가져오기
|
|
||||||
const getComponentValue = (component: any): string => {
|
const getComponentValue = (component: any): string => {
|
||||||
if (component.queryId && component.fieldName) {
|
if (component.queryId && component.fieldName) {
|
||||||
const queryResult = queryResultsMap[component.queryId];
|
const queryResult = queryResultsMap[component.queryId];
|
||||||
|
|
@ -621,11 +459,9 @@ export class ReportController {
|
||||||
return component.defaultValue || "";
|
return component.defaultValue || "";
|
||||||
};
|
};
|
||||||
|
|
||||||
// px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용)
|
// px → half-point (1px = 0.75pt, px * 1.5)
|
||||||
// px * 0.75 * 2 = px * 1.5
|
|
||||||
const pxToHalfPt = (px: number) => Math.round(px * 1.5);
|
const pxToHalfPt = (px: number) => Math.round(px * 1.5);
|
||||||
|
|
||||||
// 셀 내용 생성 헬퍼 함수 (가로 배치용)
|
|
||||||
const createCellContent = (
|
const createCellContent = (
|
||||||
component: any,
|
component: any,
|
||||||
displayValue: string,
|
displayValue: string,
|
||||||
|
|
@ -1557,7 +1393,7 @@ export class ReportController {
|
||||||
const base64 = png.toString("base64");
|
const base64 = png.toString("base64");
|
||||||
return `data:image/png;base64,${base64}`;
|
return `data:image/png;base64,${base64}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("바코드 생성 오류:", error);
|
logger.error("바코드 생성 오류:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1891,7 +1727,7 @@ export class ReportController {
|
||||||
children.push(paragraph);
|
children.push(paragraph);
|
||||||
lastBottomY = adjustedY + component.height;
|
lastBottomY = adjustedY + component.height;
|
||||||
} catch (imgError) {
|
} catch (imgError) {
|
||||||
console.error("이미지 처리 오류:", imgError);
|
logger.error("이미지 처리 오류:", imgError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2005,7 +1841,7 @@ export class ReportController {
|
||||||
});
|
});
|
||||||
children.push(paragraph);
|
children.push(paragraph);
|
||||||
} catch (imgError) {
|
} catch (imgError) {
|
||||||
console.error("서명 이미지 오류:", imgError);
|
logger.error("서명 이미지 오류:", imgError);
|
||||||
textRuns.push(
|
textRuns.push(
|
||||||
new TextRun({
|
new TextRun({
|
||||||
text: "_".repeat(20),
|
text: "_".repeat(20),
|
||||||
|
|
@ -2083,7 +1919,7 @@ export class ReportController {
|
||||||
});
|
});
|
||||||
children.push(paragraph);
|
children.push(paragraph);
|
||||||
} catch (imgError) {
|
} catch (imgError) {
|
||||||
console.error("도장 이미지 오류:", imgError);
|
logger.error("도장 이미지 오류:", imgError);
|
||||||
textRuns.push(
|
textRuns.push(
|
||||||
new TextRun({
|
new TextRun({
|
||||||
text: "(인)",
|
text: "(인)",
|
||||||
|
|
@ -2886,7 +2722,7 @@ export class ReportController {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (imgError) {
|
} catch (imgError) {
|
||||||
console.error("바코드 이미지 오류:", imgError);
|
logger.error("바코드 이미지 오류:", imgError);
|
||||||
// 바코드 이미지 생성 실패 시 텍스트로 대체
|
// 바코드 이미지 생성 실패 시 텍스트로 대체
|
||||||
const barcodeValue = component.barcodeValue || "BARCODE";
|
const barcodeValue = component.barcodeValue || "BARCODE";
|
||||||
children.push(
|
children.push(
|
||||||
|
|
@ -3164,13 +3000,57 @@ export class ReportController {
|
||||||
|
|
||||||
return res.send(docxBuffer);
|
return res.send(docxBuffer);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("WORD 변환 오류:", error);
|
logger.error("WORD 변환 오류:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message || "WORD 변환에 실패했습니다.",
|
message: error.message || "WORD 변환에 실패했습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 비주얼 쿼리 빌더 API ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getSchemaTables(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tables = await reportService.getSchemaTables();
|
||||||
|
return res.json({ success: true, data: tables });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "테이블 목록 조회에 실패했습니다.";
|
||||||
|
logger.error("스키마 테이블 조회 오류:", { error: message });
|
||||||
|
return res.status(500).json({ success: false, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSchemaTableColumns(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
if (!tableName) {
|
||||||
|
return res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
|
||||||
|
}
|
||||||
|
const columns = await reportService.getSchemaTableColumns(tableName);
|
||||||
|
return res.json({ success: true, data: columns });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "컬럼 목록 조회에 실패했습니다.";
|
||||||
|
logger.error("테이블 컬럼 조회 오류:", { error: message });
|
||||||
|
return res.status(500).json({ success: false, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewVisualQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { visualQuery } = req.body;
|
||||||
|
if (!visualQuery || !visualQuery.tableName) {
|
||||||
|
return res.status(400).json({ success: false, message: "visualQuery 정보가 필요합니다." });
|
||||||
|
}
|
||||||
|
const result = await reportService.executeVisualQuery(visualQuery);
|
||||||
|
const generatedSql = reportService.buildVisualQuerySql(visualQuery);
|
||||||
|
return res.json({ success: true, data: { ...result, sql: generatedSql } });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
|
||||||
|
logger.error("비주얼 쿼리 미리보기 오류:", { error: message });
|
||||||
|
return res.status(500).json({ success: false, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ReportController();
|
export default new ReportController();
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ router.get("/templates", (req, res, next) =>
|
||||||
router.post("/templates", (req, res, next) =>
|
router.post("/templates", (req, res, next) =>
|
||||||
reportController.createTemplate(req, res, next)
|
reportController.createTemplate(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 카테고리(report_type) 목록 조회
|
||||||
|
router.get("/categories", (req, res, next) =>
|
||||||
|
reportController.getCategories(req, res, next)
|
||||||
|
);
|
||||||
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
||||||
router.post("/templates/create-from-layout", (req, res, next) =>
|
router.post("/templates/create-from-layout", (req, res, next) =>
|
||||||
reportController.createTemplateFromLayout(req, res, next)
|
reportController.createTemplateFromLayout(req, res, next)
|
||||||
|
|
@ -61,6 +66,17 @@ router.post("/export-word", (req, res, next) =>
|
||||||
reportController.exportToWord(req, res, next)
|
reportController.exportToWord(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 비주얼 쿼리 빌더 — 스키마 조회 (/:reportId 패턴보다 반드시 먼저 등록)
|
||||||
|
router.get("/schema/tables", (req, res, next) =>
|
||||||
|
reportController.getSchemaTables(req, res, next)
|
||||||
|
);
|
||||||
|
router.get("/schema/tables/:tableName/columns", (req, res, next) =>
|
||||||
|
reportController.getSchemaTableColumns(req, res, next)
|
||||||
|
);
|
||||||
|
router.post("/schema/preview", (req, res, next) =>
|
||||||
|
reportController.previewVisualQuery(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
// 리포트 목록
|
// 리포트 목록
|
||||||
router.get("/", (req, res, next) =>
|
router.get("/", (req, res, next) =>
|
||||||
reportController.getReports(req, res, next)
|
reportController.getReports(req, res, next)
|
||||||
|
|
@ -71,6 +87,11 @@ router.post("/", (req, res, next) =>
|
||||||
reportController.createReport(req, res, next)
|
reportController.createReport(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 메뉴별 리포트 목록 (/:reportId 보다 반드시 먼저 등록)
|
||||||
|
router.get("/by-menu/:menuObjid", (req, res, next) =>
|
||||||
|
reportController.getReportsByMenuObjid(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
// 리포트 복사 (구체적인 경로를 먼저 배치)
|
// 리포트 복사 (구체적인 경로를 먼저 배치)
|
||||||
router.post("/:reportId/copy", (req, res, next) =>
|
router.post("/:reportId/copy", (req, res, next) =>
|
||||||
reportController.copyReport(req, res, next)
|
reportController.copyReport(req, res, next)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,8 +1,3 @@
|
||||||
/**
|
|
||||||
* 리포트 관리 시스템 타입 정의
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 리포트 템플릿
|
|
||||||
export interface ReportTemplate {
|
export interface ReportTemplate {
|
||||||
template_id: string;
|
template_id: string;
|
||||||
template_name_kor: string;
|
template_name_kor: string;
|
||||||
|
|
@ -21,12 +16,12 @@ export interface ReportTemplate {
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 마스터
|
|
||||||
export interface ReportMaster {
|
export interface ReportMaster {
|
||||||
report_id: string;
|
report_id: string;
|
||||||
report_name_kor: string;
|
report_name_kor: string;
|
||||||
report_name_eng: string | null;
|
report_name_eng: string | null;
|
||||||
template_id: string | null;
|
template_id: string | null;
|
||||||
|
template_name: string | null;
|
||||||
report_type: string;
|
report_type: string;
|
||||||
company_code: string | null;
|
company_code: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|
@ -37,7 +32,6 @@ export interface ReportMaster {
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 레이아웃
|
|
||||||
export interface ReportLayout {
|
export interface ReportLayout {
|
||||||
layout_id: string;
|
layout_id: string;
|
||||||
report_id: string;
|
report_id: string;
|
||||||
|
|
@ -55,7 +49,6 @@ export interface ReportLayout {
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 쿼리
|
|
||||||
export interface ReportQuery {
|
export interface ReportQuery {
|
||||||
query_id: string;
|
query_id: string;
|
||||||
report_id: string;
|
report_id: string;
|
||||||
|
|
@ -63,7 +56,7 @@ export interface ReportQuery {
|
||||||
query_type: "MASTER" | "DETAIL";
|
query_type: "MASTER" | "DETAIL";
|
||||||
sql_query: string;
|
sql_query: string;
|
||||||
parameters: string[] | null;
|
parameters: string[] | null;
|
||||||
external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB)
|
external_connection_id: number | null;
|
||||||
display_order: number;
|
display_order: number;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
|
|
@ -71,34 +64,37 @@ export interface ReportQuery {
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴)
|
|
||||||
export interface ReportDetail {
|
export interface ReportDetail {
|
||||||
report: ReportMaster;
|
report: ReportMaster;
|
||||||
layout: ReportLayout | null;
|
layout: ReportLayout | null;
|
||||||
queries: ReportQuery[];
|
queries: ReportQuery[];
|
||||||
menuObjids?: number[]; // 연결된 메뉴 ID 목록
|
menuObjids?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 목록 조회 파라미터
|
|
||||||
export interface GetReportsParams {
|
export interface GetReportsParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
searchText?: string;
|
searchText?: string;
|
||||||
|
searchField?: "report_name" | "created_by" | "report_type" | "updated_at" | "created_at";
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
reportType?: string;
|
reportType?: string;
|
||||||
useYn?: string;
|
useYn?: string;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: "ASC" | "DESC";
|
sortOrder?: "ASC" | "DESC";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 목록 응답
|
|
||||||
export interface GetReportsResponse {
|
export interface GetReportsResponse {
|
||||||
items: ReportMaster[];
|
items: ReportMaster[];
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
typeSummary: Array<{ type: string; count: number }>;
|
||||||
|
allTypes: string[];
|
||||||
|
recentActivity: Array<{ date: string; count: number }>;
|
||||||
|
recentTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 생성 요청
|
|
||||||
export interface CreateReportRequest {
|
export interface CreateReportRequest {
|
||||||
reportNameKor: string;
|
reportNameKor: string;
|
||||||
reportNameEng?: string;
|
reportNameEng?: string;
|
||||||
|
|
@ -108,7 +104,6 @@ export interface CreateReportRequest {
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 수정 요청
|
|
||||||
export interface UpdateReportRequest {
|
export interface UpdateReportRequest {
|
||||||
reportNameKor?: string;
|
reportNameKor?: string;
|
||||||
reportNameEng?: string;
|
reportNameEng?: string;
|
||||||
|
|
@ -117,23 +112,18 @@ export interface UpdateReportRequest {
|
||||||
useYn?: string;
|
useYn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 워터마크 설정
|
|
||||||
export interface WatermarkConfig {
|
export interface WatermarkConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
type: "text" | "image";
|
type: "text" | "image";
|
||||||
// 텍스트 워터마크
|
|
||||||
text?: string;
|
text?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontColor?: string;
|
fontColor?: string;
|
||||||
// 이미지 워터마크
|
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
// 공통 설정
|
opacity: number;
|
||||||
opacity: number; // 0~1
|
|
||||||
style: "diagonal" | "center" | "tile";
|
style: "diagonal" | "center" | "tile";
|
||||||
rotation?: number; // 대각선일 때 각도 (기본 -45)
|
rotation?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 페이지 설정
|
|
||||||
export interface PageConfig {
|
export interface PageConfig {
|
||||||
page_id: string;
|
page_id: string;
|
||||||
page_name: string;
|
page_name: string;
|
||||||
|
|
@ -147,30 +137,29 @@ export interface PageConfig {
|
||||||
left: number;
|
left: number;
|
||||||
right: number;
|
right: number;
|
||||||
};
|
};
|
||||||
components: any[];
|
components: Record<string, unknown>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레이아웃 설정
|
|
||||||
export interface ReportLayoutConfig {
|
export interface ReportLayoutConfig {
|
||||||
pages: PageConfig[];
|
pages: PageConfig[];
|
||||||
watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크
|
watermark?: WatermarkConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레이아웃 저장 요청
|
export interface SaveLayoutQueryItem {
|
||||||
export interface SaveLayoutRequest {
|
|
||||||
layoutConfig: ReportLayoutConfig;
|
|
||||||
queries?: Array<{
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: "MASTER" | "DETAIL";
|
type: "MASTER" | "DETAIL";
|
||||||
sqlQuery: string;
|
sqlQuery: string;
|
||||||
parameters: string[];
|
parameters: string[];
|
||||||
externalConnectionId?: number;
|
externalConnectionId?: number | null;
|
||||||
}>;
|
}
|
||||||
menuObjids?: number[]; // 연결할 메뉴 ID 목록
|
|
||||||
|
export interface SaveLayoutRequest {
|
||||||
|
layoutConfig: ReportLayoutConfig;
|
||||||
|
queries?: SaveLayoutQueryItem[];
|
||||||
|
menuObjids?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트-메뉴 매핑
|
|
||||||
export interface ReportMenuMapping {
|
export interface ReportMenuMapping {
|
||||||
mapping_id: number;
|
mapping_id: number;
|
||||||
report_id: string;
|
report_id: string;
|
||||||
|
|
@ -180,23 +169,20 @@ export interface ReportMenuMapping {
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 템플릿 목록 응답
|
|
||||||
export interface GetTemplatesResponse {
|
export interface GetTemplatesResponse {
|
||||||
system: ReportTemplate[];
|
system: ReportTemplate[];
|
||||||
custom: ReportTemplate[];
|
custom: ReportTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 템플릿 생성 요청
|
|
||||||
export interface CreateTemplateRequest {
|
export interface CreateTemplateRequest {
|
||||||
templateNameKor: string;
|
templateNameKor: string;
|
||||||
templateNameEng?: string;
|
templateNameEng?: string;
|
||||||
templateType: string;
|
templateType: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
layoutConfig?: any;
|
layoutConfig?: Record<string, unknown>;
|
||||||
defaultQueries?: any;
|
defaultQueries?: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컴포넌트 설정 (프론트엔드와 동기화)
|
|
||||||
export interface ComponentConfig {
|
export interface ComponentConfig {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -224,21 +210,16 @@ export interface ComponentConfig {
|
||||||
conditional?: string;
|
conditional?: string;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
// 이미지 전용
|
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
objectFit?: "contain" | "cover" | "fill" | "none";
|
objectFit?: "contain" | "cover" | "fill" | "none";
|
||||||
// 구분선 전용
|
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
lineStyle?: "solid" | "dashed" | "dotted" | "double";
|
lineStyle?: "solid" | "dashed" | "dotted" | "double";
|
||||||
lineWidth?: number;
|
lineWidth?: number;
|
||||||
lineColor?: string;
|
lineColor?: string;
|
||||||
// 서명/도장 전용
|
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
labelText?: string;
|
labelText?: string;
|
||||||
labelPosition?: "top" | "left" | "bottom" | "right";
|
labelPosition?: "top" | "left" | "bottom" | "right";
|
||||||
showUnderline?: boolean;
|
|
||||||
personName?: string;
|
personName?: string;
|
||||||
// 테이블 전용
|
|
||||||
tableColumns?: Array<{
|
tableColumns?: Array<{
|
||||||
field: string;
|
field: string;
|
||||||
header: string;
|
header: string;
|
||||||
|
|
@ -249,9 +230,7 @@ export interface ComponentConfig {
|
||||||
headerTextColor?: string;
|
headerTextColor?: string;
|
||||||
showBorder?: boolean;
|
showBorder?: boolean;
|
||||||
rowHeight?: number;
|
rowHeight?: number;
|
||||||
// 페이지 번호 전용
|
|
||||||
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
|
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
|
||||||
// 카드 컴포넌트 전용
|
|
||||||
cardTitle?: string;
|
cardTitle?: string;
|
||||||
cardItems?: Array<{
|
cardItems?: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -267,7 +246,6 @@ export interface ComponentConfig {
|
||||||
titleColor?: string;
|
titleColor?: string;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
valueColor?: string;
|
valueColor?: string;
|
||||||
// 계산 컴포넌트 전용
|
|
||||||
calcItems?: Array<{
|
calcItems?: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
value: number | string;
|
value: number | string;
|
||||||
|
|
@ -280,7 +258,6 @@ export interface ComponentConfig {
|
||||||
showCalcBorder?: boolean;
|
showCalcBorder?: boolean;
|
||||||
numberFormat?: "none" | "comma" | "currency";
|
numberFormat?: "none" | "comma" | "currency";
|
||||||
currencySuffix?: string;
|
currencySuffix?: string;
|
||||||
// 바코드 컴포넌트 전용
|
|
||||||
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
||||||
barcodeValue?: string;
|
barcodeValue?: string;
|
||||||
barcodeFieldName?: string;
|
barcodeFieldName?: string;
|
||||||
|
|
@ -289,19 +266,118 @@ export interface ComponentConfig {
|
||||||
barcodeBackground?: string;
|
barcodeBackground?: string;
|
||||||
barcodeMargin?: number;
|
barcodeMargin?: number;
|
||||||
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
||||||
// QR코드 다중 필드 (JSON 형식)
|
|
||||||
qrDataFields?: Array<{
|
qrDataFields?: Array<{
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
label: string;
|
label: string;
|
||||||
}>;
|
}>;
|
||||||
qrUseMultiField?: boolean;
|
qrUseMultiField?: boolean;
|
||||||
qrIncludeAllRows?: boolean;
|
qrIncludeAllRows?: boolean;
|
||||||
// 체크박스 컴포넌트 전용
|
checkboxChecked?: boolean;
|
||||||
checkboxChecked?: boolean; // 체크 상태 (고정값)
|
checkboxFieldName?: string;
|
||||||
checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값)
|
checkboxLabel?: string;
|
||||||
checkboxLabel?: string; // 체크박스 옆 레이블 텍스트
|
checkboxSize?: number;
|
||||||
checkboxSize?: number; // 체크박스 크기 (px)
|
checkboxColor?: string;
|
||||||
checkboxColor?: string; // 체크 색상
|
checkboxBorderColor?: string;
|
||||||
checkboxBorderColor?: string; // 테두리 색상
|
checkboxLabelPosition?: "left" | "right";
|
||||||
checkboxLabelPosition?: "left" | "right"; // 레이블 위치
|
visualQuery?: VisualQuery;
|
||||||
|
// 카드 레이아웃 설정 (card 컴포넌트 전용 - v3)
|
||||||
|
cardLayoutConfig?: CardLayoutConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisualQueryFormulaColumn {
|
||||||
|
alias: string;
|
||||||
|
header: string;
|
||||||
|
expression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisualQuery {
|
||||||
|
tableName: string;
|
||||||
|
limit?: number;
|
||||||
|
columns: string[];
|
||||||
|
formulaColumns: VisualQueryFormulaColumn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 카드 레이아웃 v3 타입 정의
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CardElementType = "header" | "dataCell" | "divider" | "badge";
|
||||||
|
export type CellDirection = "vertical" | "horizontal";
|
||||||
|
|
||||||
|
export interface CardElementBase {
|
||||||
|
id: string;
|
||||||
|
type: CardElementType;
|
||||||
|
colspan?: number;
|
||||||
|
rowspan?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardHeaderElement extends CardElementBase {
|
||||||
|
type: "header";
|
||||||
|
icon?: string;
|
||||||
|
iconColor?: string;
|
||||||
|
title: string;
|
||||||
|
titleColor?: string;
|
||||||
|
titleFontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardDataCellElement extends CardElementBase {
|
||||||
|
type: "dataCell";
|
||||||
|
direction: CellDirection;
|
||||||
|
label: string;
|
||||||
|
columnName?: string;
|
||||||
|
inputType?: "text" | "date" | "number" | "select" | "readonly";
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
selectOptions?: string[];
|
||||||
|
labelWidth?: number;
|
||||||
|
labelFontSize?: number;
|
||||||
|
labelColor?: string;
|
||||||
|
valueFontSize?: number;
|
||||||
|
valueColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardDividerElement extends CardElementBase {
|
||||||
|
type: "divider";
|
||||||
|
style?: "solid" | "dashed" | "dotted";
|
||||||
|
color?: string;
|
||||||
|
thickness?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardBadgeElement extends CardElementBase {
|
||||||
|
type: "badge";
|
||||||
|
label?: string;
|
||||||
|
columnName?: string;
|
||||||
|
colorMap?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CardElement =
|
||||||
|
| CardHeaderElement
|
||||||
|
| CardDataCellElement
|
||||||
|
| CardDividerElement
|
||||||
|
| CardBadgeElement;
|
||||||
|
|
||||||
|
export interface CardLayoutRow {
|
||||||
|
id: string;
|
||||||
|
gridColumns: number;
|
||||||
|
elements: CardElement[];
|
||||||
|
height?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardLayoutConfig {
|
||||||
|
tableName?: string;
|
||||||
|
primaryKey?: string;
|
||||||
|
rows: CardLayoutRow[];
|
||||||
|
padding?: string;
|
||||||
|
gap?: string;
|
||||||
|
borderStyle?: string;
|
||||||
|
borderColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
headerTitleFontSize?: number;
|
||||||
|
headerTitleColor?: string;
|
||||||
|
labelFontSize?: number;
|
||||||
|
labelColor?: string;
|
||||||
|
valueFontSize?: number;
|
||||||
|
valueColor?: string;
|
||||||
|
dividerThickness?: number;
|
||||||
|
dividerColor?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,591 +0,0 @@
|
||||||
# 리포트 디자이너 그리드 시스템 구현 계획
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
현재 자유 배치 방식의 리포트 디자이너를 **그리드 기반 스냅 시스템**으로 전환합니다.
|
|
||||||
안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다.
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
1. **정렬된 레이아웃**: 그리드 기반으로 요소들이 자동 정렬
|
|
||||||
2. **Word/PDF 변환 개선**: 그리드 정보를 활용하여 정확한 문서 변환
|
|
||||||
3. **직관적인 UI**: 그리드 시각화를 통한 명확한 배치 가이드
|
|
||||||
4. **사용자 제어**: 그리드 크기, 가시성 등 사용자 설정 가능
|
|
||||||
|
|
||||||
## 핵심 개념
|
|
||||||
|
|
||||||
### 그리드 시스템
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface GridConfig {
|
|
||||||
// 그리드 설정
|
|
||||||
cellWidth: number; // 그리드 셀 너비 (px)
|
|
||||||
cellHeight: number; // 그리드 셀 높이 (px)
|
|
||||||
rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
|
|
||||||
columns: number; // 가로 그리드 수 (계산값: pageWidth / cellWidth)
|
|
||||||
|
|
||||||
// 표시 설정
|
|
||||||
visible: boolean; // 그리드 표시 여부
|
|
||||||
snapToGrid: boolean; // 그리드 스냅 활성화 여부
|
|
||||||
|
|
||||||
// 시각적 설정
|
|
||||||
gridColor: string; // 그리드 선 색상
|
|
||||||
gridOpacity: number; // 그리드 투명도 (0-1)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 컴포넌트 위치/크기 (그리드 기반)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ComponentPosition {
|
|
||||||
// 그리드 좌표 (셀 단위)
|
|
||||||
gridX: number; // 시작 열 (0부터 시작)
|
|
||||||
gridY: number; // 시작 행 (0부터 시작)
|
|
||||||
gridWidth: number; // 차지하는 열 수
|
|
||||||
gridHeight: number; // 차지하는 행 수
|
|
||||||
|
|
||||||
// 실제 픽셀 좌표 (계산값)
|
|
||||||
x: number; // gridX * cellWidth
|
|
||||||
y: number; // gridY * cellHeight
|
|
||||||
width: number; // gridWidth * cellWidth
|
|
||||||
height: number; // gridHeight * cellHeight
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 구현 단계
|
|
||||||
|
|
||||||
### Phase 1: 그리드 시스템 기반 구조
|
|
||||||
|
|
||||||
#### 1.1 타입 정의
|
|
||||||
|
|
||||||
- **파일**: `frontend/types/report.ts`
|
|
||||||
- **내용**:
|
|
||||||
- `GridConfig` 인터페이스 추가
|
|
||||||
- `ComponentConfig`에 `gridX`, `gridY`, `gridWidth`, `gridHeight` 추가
|
|
||||||
- `ReportPage`에 `gridConfig` 추가
|
|
||||||
|
|
||||||
#### 1.2 Context 확장
|
|
||||||
|
|
||||||
- **파일**: `frontend/contexts/ReportDesignerContext.tsx`
|
|
||||||
- **내용**:
|
|
||||||
- `gridConfig` 상태 추가
|
|
||||||
- `updateGridConfig()` 함수 추가
|
|
||||||
- `snapToGrid()` 유틸리티 함수 추가
|
|
||||||
- 컴포넌트 추가/이동/리사이즈 시 그리드 스냅 적용
|
|
||||||
|
|
||||||
#### 1.3 그리드 계산 유틸리티
|
|
||||||
|
|
||||||
- **파일**: `frontend/lib/utils/gridUtils.ts` (신규)
|
|
||||||
- **내용**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 픽셀 좌표 → 그리드 좌표 변환
|
|
||||||
export function pixelToGrid(pixel: number, cellSize: number): number;
|
|
||||||
|
|
||||||
// 그리드 좌표 → 픽셀 좌표 변환
|
|
||||||
export function gridToPixel(grid: number, cellSize: number): number;
|
|
||||||
|
|
||||||
// 컴포넌트 위치/크기를 그리드에 스냅
|
|
||||||
export function snapComponentToGrid(
|
|
||||||
component: ComponentConfig,
|
|
||||||
gridConfig: GridConfig
|
|
||||||
): ComponentConfig;
|
|
||||||
|
|
||||||
// 그리드 충돌 감지
|
|
||||||
export function detectGridCollision(
|
|
||||||
component: ComponentConfig,
|
|
||||||
otherComponents: ComponentConfig[]
|
|
||||||
): boolean;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: 그리드 시각화
|
|
||||||
|
|
||||||
#### 2.1 그리드 레이어 컴포넌트
|
|
||||||
|
|
||||||
- **파일**: `frontend/components/report/designer/GridLayer.tsx` (신규)
|
|
||||||
- **내용**:
|
|
||||||
- Canvas 위에 그리드 선 렌더링
|
|
||||||
- SVG 또는 Canvas API 사용
|
|
||||||
- 그리드 크기/색상/투명도 적용
|
|
||||||
- 줌/스크롤 시에도 정확한 위치 유지
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
interface GridLayerProps {
|
|
||||||
gridConfig: GridConfig;
|
|
||||||
pageWidth: number;
|
|
||||||
pageHeight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GridLayer({
|
|
||||||
gridConfig,
|
|
||||||
pageWidth,
|
|
||||||
pageHeight,
|
|
||||||
}: GridLayerProps) {
|
|
||||||
if (!gridConfig.visible) return null;
|
|
||||||
|
|
||||||
// SVG로 그리드 선 렌더링
|
|
||||||
return (
|
|
||||||
<svg className="absolute inset-0 pointer-events-none">
|
|
||||||
{/* 세로 선 */}
|
|
||||||
{Array.from({ length: gridConfig.columns + 1 }).map((_, i) => (
|
|
||||||
<line
|
|
||||||
key={`v-${i}`}
|
|
||||||
x1={i * gridConfig.cellWidth}
|
|
||||||
y1={0}
|
|
||||||
x2={i * gridConfig.cellWidth}
|
|
||||||
y2={pageHeight}
|
|
||||||
stroke={gridConfig.gridColor}
|
|
||||||
strokeOpacity={gridConfig.opacity}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{/* 가로 선 */}
|
|
||||||
{Array.from({ length: gridConfig.rows + 1 }).map((_, i) => (
|
|
||||||
<line
|
|
||||||
key={`h-${i}`}
|
|
||||||
x1={0}
|
|
||||||
y1={i * gridConfig.cellHeight}
|
|
||||||
x2={pageWidth}
|
|
||||||
y2={i * gridConfig.cellHeight}
|
|
||||||
stroke={gridConfig.gridColor}
|
|
||||||
strokeOpacity={gridConfig.opacity}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.2 Canvas 통합
|
|
||||||
|
|
||||||
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
|
|
||||||
- **내용**:
|
|
||||||
- `<GridLayer />` 추가
|
|
||||||
- 컴포넌트 렌더링 시 그리드 기반 위치 사용
|
|
||||||
|
|
||||||
### Phase 3: 드래그 앤 드롭 스냅
|
|
||||||
|
|
||||||
#### 3.1 드래그 시 그리드 스냅
|
|
||||||
|
|
||||||
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
|
|
||||||
- **내용**:
|
|
||||||
- `useDrop` 훅 수정
|
|
||||||
- 드롭 위치를 그리드에 스냅
|
|
||||||
- 실시간 스냅 가이드 표시
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [, drop] = useDrop({
|
|
||||||
accept: ["TEXT", "LABEL", "TABLE", "SIGNATURE", "STAMP"],
|
|
||||||
drop: (item: any, monitor) => {
|
|
||||||
const offset = monitor.getClientOffset();
|
|
||||||
if (!offset) return;
|
|
||||||
|
|
||||||
// 캔버스 상대 좌표 계산
|
|
||||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
|
||||||
if (!canvasRect) return;
|
|
||||||
|
|
||||||
let x = offset.x - canvasRect.left;
|
|
||||||
let y = offset.y - canvasRect.top;
|
|
||||||
|
|
||||||
// 그리드 스냅 적용
|
|
||||||
if (gridConfig.snapToGrid) {
|
|
||||||
const gridX = Math.round(x / gridConfig.cellWidth);
|
|
||||||
const gridY = Math.round(y / gridConfig.cellHeight);
|
|
||||||
x = gridX * gridConfig.cellWidth;
|
|
||||||
y = gridY * gridConfig.cellHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트 추가
|
|
||||||
addComponent({ type: item.type, x, y });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2 리사이즈 시 그리드 스냅
|
|
||||||
|
|
||||||
- **파일**: `frontend/components/report/designer/ComponentWrapper.tsx`
|
|
||||||
- **내용**:
|
|
||||||
- `react-resizable` 또는 `react-rnd`의 `snap` 설정 활용
|
|
||||||
- 리사이즈 핸들 드래그 시 그리드 단위로만 크기 조절
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
<Rnd
|
|
||||||
position={{ x: component.x, y: component.y }}
|
|
||||||
size={{ width: component.width, height: component.height }}
|
|
||||||
onDragStop={(e, d) => {
|
|
||||||
let newX = d.x;
|
|
||||||
let newY = d.y;
|
|
||||||
|
|
||||||
if (gridConfig.snapToGrid) {
|
|
||||||
const gridX = Math.round(newX / gridConfig.cellWidth);
|
|
||||||
const gridY = Math.round(newY / gridConfig.cellHeight);
|
|
||||||
newX = gridX * gridConfig.cellWidth;
|
|
||||||
newY = gridY * gridConfig.cellHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateComponent(component.id, { x: newX, y: newY });
|
|
||||||
}}
|
|
||||||
onResizeStop={(e, direction, ref, delta, position) => {
|
|
||||||
let newWidth = parseInt(ref.style.width);
|
|
||||||
let newHeight = parseInt(ref.style.height);
|
|
||||||
|
|
||||||
if (gridConfig.snapToGrid) {
|
|
||||||
const gridWidth = Math.round(newWidth / gridConfig.cellWidth);
|
|
||||||
const gridHeight = Math.round(newHeight / gridConfig.cellHeight);
|
|
||||||
newWidth = gridWidth * gridConfig.cellWidth;
|
|
||||||
newHeight = gridHeight * gridConfig.cellHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateComponent(component.id, {
|
|
||||||
width: newWidth,
|
|
||||||
height: newHeight,
|
|
||||||
...position,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
grid={
|
|
||||||
gridConfig.snapToGrid
|
|
||||||
? [gridConfig.cellWidth, gridConfig.cellHeight]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 4: 그리드 설정 UI
|
|
||||||
|
|
||||||
#### 4.1 그리드 설정 패널
|
|
||||||
|
|
||||||
- **파일**: `frontend/components/report/designer/GridSettingsPanel.tsx` (신규)
|
|
||||||
- **내용**:
|
|
||||||
- 그리드 크기 조절 (cellWidth, cellHeight)
|
|
||||||
- 그리드 표시/숨김 토글
|
|
||||||
- 스냅 활성화/비활성화 토글
|
|
||||||
- 그리드 색상/투명도 조절
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
export function GridSettingsPanel() {
|
|
||||||
const { gridConfig, updateGridConfig } = useReportDesigner();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm">그리드 설정</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{/* 그리드 표시 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label>그리드 표시</Label>
|
|
||||||
<Switch
|
|
||||||
checked={gridConfig.visible}
|
|
||||||
onCheckedChange={(visible) => updateGridConfig({ visible })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 스냅 활성화 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label>그리드 스냅</Label>
|
|
||||||
<Switch
|
|
||||||
checked={gridConfig.snapToGrid}
|
|
||||||
onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 셀 크기 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>셀 너비 (px)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={gridConfig.cellWidth}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateGridConfig({ cellWidth: parseInt(e.target.value) })
|
|
||||||
}
|
|
||||||
min={10}
|
|
||||||
max={100}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>셀 높이 (px)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={gridConfig.cellHeight}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateGridConfig({ cellHeight: parseInt(e.target.value) })
|
|
||||||
}
|
|
||||||
min={10}
|
|
||||||
max={100}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 프리셋 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>프리셋</Label>
|
|
||||||
<Select
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const presets: Record<
|
|
||||||
string,
|
|
||||||
{ cellWidth: number; cellHeight: number }
|
|
||||||
> = {
|
|
||||||
fine: { cellWidth: 10, cellHeight: 10 },
|
|
||||||
medium: { cellWidth: 20, cellHeight: 20 },
|
|
||||||
coarse: { cellWidth: 50, cellHeight: 50 },
|
|
||||||
};
|
|
||||||
updateGridConfig(presets[value]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="그리드 크기 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="fine">세밀 (10x10)</SelectItem>
|
|
||||||
<SelectItem value="medium">중간 (20x20)</SelectItem>
|
|
||||||
<SelectItem value="coarse">넓음 (50x50)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.2 툴바에 그리드 토글 추가
|
|
||||||
|
|
||||||
- **파일**: `frontend/components/report/designer/ReportDesignerToolbar.tsx`
|
|
||||||
- **내용**:
|
|
||||||
- 그리드 표시/숨김 버튼
|
|
||||||
- 그리드 설정 모달 열기 버튼
|
|
||||||
- 키보드 단축키 (`G` 키로 그리드 토글)
|
|
||||||
|
|
||||||
### Phase 5: Word 변환 개선
|
|
||||||
|
|
||||||
#### 5.1 그리드 기반 레이아웃 변환
|
|
||||||
|
|
||||||
- **파일**: `frontend/components/report/designer/ReportPreviewModal.tsx`
|
|
||||||
- **내용**:
|
|
||||||
- 그리드 정보를 활용하여 더 정확한 테이블 레이아웃 생성
|
|
||||||
- 그리드 행/열을 Word 테이블의 행/열로 매핑
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const handleDownloadWord = async () => {
|
|
||||||
// 그리드 기반으로 컴포넌트 배치 맵 생성
|
|
||||||
const gridMap: (ComponentConfig | null)[][] = Array(gridConfig.rows)
|
|
||||||
.fill(null)
|
|
||||||
.map(() => Array(gridConfig.columns).fill(null));
|
|
||||||
|
|
||||||
// 각 컴포넌트를 그리드 맵에 배치
|
|
||||||
for (const component of components) {
|
|
||||||
const gridX = Math.round(component.x / gridConfig.cellWidth);
|
|
||||||
const gridY = Math.round(component.y / gridConfig.cellHeight);
|
|
||||||
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
|
|
||||||
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
|
|
||||||
|
|
||||||
// 컴포넌트가 차지하는 모든 셀에 참조 저장
|
|
||||||
for (let y = gridY; y < gridY + gridHeight; y++) {
|
|
||||||
for (let x = gridX; x < gridX + gridWidth; x++) {
|
|
||||||
if (y < gridConfig.rows && x < gridConfig.columns) {
|
|
||||||
gridMap[y][x] = component;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 그리드 맵을 Word 테이블로 변환
|
|
||||||
const tableRows: TableRow[] = [];
|
|
||||||
|
|
||||||
for (let y = 0; y < gridConfig.rows; y++) {
|
|
||||||
const cells: TableCell[] = [];
|
|
||||||
let x = 0;
|
|
||||||
|
|
||||||
while (x < gridConfig.columns) {
|
|
||||||
const component = gridMap[y][x];
|
|
||||||
|
|
||||||
if (!component) {
|
|
||||||
// 빈 셀
|
|
||||||
cells.push(new TableCell({ children: [new Paragraph("")] }));
|
|
||||||
x++;
|
|
||||||
} else {
|
|
||||||
// 컴포넌트 셀
|
|
||||||
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
|
|
||||||
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
|
|
||||||
|
|
||||||
const cell = createTableCell(component, gridWidth, gridHeight);
|
|
||||||
if (cell) cells.push(cell);
|
|
||||||
|
|
||||||
x += gridWidth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cells.length > 0) {
|
|
||||||
tableRows.push(new TableRow({ children: cells }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... Word 문서 생성
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 6: 데이터 마이그레이션
|
|
||||||
|
|
||||||
#### 6.1 기존 레이아웃 자동 변환
|
|
||||||
|
|
||||||
- **파일**: `frontend/lib/utils/layoutMigration.ts` (신규)
|
|
||||||
- **내용**:
|
|
||||||
- 기존 절대 위치 데이터를 그리드 기반으로 변환
|
|
||||||
- 가장 가까운 그리드 셀에 스냅
|
|
||||||
- 마이그레이션 로그 생성
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function migrateLayoutToGrid(
|
|
||||||
layout: ReportLayoutConfig,
|
|
||||||
gridConfig: GridConfig
|
|
||||||
): ReportLayoutConfig {
|
|
||||||
return {
|
|
||||||
...layout,
|
|
||||||
pages: layout.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
gridConfig,
|
|
||||||
components: page.components.map((component) => {
|
|
||||||
// 픽셀 좌표를 그리드 좌표로 변환
|
|
||||||
const gridX = Math.round(component.x / gridConfig.cellWidth);
|
|
||||||
const gridY = Math.round(component.y / gridConfig.cellHeight);
|
|
||||||
const gridWidth = Math.max(
|
|
||||||
1,
|
|
||||||
Math.round(component.width / gridConfig.cellWidth)
|
|
||||||
);
|
|
||||||
const gridHeight = Math.max(
|
|
||||||
1,
|
|
||||||
Math.round(component.height / gridConfig.cellHeight)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...component,
|
|
||||||
gridX,
|
|
||||||
gridY,
|
|
||||||
gridWidth,
|
|
||||||
gridHeight,
|
|
||||||
x: gridX * gridConfig.cellWidth,
|
|
||||||
y: gridY * gridConfig.cellHeight,
|
|
||||||
width: gridWidth * gridConfig.cellWidth,
|
|
||||||
height: gridHeight * gridConfig.cellHeight,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6.2 마이그레이션 UI
|
|
||||||
|
|
||||||
- **파일**: `frontend/components/report/designer/MigrationModal.tsx` (신규)
|
|
||||||
- **내용**:
|
|
||||||
- 기존 리포트 로드 시 마이그레이션 필요 여부 체크
|
|
||||||
- 마이그레이션 전/후 미리보기
|
|
||||||
- 사용자 확인 후 적용
|
|
||||||
|
|
||||||
## 데이터베이스 스키마 변경
|
|
||||||
|
|
||||||
### report_layout_pages 테이블
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE report_layout_pages
|
|
||||||
ADD COLUMN grid_cell_width INTEGER DEFAULT 20,
|
|
||||||
ADD COLUMN grid_cell_height INTEGER DEFAULT 20,
|
|
||||||
ADD COLUMN grid_visible BOOLEAN DEFAULT true,
|
|
||||||
ADD COLUMN grid_snap_enabled BOOLEAN DEFAULT true,
|
|
||||||
ADD COLUMN grid_color VARCHAR(7) DEFAULT '#e5e7eb',
|
|
||||||
ADD COLUMN grid_opacity DECIMAL(3,2) DEFAULT 0.5;
|
|
||||||
```
|
|
||||||
|
|
||||||
### report_layout_components 테이블
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE report_layout_components
|
|
||||||
ADD COLUMN grid_x INTEGER,
|
|
||||||
ADD COLUMN grid_y INTEGER,
|
|
||||||
ADD COLUMN grid_width INTEGER,
|
|
||||||
ADD COLUMN grid_height INTEGER;
|
|
||||||
|
|
||||||
-- 기존 데이터 마이그레이션
|
|
||||||
UPDATE report_layout_components
|
|
||||||
SET
|
|
||||||
grid_x = ROUND(position_x / 20.0),
|
|
||||||
grid_y = ROUND(position_y / 20.0),
|
|
||||||
grid_width = GREATEST(1, ROUND(width / 20.0)),
|
|
||||||
grid_height = GREATEST(1, ROUND(height / 20.0))
|
|
||||||
WHERE grid_x IS NULL;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 테스트 계획
|
|
||||||
|
|
||||||
### 단위 테스트
|
|
||||||
|
|
||||||
- `gridUtils.ts`의 모든 함수 테스트
|
|
||||||
- 그리드 좌표 ↔ 픽셀 좌표 변환 정확성
|
|
||||||
- 충돌 감지 로직
|
|
||||||
|
|
||||||
### 통합 테스트
|
|
||||||
|
|
||||||
- 드래그 앤 드롭 시 그리드 스냅 동작
|
|
||||||
- 리사이즈 시 그리드 스냅 동작
|
|
||||||
- 그리드 크기 변경 시 컴포넌트 재배치
|
|
||||||
|
|
||||||
### E2E 테스트
|
|
||||||
|
|
||||||
- 새 리포트 생성 및 그리드 설정
|
|
||||||
- 기존 리포트 마이그레이션
|
|
||||||
- Word 다운로드 시 레이아웃 정확성
|
|
||||||
|
|
||||||
## 예상 개발 일정
|
|
||||||
|
|
||||||
- **Phase 1**: 그리드 시스템 기반 구조 (2일)
|
|
||||||
- **Phase 2**: 그리드 시각화 (1일)
|
|
||||||
- **Phase 3**: 드래그 앤 드롭 스냅 (2일)
|
|
||||||
- **Phase 4**: 그리드 설정 UI (1일)
|
|
||||||
- **Phase 5**: Word 변환 개선 (2일)
|
|
||||||
- **Phase 6**: 데이터 마이그레이션 (1일)
|
|
||||||
- **테스트 및 디버깅**: (2일)
|
|
||||||
|
|
||||||
**총 예상 기간**: 11일
|
|
||||||
|
|
||||||
## 기술적 고려사항
|
|
||||||
|
|
||||||
### 성능 최적화
|
|
||||||
|
|
||||||
- 그리드 렌더링: SVG 대신 Canvas API 고려 (많은 셀의 경우)
|
|
||||||
- 메모이제이션: 그리드 계산 결과 캐싱
|
|
||||||
- 가상화: 큰 페이지에서 보이는 영역만 렌더링
|
|
||||||
|
|
||||||
### 사용자 경험
|
|
||||||
|
|
||||||
- 실시간 스냅 가이드: 드래그 중 스냅될 위치 미리 표시
|
|
||||||
- 키보드 단축키: 방향키로 그리드 단위 이동, Shift+방향키로 픽셀 단위 미세 조정
|
|
||||||
- 언두/리두: 그리드 스냅 적용 전/후 상태 저장
|
|
||||||
|
|
||||||
### 하위 호환성
|
|
||||||
|
|
||||||
- 기존 리포트는 자동 마이그레이션 제공
|
|
||||||
- 마이그레이션 옵션: 자동 / 수동 선택 가능
|
|
||||||
- 레거시 모드: 그리드 없이 자유 배치 가능 (옵션)
|
|
||||||
|
|
||||||
## 추가 기능 (향후 확장)
|
|
||||||
|
|
||||||
### 스마트 가이드
|
|
||||||
|
|
||||||
- 다른 컴포넌트와 정렬 시 가이드 라인 표시
|
|
||||||
- 균등 간격 가이드
|
|
||||||
|
|
||||||
### 그리드 템플릿
|
|
||||||
|
|
||||||
- 자주 사용하는 그리드 레이아웃 템플릿 제공
|
|
||||||
- 문서 종류별 프리셋 (계약서, 보고서, 송장 등)
|
|
||||||
|
|
||||||
### 그리드 병합
|
|
||||||
|
|
||||||
- 여러 그리드 셀을 하나로 병합
|
|
||||||
- 복잡한 레이아웃 지원
|
|
||||||
|
|
||||||
## 참고 자료
|
|
||||||
|
|
||||||
- Android Home Screen Widget System
|
|
||||||
- Microsoft Word Table Layout
|
|
||||||
- CSS Grid Layout
|
|
||||||
- Figma Auto Layout
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,358 +0,0 @@
|
||||||
# 리포트 관리 시스템 구현 진행 상황
|
|
||||||
|
|
||||||
## 프로젝트 개요
|
|
||||||
|
|
||||||
동적 리포트 디자이너 시스템 구현
|
|
||||||
|
|
||||||
- 사용자가 드래그 앤 드롭으로 리포트 레이아웃 설계
|
|
||||||
- SQL 쿼리 연동으로 실시간 데이터 표시
|
|
||||||
- 미리보기 및 인쇄 기능
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 완료된 작업 ✅
|
|
||||||
|
|
||||||
### 1. 데이터베이스 설계 및 구축
|
|
||||||
|
|
||||||
- [x] `report_template` 테이블 생성 (18개 초기 템플릿)
|
|
||||||
- [x] `report_master` 테이블 생성 (리포트 메타 정보)
|
|
||||||
- [x] `report_layout` 테이블 생성 (레이아웃 JSON)
|
|
||||||
- [x] `report_query` 테이블 생성 (쿼리 정의)
|
|
||||||
|
|
||||||
**파일**: `db/report_schema.sql`, `db/report_query_schema.sql`
|
|
||||||
|
|
||||||
### 2. 백엔드 API 구현
|
|
||||||
|
|
||||||
- [x] 리포트 CRUD API (생성, 조회, 수정, 삭제)
|
|
||||||
- [x] 템플릿 조회 API
|
|
||||||
- [x] 레이아웃 저장/조회 API
|
|
||||||
- [x] 쿼리 실행 API (파라미터 지원)
|
|
||||||
- [x] 리포트 복사 API
|
|
||||||
- [x] Raw SQL 기반 구현 (Prisma 대신 pg 사용)
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `backend-node/src/types/report.ts`
|
|
||||||
- `backend-node/src/services/reportService.ts`
|
|
||||||
- `backend-node/src/controllers/reportController.ts`
|
|
||||||
- `backend-node/src/routes/reportRoutes.ts`
|
|
||||||
|
|
||||||
### 3. 프론트엔드 - 리포트 목록 페이지
|
|
||||||
|
|
||||||
- [x] 리포트 리스트 조회 및 표시
|
|
||||||
- [x] 검색 기능
|
|
||||||
- [x] 페이지네이션
|
|
||||||
- [x] 새 리포트 생성 (디자이너로 이동)
|
|
||||||
- [x] 수정/복사/삭제 액션 버튼
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `frontend/app/(main)/admin/report/page.tsx`
|
|
||||||
- `frontend/components/report/ReportListTable.tsx`
|
|
||||||
- `frontend/hooks/useReportList.ts`
|
|
||||||
|
|
||||||
### 4. 프론트엔드 - 리포트 디자이너 기본 구조
|
|
||||||
|
|
||||||
- [x] Context 기반 상태 관리 (`ReportDesignerContext`)
|
|
||||||
- [x] 툴바 (저장, 미리보기, 초기화, 뒤로가기)
|
|
||||||
- [x] 3단 레이아웃 (좌측 팔레트 / 중앙 캔버스 / 우측 속성)
|
|
||||||
- [x] "new" 리포트 처리 (저장 시 생성)
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `frontend/contexts/ReportDesignerContext.tsx`
|
|
||||||
- `frontend/app/(main)/admin/report/designer/[reportId]/page.tsx`
|
|
||||||
- `frontend/components/report/designer/ReportDesignerToolbar.tsx`
|
|
||||||
|
|
||||||
### 5. 컴포넌트 팔레트 및 캔버스
|
|
||||||
|
|
||||||
- [x] 드래그 가능한 컴포넌트 목록 (텍스트, 레이블, 테이블)
|
|
||||||
- [x] 드래그 앤 드롭으로 캔버스에 컴포넌트 배치
|
|
||||||
- [x] 컴포넌트 이동 (드래그)
|
|
||||||
- [x] 컴포넌트 크기 조절 (리사이즈 핸들)
|
|
||||||
- [x] 컴포넌트 선택 및 삭제
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `frontend/components/report/designer/ComponentPalette.tsx`
|
|
||||||
- `frontend/components/report/designer/ReportDesignerCanvas.tsx`
|
|
||||||
- `frontend/components/report/designer/CanvasComponent.tsx`
|
|
||||||
|
|
||||||
### 6. 쿼리 관리 시스템
|
|
||||||
|
|
||||||
- [x] 쿼리 추가/수정/삭제 (마스터/디테일)
|
|
||||||
- [x] SQL 파라미터 자동 감지 ($1, $2 등)
|
|
||||||
- [x] 파라미터 타입 선택 (text, number, date)
|
|
||||||
- [x] 파라미터 입력값 검증
|
|
||||||
- [x] 쿼리 실행 및 결과 표시
|
|
||||||
- [x] "new" 리포트에서도 쿼리 실행 가능
|
|
||||||
- [x] 실행 결과를 Context에 저장
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `frontend/components/report/designer/QueryManager.tsx`
|
|
||||||
- `frontend/contexts/ReportDesignerContext.tsx` (QueryResult 관리)
|
|
||||||
|
|
||||||
### 7. 데이터 바인딩 시스템
|
|
||||||
|
|
||||||
- [x] 속성 패널에서 컴포넌트-쿼리 연결
|
|
||||||
- [x] 텍스트/레이블: 쿼리 + 필드 선택
|
|
||||||
- [x] 테이블: 쿼리 선택 (모든 필드 자동 표시)
|
|
||||||
- [x] 캔버스에서 실제 데이터 표시 (바인딩된 필드의 값)
|
|
||||||
- [x] 실행 결과가 없으면 `{필드명}` 표시
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
|
|
||||||
- `frontend/components/report/designer/CanvasComponent.tsx`
|
|
||||||
|
|
||||||
### 8. 미리보기 및 내보내기
|
|
||||||
|
|
||||||
- [x] 미리보기 모달
|
|
||||||
- [x] 실제 쿼리 데이터로 렌더링
|
|
||||||
- [x] 편집용 UI 제거 (순수 데이터만 표시)
|
|
||||||
- [x] 브라우저 인쇄 기능
|
|
||||||
- [x] PDF 다운로드 (브라우저 네이티브 인쇄 기능)
|
|
||||||
- [x] WORD 다운로드 (docx 라이브러리)
|
|
||||||
- [x] 파일명 자동 생성 (리포트명\_날짜)
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `frontend/components/report/designer/ReportPreviewModal.tsx`
|
|
||||||
|
|
||||||
**사용 라이브러리**:
|
|
||||||
|
|
||||||
- `docx`: WORD 문서 생성 (PDF는 브라우저 기본 기능 사용)
|
|
||||||
|
|
||||||
### 9. 템플릿 시스템
|
|
||||||
|
|
||||||
- [x] 시스템 템플릿 적용 (발주서, 청구서, 기본)
|
|
||||||
- [x] 템플릿별 기본 컴포넌트 자동 배치
|
|
||||||
- [x] 템플릿별 기본 쿼리 자동 생성
|
|
||||||
- [x] 사용자 정의 템플릿 저장 기능
|
|
||||||
- [x] 사용자 정의 템플릿 목록 조회
|
|
||||||
- [x] 사용자 정의 템플릿 삭제
|
|
||||||
- [x] 사용자 정의 템플릿 적용 (백엔드 연동)
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `frontend/contexts/ReportDesignerContext.tsx` (템플릿 적용 로직)
|
|
||||||
- `frontend/components/report/designer/TemplatePalette.tsx`
|
|
||||||
- `frontend/components/report/designer/SaveAsTemplateModal.tsx`
|
|
||||||
- `backend-node/src/services/reportService.ts` (createTemplateFromLayout)
|
|
||||||
|
|
||||||
### 10. 외부 DB 연동
|
|
||||||
|
|
||||||
- [x] 쿼리별 외부 DB 연결 선택
|
|
||||||
- [x] 외부 DB 연결 목록 조회 API
|
|
||||||
- [x] 쿼리 실행 시 외부 DB 지원
|
|
||||||
- [x] 내부/외부 DB 선택 UI
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `frontend/components/report/designer/QueryManager.tsx`
|
|
||||||
- `backend-node/src/services/reportService.ts` (executeQuery with external DB)
|
|
||||||
|
|
||||||
### 11. 컴포넌트 스타일링
|
|
||||||
|
|
||||||
- [x] 폰트 크기 설정
|
|
||||||
- [x] 폰트 색상 설정 (컬러피커)
|
|
||||||
- [x] 폰트 굵기 (보통/굵게)
|
|
||||||
- [x] 텍스트 정렬 (좌/중/우)
|
|
||||||
- [x] 배경색 설정 (투명 옵션 포함)
|
|
||||||
- [x] 테두리 설정 (두께, 색상)
|
|
||||||
- [x] 캔버스 및 미리보기에 스타일 반영
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
|
|
||||||
- `frontend/components/report/designer/CanvasComponent.tsx`
|
|
||||||
|
|
||||||
### 12. 레이아웃 도구 (완료!)
|
|
||||||
|
|
||||||
- [x] **Grid Snap**: 10px 단위 그리드에 자동 정렬
|
|
||||||
- [x] **정렬 가이드라인**: 드래그 시 빨간색 가이드라인 표시
|
|
||||||
- [x] **복사/붙여넣기**: Ctrl+C/V로 컴포넌트 복사 (20px 오프셋)
|
|
||||||
- [x] **Undo/Redo**: 히스토리 관리 (Ctrl+Z / Ctrl+Shift+Z)
|
|
||||||
- [x] **컴포넌트 정렬**: 좌/우/상/하/가로중앙/세로중앙 정렬
|
|
||||||
- [x] **컴포넌트 배치**: 가로/세로 균등 배치 (3개 이상)
|
|
||||||
- [x] **크기 조정**: 같은 너비/높이/크기로 조정 (2개 이상)
|
|
||||||
- [x] **화살표 키 이동**: 1px 이동, Shift+화살표 10px 이동
|
|
||||||
- [x] **레이어 관리**: 맨 앞/뒤, 한 단계 앞/뒤 (Z-Index 조정)
|
|
||||||
- [x] **컴포넌트 잠금**: 편집/이동/삭제 방지, 🔒 표시
|
|
||||||
- [x] **눈금자 표시**: 가로/세로 mm 단위 눈금자
|
|
||||||
- [x] **컴포넌트 그룹화**: 여러 컴포넌트를 그룹으로 묶어 함께 이동, 👥 표시
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
|
|
||||||
- `frontend/contexts/ReportDesignerContext.tsx` (레이아웃 도구 로직)
|
|
||||||
- `frontend/components/report/designer/ReportDesignerToolbar.tsx` (버튼 UI)
|
|
||||||
- `frontend/components/report/designer/ReportDesignerCanvas.tsx` (Grid, 가이드라인)
|
|
||||||
- `frontend/components/report/designer/CanvasComponent.tsx` (잠금, 그룹)
|
|
||||||
- `frontend/components/report/designer/Ruler.tsx` (눈금자 컴포넌트)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 진행 중인 작업 🚧
|
|
||||||
|
|
||||||
없음 (모든 레이아웃 도구 구현 완료!)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 남은 작업 (우선순위순) 📋
|
|
||||||
|
|
||||||
### Phase 1: 추가 컴포넌트 ✅ 완료!
|
|
||||||
|
|
||||||
1. **이미지 컴포넌트** ✅
|
|
||||||
|
|
||||||
- [x] 파일 업로드 (multer, 10MB 제한)
|
|
||||||
- [x] 회사별 디렉토리 분리 저장
|
|
||||||
- [x] 맞춤 방식 (contain/cover/fill/none)
|
|
||||||
- [x] CORS 설정으로 이미지 로딩
|
|
||||||
- [x] 캔버스 및 미리보기 렌더링
|
|
||||||
- 로고, 서명, 도장 등에 활용
|
|
||||||
|
|
||||||
2. **구분선 컴포넌트 (Divider)** ✅
|
|
||||||
|
|
||||||
- [x] 가로/세로 방향 선택
|
|
||||||
- [x] 선 두께 (lineWidth) 독립 속성
|
|
||||||
- [x] 선 색상 (lineColor) 독립 속성
|
|
||||||
- [x] 선 스타일 (solid/dashed/dotted/double)
|
|
||||||
- [x] 캔버스 및 미리보기 렌더링
|
|
||||||
|
|
||||||
**파일**:
|
|
||||||
- `backend-node/src/controllers/reportController.ts` (uploadImage)
|
|
||||||
- `backend-node/src/routes/reportRoutes.ts` (multer 설정)
|
|
||||||
- `frontend/types/report.ts` (이미지/구분선 속성)
|
|
||||||
- `frontend/components/report/designer/ComponentPalette.tsx`
|
|
||||||
- `frontend/components/report/designer/CanvasComponent.tsx`
|
|
||||||
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
|
|
||||||
- `frontend/components/report/designer/ReportPreviewModal.tsx`
|
|
||||||
- `frontend/lib/api/client.ts` (getFullImageUrl)
|
|
||||||
|
|
||||||
3. **차트 컴포넌트** (선택사항) ⬅️ 다음 권장 작업
|
|
||||||
- 막대 차트
|
|
||||||
- 선 차트
|
|
||||||
- 원형 차트
|
|
||||||
- 쿼리 데이터 연동
|
|
||||||
|
|
||||||
### Phase 2: 고급 기능
|
|
||||||
|
|
||||||
4. **조건부 서식**
|
|
||||||
|
|
||||||
- 특정 조건에 따른 스타일 변경
|
|
||||||
- 값 범위에 따른 색상 표시
|
|
||||||
- 수식 기반 표시/숨김
|
|
||||||
|
|
||||||
5. **쿼리 관리 개선**
|
|
||||||
- 쿼리 미리보기 개선 (테이블 형태)
|
|
||||||
- 쿼리 저장/불러오기
|
|
||||||
- 쿼리 템플릿
|
|
||||||
|
|
||||||
### Phase 3: 성능 및 보안
|
|
||||||
|
|
||||||
6. **성능 최적화**
|
|
||||||
|
|
||||||
- 쿼리 결과 캐싱
|
|
||||||
- 대용량 데이터 페이징
|
|
||||||
- 렌더링 최적화
|
|
||||||
- 이미지 레이지 로딩
|
|
||||||
|
|
||||||
7. **권한 관리**
|
|
||||||
- 리포트별 접근 권한
|
|
||||||
- 수정 권한 분리
|
|
||||||
- 템플릿 공유
|
|
||||||
- 사용자별 리포트 목록 필터링
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 기술 스택
|
|
||||||
|
|
||||||
### 백엔드
|
|
||||||
|
|
||||||
- Node.js + TypeScript
|
|
||||||
- Express.js
|
|
||||||
- PostgreSQL (raw SQL)
|
|
||||||
- pg (node-postgres)
|
|
||||||
|
|
||||||
### 프론트엔드
|
|
||||||
|
|
||||||
- Next.js 14 (App Router)
|
|
||||||
- React 18
|
|
||||||
- TypeScript
|
|
||||||
- Tailwind CSS
|
|
||||||
- Shadcn UI
|
|
||||||
- react-dnd (드래그 앤 드롭)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 주요 아키텍처 결정
|
|
||||||
|
|
||||||
### 1. Context API 사용
|
|
||||||
|
|
||||||
- 리포트 디자이너의 복잡한 상태를 Context로 중앙 관리
|
|
||||||
- 컴포넌트 간 prop drilling 방지
|
|
||||||
|
|
||||||
### 2. Raw SQL 사용
|
|
||||||
|
|
||||||
- Prisma 대신 직접 SQL 작성
|
|
||||||
- 복잡한 쿼리와 트랜잭션 처리에 유리
|
|
||||||
- 데이터베이스 제어 수준 향상
|
|
||||||
|
|
||||||
### 3. JSON 기반 레이아웃 저장
|
|
||||||
|
|
||||||
- 레이아웃을 JSONB로 DB에 저장
|
|
||||||
- 버전 관리 용이
|
|
||||||
- 유연한 스키마
|
|
||||||
|
|
||||||
### 4. 쿼리 실행 결과 메모리 관리
|
|
||||||
|
|
||||||
- Context에 쿼리 결과 저장
|
|
||||||
- 컴포넌트에서 실시간 참조
|
|
||||||
- 불필요한 API 호출 방지
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 참고 문서
|
|
||||||
|
|
||||||
- [리포트*관리*시스템\_설계.md](./리포트_관리_시스템_설계.md) - 초기 설계 문서
|
|
||||||
- [레포트드자이너.html](../레포트드자이너.html) - 참조 프로토타입
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 다음 작업: 리포트 복사/삭제 테스트 및 검증
|
|
||||||
|
|
||||||
### 테스트 항목
|
|
||||||
|
|
||||||
1. **복사 기능 테스트**
|
|
||||||
|
|
||||||
- 리포트 복사 버튼 클릭
|
|
||||||
- 복사된 리포트명 확인 (원본명 + "\_copy")
|
|
||||||
- 복사된 리포트의 레이아웃 확인
|
|
||||||
- 복사된 리포트의 쿼리 확인
|
|
||||||
- 목록 자동 새로고침 확인
|
|
||||||
|
|
||||||
2. **삭제 기능 테스트**
|
|
||||||
|
|
||||||
- 삭제 버튼 클릭 시 확인 다이얼로그 표시
|
|
||||||
- 취소 버튼 동작 확인
|
|
||||||
- 삭제 실행 후 목록에서 제거 확인
|
|
||||||
- Toast 메시지 표시 확인
|
|
||||||
|
|
||||||
3. **에러 처리 테스트**
|
|
||||||
- 존재하지 않는 리포트 삭제 시도
|
|
||||||
- 네트워크 오류 시 Toast 메시지
|
|
||||||
- 로딩 중 버튼 비활성화 확인
|
|
||||||
|
|
||||||
### 추가 개선 사항
|
|
||||||
|
|
||||||
- [ ] 컴포넌트 복사 기능 (Ctrl+C/Ctrl+V)
|
|
||||||
- [ ] 다중 선택 및 정렬 기능
|
|
||||||
- [ ] 실행 취소/다시 실행 (Undo/Redo)
|
|
||||||
- [ ] 사용자 정의 템플릿 저장
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**최종 업데이트**: 2025-10-01
|
|
||||||
**작성자**: AI Assistant
|
|
||||||
**상태**: 이미지 & 구분선 컴포넌트 완료 (기본 컴포넌트 완료, 약 99% 완료)
|
|
||||||
|
|
@ -1,679 +0,0 @@
|
||||||
# 리포트 관리 시스템 설계
|
|
||||||
|
|
||||||
## 1. 프로젝트 개요
|
|
||||||
|
|
||||||
### 1.1 목적
|
|
||||||
|
|
||||||
ERP 시스템에서 다양한 업무 문서(발주서, 청구서, 거래명세서 등)를 동적으로 디자인하고 관리할 수 있는 리포트 관리 시스템을 구축합니다.
|
|
||||||
|
|
||||||
### 1.2 주요 기능
|
|
||||||
|
|
||||||
- 리포트 목록 조회 및 관리
|
|
||||||
- 드래그 앤 드롭 기반 리포트 디자이너
|
|
||||||
- 템플릿 관리 (기본 템플릿 + 사용자 정의 템플릿)
|
|
||||||
- 쿼리 관리 (마스터/디테일)
|
|
||||||
- 외부 DB 연동
|
|
||||||
- 인쇄 및 내보내기 (PDF, WORD)
|
|
||||||
- 미리보기 기능
|
|
||||||
|
|
||||||
## 2. 화면 구성
|
|
||||||
|
|
||||||
### 2.1 리포트 목록 화면 (`/admin/report`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 리포트 관리 [+ 새 리포트] │
|
|
||||||
├──────────────────────────────────────────────────────────────────┤
|
|
||||||
│ 검색: [____________________] [검색] [초기화] │
|
|
||||||
├──────────────────────────────────────────────────────────────────┤
|
|
||||||
│ No │ 리포트명 │ 작성자 │ 수정일 │ 액션 │
|
|
||||||
├────┼──────────────┼────────┼───────────┼────────────────────────┤
|
|
||||||
│ 1 │ 발주서 양식 │ 홍길동 │ 2025-10-01 │ 수정 │ 복사 │ 삭제 │
|
|
||||||
│ 2 │ 청구서 기본 │ 김철수 │ 2025-09-28 │ 수정 │ 복사 │ 삭제 │
|
|
||||||
│ 3 │ 거래명세서 │ 이영희 │ 2025-09-25 │ 수정 │ 복사 │ 삭제 │
|
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**기능**
|
|
||||||
|
|
||||||
- 리포트 목록 조회 (페이징, 정렬, 검색)
|
|
||||||
- 새 리포트 생성
|
|
||||||
- 기존 리포트 수정
|
|
||||||
- 리포트 복사
|
|
||||||
- 리포트 삭제
|
|
||||||
- 리포트 미리보기
|
|
||||||
|
|
||||||
### 2.2 리포트 디자이너 화면
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 리포트 디자이너 [저장] [미리보기] [초기화] [목록으로] │
|
|
||||||
├──────┬────────────────────────────────────────────────┬──────────┤
|
|
||||||
│ │ │ │
|
|
||||||
│ 템플릿│ 작업 영역 (캔버스) │ 속성 패널 │
|
|
||||||
│ │ │ │
|
|
||||||
│ 컴포넌트│ [드래그 앤 드롭] │ 쿼리 관리 │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ DB 연동 │
|
|
||||||
└──────┴────────────────────────────────────────────────┴──────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 미리보기 모달
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 미리보기 [닫기] │
|
|
||||||
├──────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ [리포트 내용 미리보기] │
|
|
||||||
│ │
|
|
||||||
├──────────────────────────────────────────────────────────────────┤
|
|
||||||
│ [인쇄] [PDF] [WORD] │
|
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 데이터베이스 설계
|
|
||||||
|
|
||||||
### 3.1 테이블 구조
|
|
||||||
|
|
||||||
#### REPORT_TEMPLATE (리포트 템플릿)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE report_template (
|
|
||||||
template_id VARCHAR(50) PRIMARY KEY, -- 템플릿 ID
|
|
||||||
template_name_kor VARCHAR(100) NOT NULL, -- 템플릿명 (한국어)
|
|
||||||
template_name_eng VARCHAR(100), -- 템플릿명 (영어)
|
|
||||||
template_type VARCHAR(30) NOT NULL, -- 템플릿 타입 (ORDER, INVOICE, STATEMENT, etc)
|
|
||||||
is_system CHAR(1) DEFAULT 'N', -- 시스템 기본 템플릿 여부 (Y/N)
|
|
||||||
thumbnail_url VARCHAR(500), -- 썸네일 이미지 경로
|
|
||||||
description TEXT, -- 템플릿 설명
|
|
||||||
layout_config TEXT, -- 레이아웃 설정 (JSON)
|
|
||||||
default_queries TEXT, -- 기본 쿼리 (JSON)
|
|
||||||
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
|
|
||||||
sort_order INTEGER DEFAULT 0, -- 정렬 순서
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by VARCHAR(50),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by VARCHAR(50)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### REPORT_MASTER (리포트 마스터)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE report_master (
|
|
||||||
report_id VARCHAR(50) PRIMARY KEY, -- 리포트 ID
|
|
||||||
report_name_kor VARCHAR(100) NOT NULL, -- 리포트명 (한국어)
|
|
||||||
report_name_eng VARCHAR(100), -- 리포트명 (영어)
|
|
||||||
template_id VARCHAR(50), -- 템플릿 ID (FK)
|
|
||||||
report_type VARCHAR(30) NOT NULL, -- 리포트 타입
|
|
||||||
company_code VARCHAR(20), -- 회사 코드
|
|
||||||
description TEXT, -- 설명
|
|
||||||
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by VARCHAR(50),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by VARCHAR(50),
|
|
||||||
FOREIGN KEY (template_id) REFERENCES report_template(template_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### REPORT_LAYOUT (리포트 레이아웃)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE report_layout (
|
|
||||||
layout_id VARCHAR(50) PRIMARY KEY, -- 레이아웃 ID
|
|
||||||
report_id VARCHAR(50) NOT NULL, -- 리포트 ID (FK)
|
|
||||||
canvas_width INTEGER DEFAULT 210, -- 캔버스 너비 (mm)
|
|
||||||
canvas_height INTEGER DEFAULT 297, -- 캔버스 높이 (mm)
|
|
||||||
page_orientation VARCHAR(10) DEFAULT 'portrait', -- 페이지 방향 (portrait/landscape)
|
|
||||||
margin_top INTEGER DEFAULT 20, -- 상단 여백 (mm)
|
|
||||||
margin_bottom INTEGER DEFAULT 20, -- 하단 여백 (mm)
|
|
||||||
margin_left INTEGER DEFAULT 20, -- 좌측 여백 (mm)
|
|
||||||
margin_right INTEGER DEFAULT 20, -- 우측 여백 (mm)
|
|
||||||
components TEXT, -- 컴포넌트 배치 정보 (JSON)
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by VARCHAR(50),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by VARCHAR(50),
|
|
||||||
FOREIGN KEY (report_id) REFERENCES report_master(report_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 컴포넌트 목록
|
|
||||||
|
|
||||||
### 4.1 기본 컴포넌트
|
|
||||||
|
|
||||||
#### 텍스트 관련
|
|
||||||
|
|
||||||
- **Text Field**: 단일 라인 텍스트 입력/표시
|
|
||||||
- **Text Area**: 여러 줄 텍스트 입력/표시
|
|
||||||
- **Label**: 고정 라벨 텍스트
|
|
||||||
- **Rich Text**: 서식이 있는 텍스트 (굵게, 기울임, 색상)
|
|
||||||
|
|
||||||
#### 숫자/날짜 관련
|
|
||||||
|
|
||||||
- **Number**: 숫자 표시 (통화 형식 지원)
|
|
||||||
- **Date**: 날짜 표시 (형식 지정 가능)
|
|
||||||
- **Date Time**: 날짜 + 시간 표시
|
|
||||||
- **Calculate Field**: 계산 필드 (합계, 평균 등)
|
|
||||||
|
|
||||||
#### 테이블/그리드
|
|
||||||
|
|
||||||
- **Data Table**: 데이터 테이블 (디테일 쿼리 바인딩)
|
|
||||||
- **Summary Table**: 요약 테이블
|
|
||||||
- **Group Table**: 그룹핑 테이블
|
|
||||||
|
|
||||||
#### 이미지/그래픽
|
|
||||||
|
|
||||||
- **Image**: 이미지 표시 (로고, 서명 등)
|
|
||||||
- **Line**: 구분선
|
|
||||||
- **Rectangle**: 사각형 (테두리)
|
|
||||||
|
|
||||||
#### 특수 컴포넌트
|
|
||||||
|
|
||||||
- **Page Number**: 페이지 번호
|
|
||||||
- **Current Date**: 현재 날짜/시간
|
|
||||||
- **Company Info**: 회사 정보 (자동)
|
|
||||||
- **Signature**: 서명란
|
|
||||||
- **Stamp**: 도장란
|
|
||||||
|
|
||||||
### 4.2 컴포넌트 속성
|
|
||||||
|
|
||||||
각 컴포넌트는 다음 공통 속성을 가집니다:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ComponentBase {
|
|
||||||
id: string; // 컴포넌트 ID
|
|
||||||
type: string; // 컴포넌트 타입
|
|
||||||
x: number; // X 좌표
|
|
||||||
y: number; // Y 좌표
|
|
||||||
width: number; // 너비
|
|
||||||
height: number; // 높이
|
|
||||||
zIndex: number; // Z-인덱스
|
|
||||||
|
|
||||||
// 스타일
|
|
||||||
fontSize?: number; // 글자 크기
|
|
||||||
fontFamily?: string; // 폰트
|
|
||||||
fontWeight?: string; // 글자 굵기
|
|
||||||
fontColor?: string; // 글자 색상
|
|
||||||
backgroundColor?: string; // 배경색
|
|
||||||
borderWidth?: number; // 테두리 두께
|
|
||||||
borderColor?: string; // 테두리 색상
|
|
||||||
borderRadius?: number; // 모서리 둥글기
|
|
||||||
textAlign?: string; // 텍스트 정렬
|
|
||||||
padding?: number; // 내부 여백
|
|
||||||
|
|
||||||
// 데이터 바인딩
|
|
||||||
queryId?: string; // 연결된 쿼리 ID
|
|
||||||
fieldName?: string; // 필드명
|
|
||||||
defaultValue?: string; // 기본값
|
|
||||||
format?: string; // 표시 형식
|
|
||||||
|
|
||||||
// 기타
|
|
||||||
visible?: boolean; // 표시 여부
|
|
||||||
printable?: boolean; // 인쇄 여부
|
|
||||||
conditional?: string; // 조건부 표시 (수식)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 템플릿 목록
|
|
||||||
|
|
||||||
### 5.1 기본 템플릿 (시스템)
|
|
||||||
|
|
||||||
#### 구매/발주 관련
|
|
||||||
|
|
||||||
- **발주서 (Purchase Order)**: 거래처에 발주하는 문서
|
|
||||||
- **구매요청서 (Purchase Request)**: 내부 구매 요청 문서
|
|
||||||
- **발주 확인서 (PO Confirmation)**: 발주 확인 문서
|
|
||||||
|
|
||||||
#### 판매/청구 관련
|
|
||||||
|
|
||||||
- **청구서 (Invoice)**: 고객에게 청구하는 문서
|
|
||||||
- **견적서 (Quotation)**: 견적 제공 문서
|
|
||||||
- **거래명세서 (Transaction Statement)**: 거래 내역 명세
|
|
||||||
- **세금계산서 (Tax Invoice)**: 세금 계산서
|
|
||||||
- **영수증 (Receipt)**: 영수 증빙 문서
|
|
||||||
|
|
||||||
#### 재고/입출고 관련
|
|
||||||
|
|
||||||
- **입고증 (Goods Receipt)**: 입고 증빙 문서
|
|
||||||
- **출고증 (Delivery Note)**: 출고 증빙 문서
|
|
||||||
- **재고 현황표 (Inventory Report)**: 재고 현황
|
|
||||||
- **이동 전표 (Transfer Note)**: 재고 이동 문서
|
|
||||||
|
|
||||||
#### 생산 관련
|
|
||||||
|
|
||||||
- **작업지시서 (Work Order)**: 생산 작업 지시
|
|
||||||
- **생산 일보 (Production Daily Report)**: 생산 일일 보고
|
|
||||||
- **품질 검사표 (Quality Inspection)**: 품질 검사 기록
|
|
||||||
- **불량 보고서 (Defect Report)**: 불량 보고
|
|
||||||
|
|
||||||
#### 회계/경영 관련
|
|
||||||
|
|
||||||
- **손익 계산서 (Income Statement)**: 손익 현황
|
|
||||||
- **대차대조표 (Balance Sheet)**: 재무 상태
|
|
||||||
- **현금 흐름표 (Cash Flow Statement)**: 현금 흐름
|
|
||||||
- **급여 명세서 (Payroll Slip)**: 급여 내역
|
|
||||||
|
|
||||||
#### 일반 문서
|
|
||||||
|
|
||||||
- **기본 양식 (Basic Template)**: 빈 캔버스
|
|
||||||
- **일반 보고서 (General Report)**: 일반 보고 양식
|
|
||||||
- **목록 양식 (List Template)**: 목록형 양식
|
|
||||||
|
|
||||||
### 5.2 사용자 정의 템플릿
|
|
||||||
|
|
||||||
- 사용자가 직접 생성한 템플릿
|
|
||||||
- 기본 템플릿을 복사하여 수정 가능
|
|
||||||
- 회사별로 관리 가능
|
|
||||||
|
|
||||||
## 6. API 설계
|
|
||||||
|
|
||||||
### 6.1 리포트 목록 API
|
|
||||||
|
|
||||||
#### GET `/api/admin/reports`
|
|
||||||
|
|
||||||
리포트 목록 조회
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Request
|
|
||||||
interface GetReportsRequest {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
searchText?: string;
|
|
||||||
reportType?: string;
|
|
||||||
useYn?: string;
|
|
||||||
sortBy?: string;
|
|
||||||
sortOrder?: "ASC" | "DESC";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response
|
|
||||||
interface GetReportsResponse {
|
|
||||||
items: ReportMaster[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### GET `/api/admin/reports/:reportId`
|
|
||||||
|
|
||||||
리포트 상세 조회
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Response
|
|
||||||
interface ReportDetail {
|
|
||||||
report: ReportMaster;
|
|
||||||
layout: ReportLayout;
|
|
||||||
queries: ReportQuery[];
|
|
||||||
components: Component[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### POST `/api/admin/reports`
|
|
||||||
|
|
||||||
리포트 생성
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Request
|
|
||||||
interface CreateReportRequest {
|
|
||||||
reportNameKor: string;
|
|
||||||
reportNameEng?: string;
|
|
||||||
templateId?: string;
|
|
||||||
reportType: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response
|
|
||||||
interface CreateReportResponse {
|
|
||||||
reportId: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### PUT `/api/admin/reports/:reportId`
|
|
||||||
|
|
||||||
리포트 수정
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Request
|
|
||||||
interface UpdateReportRequest {
|
|
||||||
reportNameKor?: string;
|
|
||||||
reportNameEng?: string;
|
|
||||||
reportType?: string;
|
|
||||||
description?: string;
|
|
||||||
useYn?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### DELETE `/api/admin/reports/:reportId`
|
|
||||||
|
|
||||||
리포트 삭제
|
|
||||||
|
|
||||||
#### POST `/api/admin/reports/:reportId/copy`
|
|
||||||
|
|
||||||
리포트 복사
|
|
||||||
|
|
||||||
### 6.2 템플릿 API
|
|
||||||
|
|
||||||
#### GET `/api/admin/reports/templates`
|
|
||||||
|
|
||||||
템플릿 목록 조회
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Response
|
|
||||||
interface GetTemplatesResponse {
|
|
||||||
system: ReportTemplate[]; // 시스템 템플릿
|
|
||||||
custom: ReportTemplate[]; // 사용자 정의 템플릿
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### POST `/api/admin/reports/templates`
|
|
||||||
|
|
||||||
템플릿 생성 (사용자 정의)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Request
|
|
||||||
interface CreateTemplateRequest {
|
|
||||||
templateNameKor: string;
|
|
||||||
templateNameEng?: string;
|
|
||||||
templateType: string;
|
|
||||||
description?: string;
|
|
||||||
layoutConfig: any;
|
|
||||||
defaultQueries?: any;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### PUT `/api/admin/reports/templates/:templateId`
|
|
||||||
|
|
||||||
템플릿 수정
|
|
||||||
|
|
||||||
#### DELETE `/api/admin/reports/templates/:templateId`
|
|
||||||
|
|
||||||
템플릿 삭제
|
|
||||||
|
|
||||||
### 6.3 레이아웃 API
|
|
||||||
|
|
||||||
#### GET `/api/admin/reports/:reportId/layout`
|
|
||||||
|
|
||||||
레이아웃 조회
|
|
||||||
|
|
||||||
#### PUT `/api/admin/reports/:reportId/layout`
|
|
||||||
|
|
||||||
레이아웃 저장
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Request
|
|
||||||
interface SaveLayoutRequest {
|
|
||||||
canvasWidth: number;
|
|
||||||
canvasHeight: number;
|
|
||||||
pageOrientation: string;
|
|
||||||
margins: {
|
|
||||||
top: number;
|
|
||||||
bottom: number;
|
|
||||||
left: number;
|
|
||||||
right: number;
|
|
||||||
};
|
|
||||||
components: Component[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.4 인쇄/내보내기 API
|
|
||||||
|
|
||||||
#### POST `/api/admin/reports/:reportId/preview`
|
|
||||||
|
|
||||||
미리보기 생성
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Request
|
|
||||||
interface PreviewRequest {
|
|
||||||
parameters?: { [key: string]: any };
|
|
||||||
format?: "HTML" | "PDF";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response
|
|
||||||
interface PreviewResponse {
|
|
||||||
html?: string; // HTML 미리보기
|
|
||||||
pdfUrl?: string; // PDF URL
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### POST `/api/admin/reports/:reportId/print`
|
|
||||||
|
|
||||||
인쇄 (PDF 생성)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Request
|
|
||||||
interface PrintRequest {
|
|
||||||
parameters?: { [key: string]: any };
|
|
||||||
format: "PDF" | "WORD" | "EXCEL";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response
|
|
||||||
interface PrintResponse {
|
|
||||||
fileUrl: string;
|
|
||||||
fileName: string;
|
|
||||||
fileSize: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 프론트엔드 구조
|
|
||||||
|
|
||||||
### 7.1 페이지 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
/admin/report
|
|
||||||
├── ReportListPage.tsx # 리포트 목록 페이지
|
|
||||||
├── ReportDesignerPage.tsx # 리포트 디자이너 페이지
|
|
||||||
└── components/
|
|
||||||
├── ReportList.tsx # 리포트 목록 테이블
|
|
||||||
├── ReportSearchForm.tsx # 검색 폼
|
|
||||||
├── TemplateSelector.tsx # 템플릿 선택기
|
|
||||||
├── ComponentPalette.tsx # 컴포넌트 팔레트
|
|
||||||
├── Canvas.tsx # 캔버스 영역
|
|
||||||
├── ComponentRenderer.tsx # 컴포넌트 렌더러
|
|
||||||
├── PropertyPanel.tsx # 속성 패널
|
|
||||||
├── QueryManager.tsx # 쿼리 관리
|
|
||||||
├── QueryCard.tsx # 쿼리 카드
|
|
||||||
├── ConnectionManager.tsx # 외부 DB 연결 관리
|
|
||||||
├── PreviewModal.tsx # 미리보기 모달
|
|
||||||
└── PrintOptionsModal.tsx # 인쇄 옵션 모달
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 상태 관리
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ReportDesignerState {
|
|
||||||
// 리포트 기본 정보
|
|
||||||
report: ReportMaster | null;
|
|
||||||
|
|
||||||
// 레이아웃
|
|
||||||
layout: ReportLayout | null;
|
|
||||||
components: Component[];
|
|
||||||
selectedComponentId: string | null;
|
|
||||||
|
|
||||||
// 쿼리
|
|
||||||
queries: ReportQuery[];
|
|
||||||
queryResults: { [queryId: string]: any[] };
|
|
||||||
|
|
||||||
// 외부 연결
|
|
||||||
connections: ReportExternalConnection[];
|
|
||||||
|
|
||||||
// UI 상태
|
|
||||||
isDragging: boolean;
|
|
||||||
isResizing: boolean;
|
|
||||||
showPreview: boolean;
|
|
||||||
showPrintOptions: boolean;
|
|
||||||
|
|
||||||
// 히스토리 (Undo/Redo)
|
|
||||||
history: {
|
|
||||||
past: Component[][];
|
|
||||||
present: Component[];
|
|
||||||
future: Component[][];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 구현 우선순위
|
|
||||||
|
|
||||||
### Phase 1: 기본 기능 (2주)
|
|
||||||
|
|
||||||
- [ ] 데이터베이스 테이블 생성
|
|
||||||
- [ ] 리포트 목록 화면
|
|
||||||
- [ ] 리포트 CRUD API
|
|
||||||
- [ ] 템플릿 목록 조회
|
|
||||||
- [ ] 기본 템플릿 데이터 생성
|
|
||||||
|
|
||||||
### Phase 2: 디자이너 기본 (2주)
|
|
||||||
|
|
||||||
- [ ] 캔버스 구현
|
|
||||||
- [ ] 컴포넌트 드래그 앤 드롭
|
|
||||||
- [ ] 컴포넌트 선택/이동/크기 조절
|
|
||||||
- [ ] 속성 패널 (기본)
|
|
||||||
- [ ] 저장/불러오기
|
|
||||||
|
|
||||||
### Phase 3: 쿼리 관리 (1주)
|
|
||||||
|
|
||||||
- [ ] 쿼리 추가/수정/삭제
|
|
||||||
- [ ] 파라미터 감지 및 입력
|
|
||||||
- [ ] 쿼리 실행 (내부 DB)
|
|
||||||
- [ ] 쿼리 결과를 컴포넌트에 바인딩
|
|
||||||
|
|
||||||
### Phase 4: 쿼리 관리 고급 (1주)
|
|
||||||
|
|
||||||
- [ ] 쿼리 필드 매핑
|
|
||||||
- [ ] 컴포넌트와 데이터 바인딩
|
|
||||||
- [ ] 파라미터 전달 및 처리
|
|
||||||
|
|
||||||
### Phase 5: 미리보기/인쇄 (1주)
|
|
||||||
|
|
||||||
- [ ] HTML 미리보기
|
|
||||||
- [ ] PDF 생성
|
|
||||||
- [ ] WORD 생성
|
|
||||||
- [ ] 브라우저 인쇄
|
|
||||||
|
|
||||||
### Phase 6: 고급 기능 (2주)
|
|
||||||
|
|
||||||
- [ ] 템플릿 생성 기능
|
|
||||||
- [ ] 컴포넌트 추가 (이미지, 서명, 도장)
|
|
||||||
- [ ] 계산 필드
|
|
||||||
- [ ] 조건부 표시
|
|
||||||
- [ ] Undo/Redo
|
|
||||||
- [ ] 다국어 지원
|
|
||||||
|
|
||||||
## 9. 기술 스택
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- **Node.js + TypeScript**: 백엔드 서버
|
|
||||||
- **PostgreSQL**: 데이터베이스
|
|
||||||
- **Prisma**: ORM
|
|
||||||
- **Puppeteer**: PDF 생성
|
|
||||||
- **docx**: WORD 생성
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- **Next.js + React**: 프론트엔드 프레임워크
|
|
||||||
- **TypeScript**: 타입 안정성
|
|
||||||
- **TailwindCSS**: 스타일링
|
|
||||||
- **react-dnd**: 드래그 앤 드롭
|
|
||||||
- **react-grid-layout**: 레이아웃 관리
|
|
||||||
- **react-to-print**: 인쇄 기능
|
|
||||||
- **react-pdf**: PDF 미리보기
|
|
||||||
|
|
||||||
## 10. 보안 고려사항
|
|
||||||
|
|
||||||
### 10.1 쿼리 실행 보안
|
|
||||||
|
|
||||||
- SELECT 쿼리만 허용 (INSERT, UPDATE, DELETE 금지)
|
|
||||||
- 쿼리 결과 크기 제한 (최대 1000 rows)
|
|
||||||
- 실행 시간 제한 (30초)
|
|
||||||
- SQL 인젝션 방지 (파라미터 바인딩 강제)
|
|
||||||
- 위험한 함수 차단 (DROP, TRUNCATE 등)
|
|
||||||
|
|
||||||
### 10.2 파일 보안
|
|
||||||
|
|
||||||
- 생성된 PDF/WORD 파일은 임시 디렉토리에 저장
|
|
||||||
- 파일은 24시간 후 자동 삭제
|
|
||||||
- 파일 다운로드 시 토큰 검증
|
|
||||||
|
|
||||||
### 10.3 접근 권한
|
|
||||||
|
|
||||||
- 리포트 생성/수정/삭제 권한 체크
|
|
||||||
- 관리자만 템플릿 생성 가능
|
|
||||||
- 사용자별 리포트 접근 제어
|
|
||||||
|
|
||||||
## 11. 성능 최적화
|
|
||||||
|
|
||||||
### 11.1 PDF 생성 최적화
|
|
||||||
|
|
||||||
- 백그라운드 작업으로 처리
|
|
||||||
- 생성된 PDF는 CDN에 캐싱
|
|
||||||
|
|
||||||
### 11.2 프론트엔드 최적화
|
|
||||||
|
|
||||||
- 컴포넌트 가상화 (많은 컴포넌트 처리)
|
|
||||||
- 디바운싱/쓰로틀링 (드래그 앤 드롭)
|
|
||||||
- 이미지 레이지 로딩
|
|
||||||
|
|
||||||
### 11.3 데이터베이스 최적화
|
|
||||||
|
|
||||||
- 레이아웃 데이터는 JSON 형태로 저장
|
|
||||||
- 리포트 목록 조회 시 인덱스 활용
|
|
||||||
- 자주 사용하는 템플릿 캐싱
|
|
||||||
|
|
||||||
## 12. 테스트 계획
|
|
||||||
|
|
||||||
### 12.1 단위 테스트
|
|
||||||
|
|
||||||
- API 엔드포인트 테스트
|
|
||||||
- 쿼리 파싱 테스트
|
|
||||||
- PDF 생성 테스트
|
|
||||||
|
|
||||||
### 12.2 통합 테스트
|
|
||||||
|
|
||||||
- 리포트 생성 → 쿼리 실행 → PDF 생성 전체 플로우
|
|
||||||
- 템플릿 적용 → 데이터 바인딩 테스트
|
|
||||||
|
|
||||||
### 12.3 UI 테스트
|
|
||||||
|
|
||||||
- 드래그 앤 드롭 동작 테스트
|
|
||||||
- 컴포넌트 속성 변경 테스트
|
|
||||||
|
|
||||||
## 13. 향후 확장 계획
|
|
||||||
|
|
||||||
### 13.1 고급 기능
|
|
||||||
|
|
||||||
- 차트/그래프 컴포넌트
|
|
||||||
- 조건부 서식 (색상 변경 등)
|
|
||||||
- 그룹핑 및 집계 함수
|
|
||||||
- 마스터-디테일 관계 자동 설정
|
|
||||||
|
|
||||||
### 13.2 협업 기능
|
|
||||||
|
|
||||||
- 리포트 공유
|
|
||||||
- 버전 관리
|
|
||||||
- 댓글 기능
|
|
||||||
|
|
||||||
### 13.3 자동화
|
|
||||||
|
|
||||||
- 스케줄링 (정기적 리포트 생성)
|
|
||||||
- 이메일 자동 발송
|
|
||||||
- 알림 설정
|
|
||||||
|
|
||||||
## 14. 참고 자료
|
|
||||||
|
|
||||||
### 14.1 유사 솔루션
|
|
||||||
|
|
||||||
- Crystal Reports
|
|
||||||
- JasperReports
|
|
||||||
- BIRT (Business Intelligence and Reporting Tools)
|
|
||||||
- FastReport
|
|
||||||
|
|
||||||
### 14.2 라이브러리
|
|
||||||
|
|
||||||
- [react-grid-layout](https://github.com/react-grid-layout/react-grid-layout)
|
|
||||||
- [react-dnd](https://react-dnd.github.io/react-dnd/)
|
|
||||||
- [puppeteer](https://pptr.dev/)
|
|
||||||
- [docx](https://docx.js.org/)
|
|
||||||
|
|
@ -1,371 +0,0 @@
|
||||||
# 리포트 문서 번호 자동 채번 시스템 설계
|
|
||||||
|
|
||||||
## 1. 개요
|
|
||||||
|
|
||||||
리포트 관리 시스템에 체계적인 문서 번호 자동 채번 시스템을 추가하여, 기업 환경에서 문서를 추적하고 관리할 수 있도록 합니다.
|
|
||||||
|
|
||||||
## 2. 문서 번호 형식
|
|
||||||
|
|
||||||
### 2.1 기본 형식
|
|
||||||
|
|
||||||
```
|
|
||||||
{PREFIX}-{YEAR}-{SEQUENCE}
|
|
||||||
예: RPT-2024-0001, INV-2024-0123
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 확장 형식 (선택 사항)
|
|
||||||
|
|
||||||
```
|
|
||||||
{PREFIX}-{DEPT_CODE}-{YEAR}-{SEQUENCE}
|
|
||||||
예: RPT-SALES-2024-0001, INV-FIN-2024-0123
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 구성 요소
|
|
||||||
|
|
||||||
- **PREFIX**: 문서 유형 접두사 (예: RPT, INV, PO, QT)
|
|
||||||
- **DEPT_CODE**: 부서 코드 (선택 사항)
|
|
||||||
- **YEAR**: 연도 (4자리)
|
|
||||||
- **SEQUENCE**: 순차 번호 (0001부터 시작, 자릿수 설정 가능)
|
|
||||||
|
|
||||||
## 3. 데이터베이스 스키마
|
|
||||||
|
|
||||||
### 3.1 문서 번호 규칙 테이블
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 문서 번호 규칙 정의
|
|
||||||
CREATE TABLE report_number_rules (
|
|
||||||
rule_id SERIAL PRIMARY KEY,
|
|
||||||
rule_name VARCHAR(100) NOT NULL, -- 규칙 이름
|
|
||||||
prefix VARCHAR(20) NOT NULL, -- 접두사 (RPT, INV 등)
|
|
||||||
use_dept_code BOOLEAN DEFAULT FALSE, -- 부서 코드 사용 여부
|
|
||||||
use_year BOOLEAN DEFAULT TRUE, -- 연도 사용 여부
|
|
||||||
sequence_length INTEGER DEFAULT 4, -- 순차 번호 자릿수
|
|
||||||
reset_period VARCHAR(20) DEFAULT 'YEARLY', -- 초기화 주기 (YEARLY, MONTHLY, NEVER)
|
|
||||||
separator VARCHAR(5) DEFAULT '-', -- 구분자
|
|
||||||
description TEXT, -- 설명
|
|
||||||
is_active BOOLEAN DEFAULT TRUE, -- 활성화 여부
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by VARCHAR(50),
|
|
||||||
updated_by VARCHAR(50)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 기본 데이터 삽입
|
|
||||||
INSERT INTO report_number_rules (rule_name, prefix, description)
|
|
||||||
VALUES ('리포트 문서 번호', 'RPT', '일반 리포트 문서 번호 규칙');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 문서 번호 시퀀스 테이블
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 문서 번호 시퀀스 관리 (연도/부서별 현재 번호)
|
|
||||||
CREATE TABLE report_number_sequences (
|
|
||||||
sequence_id SERIAL PRIMARY KEY,
|
|
||||||
rule_id INTEGER NOT NULL REFERENCES report_number_rules(rule_id),
|
|
||||||
dept_code VARCHAR(20), -- 부서 코드 (NULL 가능)
|
|
||||||
year INTEGER NOT NULL, -- 연도
|
|
||||||
current_number INTEGER DEFAULT 0, -- 현재 번호
|
|
||||||
last_generated_at TIMESTAMP, -- 마지막 생성 시각
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE (rule_id, dept_code, year) -- 규칙+부서+연도 조합 유니크
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 리포트 테이블 수정
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 기존 report_layout 테이블에 컬럼 추가
|
|
||||||
ALTER TABLE report_layout
|
|
||||||
ADD COLUMN document_number VARCHAR(100), -- 생성된 문서 번호
|
|
||||||
ADD COLUMN number_rule_id INTEGER REFERENCES report_number_rules(rule_id), -- 사용된 규칙
|
|
||||||
ADD COLUMN number_generated_at TIMESTAMP; -- 번호 생성 시각
|
|
||||||
|
|
||||||
-- 문서 번호 인덱스 (검색 성능)
|
|
||||||
CREATE INDEX idx_report_layout_document_number ON report_layout(document_number);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 문서 번호 이력 테이블 (감사용)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 문서 번호 생성 이력
|
|
||||||
CREATE TABLE report_number_history (
|
|
||||||
history_id SERIAL PRIMARY KEY,
|
|
||||||
report_id INTEGER REFERENCES report_layout(id),
|
|
||||||
document_number VARCHAR(100) NOT NULL,
|
|
||||||
rule_id INTEGER REFERENCES report_number_rules(rule_id),
|
|
||||||
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
generated_by VARCHAR(50),
|
|
||||||
is_voided BOOLEAN DEFAULT FALSE, -- 번호 무효화 여부
|
|
||||||
void_reason TEXT, -- 무효화 사유
|
|
||||||
voided_at TIMESTAMP,
|
|
||||||
voided_by VARCHAR(50)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 문서 번호로 검색 인덱스
|
|
||||||
CREATE INDEX idx_report_number_history_doc_number ON report_number_history(document_number);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 백엔드 구현
|
|
||||||
|
|
||||||
### 4.1 서비스 레이어 (`reportNumberService.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export class ReportNumberService {
|
|
||||||
// 문서 번호 생성
|
|
||||||
static async generateNumber(
|
|
||||||
ruleId: number,
|
|
||||||
deptCode?: string
|
|
||||||
): Promise<string>;
|
|
||||||
|
|
||||||
// 문서 번호 형식 검증
|
|
||||||
static async validateNumber(documentNumber: string): Promise<boolean>;
|
|
||||||
|
|
||||||
// 문서 번호 중복 체크
|
|
||||||
static async isDuplicate(documentNumber: string): Promise<boolean>;
|
|
||||||
|
|
||||||
// 문서 번호 무효화
|
|
||||||
static async voidNumber(
|
|
||||||
documentNumber: string,
|
|
||||||
reason: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<void>;
|
|
||||||
|
|
||||||
// 특정 규칙의 다음 번호 미리보기
|
|
||||||
static async previewNextNumber(
|
|
||||||
ruleId: number,
|
|
||||||
deptCode?: string
|
|
||||||
): Promise<string>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 컨트롤러 (`reportNumberController.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// GET /api/report/number-rules - 규칙 목록
|
|
||||||
// GET /api/report/number-rules/:id - 규칙 상세
|
|
||||||
// POST /api/report/number-rules - 규칙 생성
|
|
||||||
// PUT /api/report/number-rules/:id - 규칙 수정
|
|
||||||
// DELETE /api/report/number-rules/:id - 규칙 삭제
|
|
||||||
|
|
||||||
// POST /api/report/:reportId/generate-number - 문서 번호 생성
|
|
||||||
// POST /api/report/number/preview - 다음 번호 미리보기
|
|
||||||
// POST /api/report/number/void - 문서 번호 무효화
|
|
||||||
// GET /api/report/number/history/:documentNumber - 문서 번호 이력
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 핵심 로직 (번호 생성)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async generateNumber(ruleId: number, deptCode?: string): Promise<string> {
|
|
||||||
// 1. 트랜잭션 시작
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
|
|
||||||
// 2. 규칙 조회
|
|
||||||
const rule = await this.getRule(ruleId);
|
|
||||||
|
|
||||||
// 3. 현재 연도/월
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
|
|
||||||
// 4. 시퀀스 조회 또는 생성 (FOR UPDATE로 락)
|
|
||||||
let sequence = await this.getSequence(ruleId, deptCode, year, true);
|
|
||||||
|
|
||||||
if (!sequence) {
|
|
||||||
sequence = await this.createSequence(ruleId, deptCode, year);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 다음 번호 계산
|
|
||||||
const nextNumber = sequence.current_number + 1;
|
|
||||||
|
|
||||||
// 6. 문서 번호 생성
|
|
||||||
const documentNumber = this.formatNumber(rule, deptCode, year, nextNumber);
|
|
||||||
|
|
||||||
// 7. 시퀀스 업데이트
|
|
||||||
await this.updateSequence(sequence.sequence_id, nextNumber);
|
|
||||||
|
|
||||||
// 8. 커밋
|
|
||||||
await client.query('COMMIT');
|
|
||||||
|
|
||||||
return documentNumber;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 번호 포맷팅
|
|
||||||
private formatNumber(
|
|
||||||
rule: NumberRule,
|
|
||||||
deptCode: string | undefined,
|
|
||||||
year: number,
|
|
||||||
sequence: number
|
|
||||||
): string {
|
|
||||||
const parts = [rule.prefix];
|
|
||||||
|
|
||||||
if (rule.use_dept_code && deptCode) {
|
|
||||||
parts.push(deptCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rule.use_year) {
|
|
||||||
parts.push(year.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 0 패딩
|
|
||||||
const paddedSequence = sequence.toString().padStart(rule.sequence_length, '0');
|
|
||||||
parts.push(paddedSequence);
|
|
||||||
|
|
||||||
return parts.join(rule.separator);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 프론트엔드 구현
|
|
||||||
|
|
||||||
### 5.1 문서 번호 규칙 관리 화면
|
|
||||||
|
|
||||||
**경로**: `/admin/report/number-rules`
|
|
||||||
|
|
||||||
**기능**:
|
|
||||||
|
|
||||||
- 규칙 목록 조회
|
|
||||||
- 규칙 생성/수정/삭제
|
|
||||||
- 규칙 미리보기 (다음 번호 확인)
|
|
||||||
- 규칙 활성화/비활성화
|
|
||||||
|
|
||||||
### 5.2 리포트 목록 화면 수정
|
|
||||||
|
|
||||||
**변경 사항**:
|
|
||||||
|
|
||||||
- 문서 번호 컬럼 추가
|
|
||||||
- 문서 번호로 검색 기능
|
|
||||||
|
|
||||||
### 5.3 리포트 저장 시 번호 생성
|
|
||||||
|
|
||||||
**위치**: `ReportDesignerContext.tsx` - `saveLayout` 함수
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const saveLayout = async () => {
|
|
||||||
// 1. 새 리포트인 경우 문서 번호 자동 생성
|
|
||||||
if (reportId === "new" && !documentNumber) {
|
|
||||||
const response = await fetch(`/api/report/generate-number`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ ruleId: 1 }), // 기본 규칙
|
|
||||||
});
|
|
||||||
const { documentNumber: newNumber } = await response.json();
|
|
||||||
setDocumentNumber(newNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 리포트 저장 (문서 번호 포함)
|
|
||||||
await saveReport({ ...reportData, documentNumber });
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 문서 번호 표시 UI
|
|
||||||
|
|
||||||
**위치**: 디자이너 헤더
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<div className="document-number">
|
|
||||||
<Label>문서 번호</Label>
|
|
||||||
<Badge variant="outline">{documentNumber || "저장 시 자동 생성"}</Badge>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 동시성 제어
|
|
||||||
|
|
||||||
### 6.1 문제점
|
|
||||||
|
|
||||||
여러 사용자가 동시에 문서 번호를 생성할 때 중복 발생 가능성
|
|
||||||
|
|
||||||
### 6.2 해결 방법
|
|
||||||
|
|
||||||
**PostgreSQL의 `FOR UPDATE` 사용**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 시퀀스 조회 시 행 락 걸기
|
|
||||||
SELECT * FROM report_number_sequences
|
|
||||||
WHERE rule_id = $1 AND year = $2
|
|
||||||
FOR UPDATE;
|
|
||||||
```
|
|
||||||
|
|
||||||
**트랜잭션 격리 수준**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 테스트 시나리오
|
|
||||||
|
|
||||||
### 7.1 기본 기능 테스트
|
|
||||||
|
|
||||||
- [ ] 규칙 생성 → 문서 번호 생성 → 포맷 확인
|
|
||||||
- [ ] 연속 생성 시 순차 번호 증가 확인
|
|
||||||
- [ ] 연도 변경 시 시퀀스 초기화 확인
|
|
||||||
|
|
||||||
### 7.2 동시성 테스트
|
|
||||||
|
|
||||||
- [ ] 10명이 동시에 문서 번호 생성 → 중복 없음 확인
|
|
||||||
- [ ] 동일 규칙으로 100개 생성 → 순차 번호 연속성 확인
|
|
||||||
|
|
||||||
### 7.3 에러 처리
|
|
||||||
|
|
||||||
- [ ] 존재하지 않는 규칙 ID → 에러 메시지
|
|
||||||
- [ ] 비활성화된 규칙 사용 → 경고 메시지
|
|
||||||
- [ ] 시퀀스 최대값 초과 → 관리자 알림
|
|
||||||
|
|
||||||
## 8. 구현 순서
|
|
||||||
|
|
||||||
### Phase 1: 데이터베이스 (1단계)
|
|
||||||
|
|
||||||
1. 테이블 생성 SQL 작성
|
|
||||||
2. 마이그레이션 실행
|
|
||||||
3. 기본 데이터 삽입
|
|
||||||
|
|
||||||
### Phase 2: 백엔드 (2단계)
|
|
||||||
|
|
||||||
1. `reportNumberService.ts` 구현
|
|
||||||
2. `reportNumberController.ts` 구현
|
|
||||||
3. 라우트 추가
|
|
||||||
4. 단위 테스트
|
|
||||||
|
|
||||||
### Phase 3: 프론트엔드 (3단계)
|
|
||||||
|
|
||||||
1. 문서 번호 규칙 관리 화면
|
|
||||||
2. 리포트 목록 화면 수정
|
|
||||||
3. 디자이너 문서 번호 표시
|
|
||||||
4. 저장 시 자동 생성 연동
|
|
||||||
|
|
||||||
### Phase 4: 테스트 및 최적화 (4단계)
|
|
||||||
|
|
||||||
1. 통합 테스트
|
|
||||||
2. 동시성 테스트
|
|
||||||
3. 성능 최적화
|
|
||||||
4. 사용자 가이드 작성
|
|
||||||
|
|
||||||
## 9. 향후 확장
|
|
||||||
|
|
||||||
### 9.1 고급 기능
|
|
||||||
|
|
||||||
- 문서 번호 예약 기능
|
|
||||||
- 번호 건너뛰기 허용 설정
|
|
||||||
- 커스텀 포맷 지원 (정규식 기반)
|
|
||||||
- 연/월/일 단위 초기화 선택
|
|
||||||
|
|
||||||
### 9.2 통합
|
|
||||||
|
|
||||||
- 승인 완료 시점에 최종 번호 확정
|
|
||||||
- 외부 시스템과 번호 동기화
|
|
||||||
- 바코드/QR 코드 자동 생성
|
|
||||||
|
|
||||||
## 10. 보안 고려사항
|
|
||||||
|
|
||||||
- 문서 번호 생성 권한 제한
|
|
||||||
- 번호 무효화 감사 로그
|
|
||||||
- 시퀀스 직접 수정 방지
|
|
||||||
- API 호출 횟수 제한 (Rate Limiting)
|
|
||||||
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
# 리포트 페이지 관리 시스템 설계
|
|
||||||
|
|
||||||
## 1. 개요
|
|
||||||
|
|
||||||
리포트 디자이너에 다중 페이지 관리 기능을 추가하여 여러 페이지에 걸친 복잡한 문서를 작성할 수 있도록 합니다.
|
|
||||||
|
|
||||||
## 2. 주요 기능
|
|
||||||
|
|
||||||
### 2.1 페이지 관리
|
|
||||||
|
|
||||||
- 페이지 추가/삭제
|
|
||||||
- 페이지 복사
|
|
||||||
- 페이지 순서 변경 (드래그 앤 드롭)
|
|
||||||
- 페이지 이름 지정
|
|
||||||
|
|
||||||
### 2.2 페이지 네비게이션
|
|
||||||
|
|
||||||
- 좌측 페이지 썸네일 패널
|
|
||||||
- 페이지 간 전환 (클릭)
|
|
||||||
- 이전/다음 페이지 이동
|
|
||||||
- 페이지 번호 표시
|
|
||||||
|
|
||||||
### 2.3 페이지별 설정
|
|
||||||
|
|
||||||
- 페이지 크기 (A4, A3, Letter, 사용자 정의)
|
|
||||||
- 페이지 방향 (세로/가로)
|
|
||||||
- 여백 설정
|
|
||||||
- 배경색
|
|
||||||
|
|
||||||
### 2.4 컴포넌트 관리
|
|
||||||
|
|
||||||
- 컴포넌트는 특정 페이지에 속함
|
|
||||||
- 페이지 간 컴포넌트 복사/이동
|
|
||||||
- 현재 페이지의 컴포넌트만 표시
|
|
||||||
|
|
||||||
## 3. 데이터베이스 스키마
|
|
||||||
|
|
||||||
### 3.1 기존 구조 활용 (변경 없음)
|
|
||||||
|
|
||||||
**report_layout 테이블의 layout_config (JSONB) 활용**
|
|
||||||
|
|
||||||
기존:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"width": 210,
|
|
||||||
"height": 297,
|
|
||||||
"orientation": "portrait",
|
|
||||||
"components": [...]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
변경 후:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"pages": [
|
|
||||||
{
|
|
||||||
"page_id": "page-uuid-1",
|
|
||||||
"page_name": "표지",
|
|
||||||
"page_order": 0,
|
|
||||||
"width": 210,
|
|
||||||
"height": 297,
|
|
||||||
"orientation": "portrait",
|
|
||||||
"margins": {
|
|
||||||
"top": 20,
|
|
||||||
"bottom": 20,
|
|
||||||
"left": 20,
|
|
||||||
"right": 20
|
|
||||||
},
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"id": "comp-1",
|
|
||||||
"type": "text",
|
|
||||||
"x": 100,
|
|
||||||
"y": 50,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"page_id": "page-uuid-2",
|
|
||||||
"page_name": "본문",
|
|
||||||
"page_order": 1,
|
|
||||||
"width": 210,
|
|
||||||
"height": 297,
|
|
||||||
"orientation": "portrait",
|
|
||||||
"margins": { "top": 20, "bottom": 20, "left": 20, "right": 20 },
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"components": [...]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 마이그레이션 전략
|
|
||||||
|
|
||||||
기존 단일 페이지 리포트 자동 변환:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 기존 구조 감지 시
|
|
||||||
if (layoutConfig.components && !layoutConfig.pages) {
|
|
||||||
// 자동으로 pages 구조로 변환
|
|
||||||
layoutConfig = {
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
page_id: uuidv4(),
|
|
||||||
page_name: "페이지 1",
|
|
||||||
page_order: 0,
|
|
||||||
width: layoutConfig.width || 210,
|
|
||||||
height: layoutConfig.height || 297,
|
|
||||||
orientation: layoutConfig.orientation || "portrait",
|
|
||||||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
|
||||||
background_color: "#ffffff",
|
|
||||||
components: layoutConfig.components,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 프론트엔드 구조
|
|
||||||
|
|
||||||
### 4.1 타입 정의 (types/report.ts)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface ReportPage {
|
|
||||||
page_id: string;
|
|
||||||
report_id: string;
|
|
||||||
page_order: number;
|
|
||||||
page_name: string;
|
|
||||||
|
|
||||||
// 페이지 설정
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
orientation: 'portrait' | 'landscape';
|
|
||||||
|
|
||||||
// 여백
|
|
||||||
margin_top: number;
|
|
||||||
margin_bottom: number;
|
|
||||||
margin_left: number;
|
|
||||||
margin_right: number;
|
|
||||||
|
|
||||||
// 배경
|
|
||||||
background_color: string;
|
|
||||||
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComponentConfig {
|
|
||||||
id: string;
|
|
||||||
// page_id 불필요 (페이지의 components 배열에 포함됨)
|
|
||||||
type: 'text' | 'label' | 'image' | 'table' | ...;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
// ... 기타 속성
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReportLayoutConfig {
|
|
||||||
pages: ReportPage[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Context 구조 변경
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ReportDesignerContextType {
|
|
||||||
// 페이지 관리
|
|
||||||
pages: ReportPage[];
|
|
||||||
currentPageId: string | null;
|
|
||||||
currentPage: ReportPage | null;
|
|
||||||
|
|
||||||
addPage: () => void;
|
|
||||||
deletePage: (pageId: string) => void;
|
|
||||||
duplicatePage: (pageId: string) => void;
|
|
||||||
reorderPages: (sourceIndex: number, targetIndex: number) => void;
|
|
||||||
selectPage: (pageId: string) => void;
|
|
||||||
updatePage: (pageId: string, updates: Partial<ReportPage>) => void;
|
|
||||||
|
|
||||||
// 컴포넌트 (현재 페이지만)
|
|
||||||
currentPageComponents: ComponentConfig[];
|
|
||||||
|
|
||||||
// ... 기존 기능들
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 UI 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ ReportDesignerToolbar (저장, 미리보기, 페이지 추가 등) │
|
|
||||||
├──────────┬────────────────────────────────────┬─────────────┤
|
|
||||||
│ │ │ │
|
|
||||||
│ PageList │ ReportDesignerCanvas │ Right │
|
|
||||||
│ (좌측) │ (현재 페이지만 표시) │ Panel │
|
|
||||||
│ │ │ (속성) │
|
|
||||||
│ - Page 1 │ ┌──────────────────────────┐ │ │
|
|
||||||
│ - Page 2 │ │ │ │ │
|
|
||||||
│ * Page 3 │ │ [컴포넌트들] │ │ │
|
|
||||||
│ (현재) │ │ │ │ │
|
|
||||||
│ │ └──────────────────────────┘ │ │
|
|
||||||
│ [+ 추가] │ │ │
|
|
||||||
│ │ 이전 | 다음 (페이지 네비게이션) │ │
|
|
||||||
└──────────┴────────────────────────────────────┴─────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 컴포넌트 구조
|
|
||||||
|
|
||||||
### 5.1 새 컴포넌트
|
|
||||||
|
|
||||||
#### PageListPanel.tsx
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
- 좌측 페이지 목록 패널
|
|
||||||
- 페이지 썸네일 표시
|
|
||||||
- 드래그 앤 드롭으로 순서 변경
|
|
||||||
- 페이지 추가/삭제/복사 버튼
|
|
||||||
- 현재 페이지 하이라이트
|
|
||||||
```
|
|
||||||
|
|
||||||
#### PageNavigator.tsx
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
- 캔버스 하단의 페이지 네비게이션
|
|
||||||
- 이전/다음 버튼
|
|
||||||
- 현재 페이지 번호 표시
|
|
||||||
- 페이지 점프 (1/5 형식)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### PageSettingsPanel.tsx
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
- 우측 패널 내 페이지 설정 섹션
|
|
||||||
- 페이지 크기, 방향
|
|
||||||
- 여백 설정
|
|
||||||
- 배경색
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 수정할 컴포넌트
|
|
||||||
|
|
||||||
#### ReportDesignerContext.tsx
|
|
||||||
|
|
||||||
- pages 상태 추가
|
|
||||||
- currentPageId 상태 추가
|
|
||||||
- 페이지 관리 함수들 추가
|
|
||||||
- components를 currentPageComponents로 필터링
|
|
||||||
|
|
||||||
#### ReportDesignerCanvas.tsx
|
|
||||||
|
|
||||||
- currentPageComponents만 렌더링
|
|
||||||
- 캔버스 크기를 currentPage 기준으로 설정
|
|
||||||
- 컴포넌트 추가 시 page_id 포함
|
|
||||||
|
|
||||||
#### ReportDesignerToolbar.tsx
|
|
||||||
|
|
||||||
- "페이지 추가" 버튼 추가
|
|
||||||
- 저장 시 pages도 함께 저장
|
|
||||||
|
|
||||||
#### ReportPreviewModal.tsx
|
|
||||||
|
|
||||||
- 모든 페이지 순서대로 미리보기
|
|
||||||
- 페이지 구분선 표시
|
|
||||||
- PDF 저장 시 모든 페이지 포함
|
|
||||||
|
|
||||||
## 6. API 엔드포인트
|
|
||||||
|
|
||||||
### 6.1 페이지 관리
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 페이지 목록 조회
|
|
||||||
GET /api/report/:reportId/pages
|
|
||||||
Response: { pages: ReportPage[] }
|
|
||||||
|
|
||||||
// 페이지 생성
|
|
||||||
POST /api/report/:reportId/pages
|
|
||||||
Body: { page_name, width, height, orientation, margins }
|
|
||||||
Response: { page: ReportPage }
|
|
||||||
|
|
||||||
// 페이지 수정
|
|
||||||
PUT /api/report/pages/:pageId
|
|
||||||
Body: Partial<ReportPage>
|
|
||||||
Response: { page: ReportPage }
|
|
||||||
|
|
||||||
// 페이지 삭제
|
|
||||||
DELETE /api/report/pages/:pageId
|
|
||||||
Response: { success: boolean }
|
|
||||||
|
|
||||||
// 페이지 순서 변경
|
|
||||||
PUT /api/report/:reportId/pages/reorder
|
|
||||||
Body: { pageOrders: Array<{ page_id, page_order }> }
|
|
||||||
Response: { success: boolean }
|
|
||||||
|
|
||||||
// 페이지 복사
|
|
||||||
POST /api/report/pages/:pageId/duplicate
|
|
||||||
Response: { page: ReportPage }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 레이아웃 (기존 수정)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 레이아웃 저장 (페이지별)
|
|
||||||
PUT /api/report/:reportId/layout
|
|
||||||
Body: {
|
|
||||||
pages: ReportPage[],
|
|
||||||
components: ComponentConfig[] // page_id 포함
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 구현 단계
|
|
||||||
|
|
||||||
### Phase 1: DB 및 백엔드 (0.5일)
|
|
||||||
|
|
||||||
1. ✅ DB 스키마 생성
|
|
||||||
2. ✅ API 엔드포인트 구현
|
|
||||||
3. ✅ 기존 리포트 마이그레이션 (단일 페이지 생성)
|
|
||||||
|
|
||||||
### Phase 2: 타입 및 Context (0.5일)
|
|
||||||
|
|
||||||
1. ✅ 타입 정의 업데이트
|
|
||||||
2. ✅ Context에 페이지 상태/함수 추가
|
|
||||||
3. ✅ API 연동
|
|
||||||
|
|
||||||
### Phase 3: UI 컴포넌트 (1일)
|
|
||||||
|
|
||||||
1. ✅ PageListPanel 구현
|
|
||||||
2. ✅ PageNavigator 구현
|
|
||||||
3. ✅ PageSettingsPanel 구현
|
|
||||||
|
|
||||||
### Phase 4: 통합 및 수정 (1일)
|
|
||||||
|
|
||||||
1. ✅ Canvas에서 현재 페이지만 표시
|
|
||||||
2. ✅ 컴포넌트 추가/수정 시 page_id 처리
|
|
||||||
3. ✅ 미리보기에서 모든 페이지 표시
|
|
||||||
4. ✅ PDF/WORD 저장에서 모든 페이지 처리
|
|
||||||
|
|
||||||
### Phase 5: 테스트 및 최적화 (0.5일)
|
|
||||||
|
|
||||||
1. ✅ 페이지 전환 성능 확인
|
|
||||||
2. ✅ 썸네일 렌더링 최적화
|
|
||||||
3. ✅ 버그 수정
|
|
||||||
|
|
||||||
**총 예상 기간: 3-4일**
|
|
||||||
|
|
||||||
## 8. 주의사항
|
|
||||||
|
|
||||||
### 8.1 성능 최적화
|
|
||||||
|
|
||||||
- 페이지 썸네일은 저해상도로 렌더링
|
|
||||||
- 현재 페이지 컴포넌트만 DOM에 유지
|
|
||||||
- 페이지 전환 시 애니메이션 최소화
|
|
||||||
|
|
||||||
### 8.2 호환성
|
|
||||||
|
|
||||||
- 기존 리포트는 자동으로 단일 페이지로 마이그레이션
|
|
||||||
- 템플릿도 페이지 구조 포함
|
|
||||||
|
|
||||||
### 8.3 사용자 경험
|
|
||||||
|
|
||||||
- 페이지 삭제 시 확인 다이얼로그
|
|
||||||
- 컴포넌트가 있는 페이지 삭제 시 경고
|
|
||||||
- 페이지 순서 변경 시 즉시 반영
|
|
||||||
|
|
||||||
## 9. 추후 확장 기능
|
|
||||||
|
|
||||||
### 9.1 페이지 템플릿
|
|
||||||
|
|
||||||
- 자주 사용하는 페이지 레이아웃 저장
|
|
||||||
- 페이지 추가 시 템플릿 선택
|
|
||||||
|
|
||||||
### 9.2 마스터 페이지
|
|
||||||
|
|
||||||
- 모든 페이지에 공통으로 적용되는 헤더/푸터
|
|
||||||
- 페이지 번호 자동 삽입
|
|
||||||
|
|
||||||
### 9.3 페이지 연결
|
|
||||||
|
|
||||||
- 테이블 데이터가 여러 페이지에 자동 분할
|
|
||||||
- 페이지 오버플로우 처리
|
|
||||||
|
|
||||||
## 10. 참고 자료
|
|
||||||
|
|
||||||
- 오즈리포트 메뉴얼
|
|
||||||
- Crystal Reports 페이지 관리
|
|
||||||
- Adobe InDesign 페이지 시스템
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
# Modal Repeater Table 디버깅 가이드
|
|
||||||
|
|
||||||
## 📊 콘솔 로그 확인 순서
|
|
||||||
|
|
||||||
새로고침 후 수주 등록 모달을 열고, 아래 순서대로 콘솔 로그를 확인하세요:
|
|
||||||
|
|
||||||
### 1️⃣ 컴포넌트 마운트 (초기 로드)
|
|
||||||
|
|
||||||
```
|
|
||||||
🎬 ModalRepeaterTableComponent 마운트: {
|
|
||||||
config: {...},
|
|
||||||
propColumns: [...],
|
|
||||||
columns: [...],
|
|
||||||
columnsLength: N, // ⚠️ 0이면 문제!
|
|
||||||
value: [],
|
|
||||||
valueLength: 0,
|
|
||||||
sourceTable: "item_info",
|
|
||||||
sourceColumns: [...],
|
|
||||||
uniqueField: "item_number"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ 정상:**
|
|
||||||
- `columnsLength: 8` (품번, 품명, 규격, 재질, 수량, 단가, 금액, 납기일)
|
|
||||||
- `columns` 배열에 각 컬럼의 `field`, `label`, `type` 정보가 있어야 함
|
|
||||||
|
|
||||||
**❌ 문제:**
|
|
||||||
- `columnsLength: 0` → **이것이 문제의 원인!**
|
|
||||||
- 빈 배열이면 테이블에 컬럼이 표시되지 않음
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2️⃣ 항목 검색 모달 열림
|
|
||||||
|
|
||||||
```
|
|
||||||
🚪 모달 열림 - uniqueField: "item_number", multiSelect: true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3️⃣ 품목 체크 (선택)
|
|
||||||
|
|
||||||
```
|
|
||||||
🖱️ 행 클릭: {
|
|
||||||
item: { item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... },
|
|
||||||
uniqueField: "item_number",
|
|
||||||
itemValue: "SLI-2025-0003",
|
|
||||||
currentSelected: 0,
|
|
||||||
selectedValues: []
|
|
||||||
}
|
|
||||||
|
|
||||||
✅ 매칭 발견: { selectedValue: "SLI-2025-0003", itemValue: "SLI-2025-0003", uniqueField: "item_number" }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4️⃣ 추가 버튼 클릭
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ ItemSelectionModal 추가 버튼 클릭: {
|
|
||||||
selectedCount: 1,
|
|
||||||
selectedItems: [{ item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... }],
|
|
||||||
uniqueField: "item_number"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5️⃣ 데이터 추가 처리
|
|
||||||
|
|
||||||
```
|
|
||||||
➕ handleAddItems 호출: {
|
|
||||||
selectedItems: [{ item_number: "SLI-2025-0003", ... }],
|
|
||||||
currentValue: [],
|
|
||||||
columns: [...], // ⚠️ 여기도 확인!
|
|
||||||
calculationRules: [...]
|
|
||||||
}
|
|
||||||
|
|
||||||
📝 기본값 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, ... }]
|
|
||||||
|
|
||||||
🔢 계산 필드 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }]
|
|
||||||
|
|
||||||
✅ 최종 데이터 (onChange 호출): [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6️⃣ Renderer 업데이트
|
|
||||||
|
|
||||||
```
|
|
||||||
🔄 ModalRepeaterTableRenderer onChange 호출: {
|
|
||||||
previousValue: [],
|
|
||||||
newValue: [{ item_number: "SLI-2025-0003", ... }]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7️⃣ value 변경 감지
|
|
||||||
|
|
||||||
```
|
|
||||||
📦 ModalRepeaterTableComponent value 변경: {
|
|
||||||
valueLength: 1,
|
|
||||||
value: [{ item_number: "SLI-2025-0003", ... }],
|
|
||||||
columns: [...] // ⚠️ 여기도 확인!
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8️⃣ 테이블 리렌더링
|
|
||||||
|
|
||||||
```
|
|
||||||
📊 RepeaterTable 데이터 업데이트: {
|
|
||||||
rowCount: 1,
|
|
||||||
data: [{ item_number: "SLI-2025-0003", ... }],
|
|
||||||
columns: ["item_number", "item_name", "specification", "material", "quantity", "selling_price", "amount", "delivery_date"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 문제 진단
|
|
||||||
|
|
||||||
### Case 1: columns가 비어있음 (columnsLength: 0)
|
|
||||||
|
|
||||||
**원인:**
|
|
||||||
- 화면관리 시스템에서 modal-repeater-table 컴포넌트의 `columns` 설정을 하지 않음
|
|
||||||
- DB에 컬럼 설정이 저장되지 않음
|
|
||||||
|
|
||||||
**해결:**
|
|
||||||
1. 화면 관리 페이지로 이동
|
|
||||||
2. 해당 화면 편집
|
|
||||||
3. modal-repeater-table 컴포넌트 선택
|
|
||||||
4. 우측 설정 패널에서 "컬럼 설정" 탭 열기
|
|
||||||
5. 다음 컬럼들을 추가:
|
|
||||||
- 품번 (item_number, text, 편집불가)
|
|
||||||
- 품명 (item_name, text, 편집불가)
|
|
||||||
- 규격 (specification, text, 편집불가)
|
|
||||||
- 재질 (material, text, 편집불가)
|
|
||||||
- 수량 (quantity, number, 편집가능, 기본값: 1)
|
|
||||||
- 단가 (selling_price, number, 편집가능)
|
|
||||||
- 금액 (amount, number, 편집불가, 계산필드)
|
|
||||||
- 납기일 (delivery_date, date, 편집가능)
|
|
||||||
6. 저장
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Case 2: 로그가 8번까지 나오는데 화면에 안 보임
|
|
||||||
|
|
||||||
**원인:**
|
|
||||||
- React 리렌더링 문제
|
|
||||||
- 화면관리 시스템의 상태 동기화 문제
|
|
||||||
|
|
||||||
**해결:**
|
|
||||||
1. 브라우저 개발자 도구 → Elements 탭
|
|
||||||
2. `#component-comp_5jdmuzai .border.rounded-md table tbody` 찾기
|
|
||||||
3. 실제 DOM에 `<tr>` 요소가 추가되었는지 확인
|
|
||||||
4. 추가되었다면 CSS 문제 (display: none 등)
|
|
||||||
5. 추가 안 되었다면 컴포넌트 렌더링 문제
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Case 3: 로그가 5번까지만 나오고 멈춤
|
|
||||||
|
|
||||||
**원인:**
|
|
||||||
- `onChange` 콜백이 제대로 전달되지 않음
|
|
||||||
- Renderer의 `updateComponent`가 작동하지 않음
|
|
||||||
|
|
||||||
**해결:**
|
|
||||||
- 이미 수정한 `ModalRepeaterTableRenderer.tsx` 코드 확인
|
|
||||||
- `handleChange` 함수가 호출되는지 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 다음 단계
|
|
||||||
|
|
||||||
위 로그를 **모두** 복사해서 공유해주세요. 특히:
|
|
||||||
|
|
||||||
1. **🎬 마운트 로그의 `columnsLength` 값**
|
|
||||||
2. **로그가 어디까지 출력되는지**
|
|
||||||
3. **Elements 탭에서 `tbody` 내부 HTML 구조**
|
|
||||||
|
|
||||||
이 정보로 정확한 문제를 진단할 수 있습니다!
|
|
||||||
|
|
||||||
|
|
@ -2,29 +2,23 @@
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
const VehicleReport = dynamic(
|
const VehicleReport = dynamic(() => import("@/components/vehicle/VehicleReport"), {
|
||||||
() => import("@/components/vehicle/VehicleReport"),
|
|
||||||
{
|
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="text-muted-foreground">로딩 중...</div>
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export default function VehicleReportsPage() {
|
export default function VehicleReportsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-6">
|
<div className="container mx-auto py-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold">운행 리포트</h1>
|
<h1 className="text-2xl font-bold">운행 리포트</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">차량 운행 통계 및 분석 리포트를 확인합니다.</p>
|
||||||
차량 운행 통계 및 분석 리포트를 확인합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<VehicleReport />
|
<VehicleReport />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,21 @@
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
const VehicleTripHistory = dynamic(
|
const VehicleTripHistory = dynamic(() => import("@/components/vehicle/VehicleTripHistory"), {
|
||||||
() => import("@/components/vehicle/VehicleTripHistory"),
|
|
||||||
{
|
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="text-muted-foreground">로딩 중...</div>
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export default function VehicleTripsPage() {
|
export default function VehicleTripsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-6">
|
<div className="container mx-auto py-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold">운행 이력 관리</h1>
|
<h1 className="text-2xl font-bold">운행 이력 관리</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">차량 운행 이력을 조회하고 관리합니다.</p>
|
||||||
차량 운행 이력을 조회하고 관리합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<VehicleTripHistory />
|
<VehicleTripHistory />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,3 @@ export default function DynamicAdminPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,7 @@ import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import { BatchAPI, BatchMapping, ConnectionInfo, ColumnInfo, BatchMappingRequest } from "@/lib/api/batch";
|
||||||
BatchAPI,
|
|
||||||
BatchMapping,
|
|
||||||
ConnectionInfo,
|
|
||||||
ColumnInfo,
|
|
||||||
BatchMappingRequest,
|
|
||||||
} from "@/lib/api/batch";
|
|
||||||
|
|
||||||
export default function BatchCreatePage() {
|
export default function BatchCreatePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -68,11 +62,11 @@ export default function BatchCreatePage() {
|
||||||
|
|
||||||
// FROM 커넥션 변경
|
// FROM 커넥션 변경
|
||||||
const handleFromConnectionChange = async (connectionId: string) => {
|
const handleFromConnectionChange = async (connectionId: string) => {
|
||||||
if (connectionId === 'unknown') return;
|
if (connectionId === "unknown") return;
|
||||||
|
|
||||||
const connection = connections.find(conn => {
|
const connection = connections.find((conn) => {
|
||||||
if (conn.type === 'internal') {
|
if (conn.type === "internal") {
|
||||||
return connectionId === 'internal';
|
return connectionId === "internal";
|
||||||
}
|
}
|
||||||
return conn.id ? conn.id.toString() === connectionId : false;
|
return conn.id ? conn.id.toString() === connectionId : false;
|
||||||
});
|
});
|
||||||
|
|
@ -96,11 +90,11 @@ export default function BatchCreatePage() {
|
||||||
|
|
||||||
// TO 커넥션 변경
|
// TO 커넥션 변경
|
||||||
const handleToConnectionChange = async (connectionId: string) => {
|
const handleToConnectionChange = async (connectionId: string) => {
|
||||||
if (connectionId === 'unknown') return;
|
if (connectionId === "unknown") return;
|
||||||
|
|
||||||
const connection = connections.find(conn => {
|
const connection = connections.find((conn) => {
|
||||||
if (conn.type === 'internal') {
|
if (conn.type === "internal") {
|
||||||
return connectionId === 'internal';
|
return connectionId === "internal";
|
||||||
}
|
}
|
||||||
return conn.id ? conn.id.toString() === connectionId : false;
|
return conn.id ? conn.id.toString() === connectionId : false;
|
||||||
});
|
});
|
||||||
|
|
@ -168,9 +162,9 @@ export default function BatchCreatePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// n:1 매핑 검사
|
// n:1 매핑 검사
|
||||||
const toKey = `${toConnection.type}:${toConnection.id || 'internal'}:${toTable}:${toColumn.column_name}`;
|
const toKey = `${toConnection.type}:${toConnection.id || "internal"}:${toTable}:${toColumn.column_name}`;
|
||||||
const existingMapping = mappings.find(mapping => {
|
const existingMapping = mappings.find((mapping) => {
|
||||||
const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`;
|
const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || "internal"}:${mapping.to_table_name}:${mapping.to_column_name}`;
|
||||||
return existingToKey === toKey;
|
return existingToKey === toKey;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -184,12 +178,12 @@ export default function BatchCreatePage() {
|
||||||
from_connection_id: fromConnection.id || null,
|
from_connection_id: fromConnection.id || null,
|
||||||
from_table_name: fromTable,
|
from_table_name: fromTable,
|
||||||
from_column_name: selectedFromColumn.column_name,
|
from_column_name: selectedFromColumn.column_name,
|
||||||
from_column_type: selectedFromColumn.data_type || '',
|
from_column_type: selectedFromColumn.data_type || "",
|
||||||
to_connection_type: toConnection.type,
|
to_connection_type: toConnection.type,
|
||||||
to_connection_id: toConnection.id || null,
|
to_connection_id: toConnection.id || null,
|
||||||
to_table_name: toTable,
|
to_table_name: toTable,
|
||||||
to_column_name: toColumn.column_name,
|
to_column_name: toColumn.column_name,
|
||||||
to_column_type: toColumn.data_type || '',
|
to_column_type: toColumn.data_type || "",
|
||||||
mapping_order: mappings.length + 1,
|
mapping_order: mappings.length + 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -203,7 +197,7 @@ export default function BatchCreatePage() {
|
||||||
const newMappings = mappings.filter((_, i) => i !== index);
|
const newMappings = mappings.filter((_, i) => i !== index);
|
||||||
const reorderedMappings = newMappings.map((mapping, i) => ({
|
const reorderedMappings = newMappings.map((mapping, i) => ({
|
||||||
...mapping,
|
...mapping,
|
||||||
mapping_order: i + 1
|
mapping_order: i + 1,
|
||||||
}));
|
}));
|
||||||
setMappings(reorderedMappings);
|
setMappings(reorderedMappings);
|
||||||
toast.success("매핑이 삭제되었습니다.");
|
toast.success("매핑이 삭제되었습니다.");
|
||||||
|
|
@ -233,7 +227,7 @@ export default function BatchCreatePage() {
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
cronSchedule: cronSchedule,
|
cronSchedule: cronSchedule,
|
||||||
mappings: mappings,
|
mappings: mappings,
|
||||||
isActive: true
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await BatchAPI.createBatchConfig(request);
|
await BatchAPI.createBatchConfig(request);
|
||||||
|
|
@ -250,7 +244,7 @@ export default function BatchCreatePage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="container mx-auto space-y-6 p-6">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
|
|
@ -275,7 +269,7 @@ export default function BatchCreatePage() {
|
||||||
<CardTitle>기본 정보</CardTitle>
|
<CardTitle>기본 정보</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="batchName">배치명 *</Label>
|
<Label htmlFor="batchName">배치명 *</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -309,7 +303,7 @@ export default function BatchCreatePage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 매핑 설정 */}
|
{/* 매핑 설정 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
{/* FROM 섹션 */}
|
{/* FROM 섹션 */}
|
||||||
<Card className="border-green-200">
|
<Card className="border-green-200">
|
||||||
<CardHeader className="bg-green-50">
|
<CardHeader className="bg-green-50">
|
||||||
|
|
@ -322,7 +316,7 @@ export default function BatchCreatePage() {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>커넥션 선택</Label>
|
<Label>커넥션 선택</Label>
|
||||||
<Select
|
<Select
|
||||||
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ""}
|
value={fromConnection?.type === "internal" ? "internal" : fromConnection?.id?.toString() || ""}
|
||||||
onValueChange={handleFromConnectionChange}
|
onValueChange={handleFromConnectionChange}
|
||||||
disabled={loadingConnections}
|
disabled={loadingConnections}
|
||||||
>
|
>
|
||||||
|
|
@ -330,10 +324,11 @@ export default function BatchCreatePage() {
|
||||||
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Array.isArray(connections) && connections.map((conn) => (
|
{Array.isArray(connections) &&
|
||||||
|
connections.map((conn) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
|
key={conn.type === "internal" ? "internal" : conn.id?.toString() || conn.name}
|
||||||
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
|
value={conn.type === "internal" ? "internal" : conn.id?.toString() || "unknown"}
|
||||||
>
|
>
|
||||||
{conn.name} ({conn.type})
|
{conn.name} ({conn.type})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -344,11 +339,7 @@ export default function BatchCreatePage() {
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>테이블 선택</Label>
|
<Label>테이블 선택</Label>
|
||||||
<Select
|
<Select value={fromTable} onValueChange={handleFromTableChange} disabled={!fromConnection}>
|
||||||
value={fromTable}
|
|
||||||
onValueChange={handleFromTableChange}
|
|
||||||
disabled={!fromConnection}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="테이블을 선택하세요" />
|
<SelectValue placeholder="테이블을 선택하세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -365,16 +356,16 @@ export default function BatchCreatePage() {
|
||||||
{/* FROM 컬럼 목록 */}
|
{/* FROM 컬럼 목록 */}
|
||||||
{fromTable && (
|
{fromTable && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-blue-600 font-semibold">{fromTable} 테이블</Label>
|
<Label className="font-semibold text-blue-600">{fromTable} 테이블</Label>
|
||||||
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
<div className="max-h-80 space-y-2 overflow-y-auto rounded-lg border p-4">
|
||||||
{fromColumns.map((column) => (
|
{fromColumns.map((column) => (
|
||||||
<div
|
<div
|
||||||
key={column.column_name}
|
key={column.column_name}
|
||||||
onClick={() => handleFromColumnClick(column)}
|
onClick={() => handleFromColumnClick(column)}
|
||||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
className={`cursor-pointer rounded border p-3 transition-colors ${
|
||||||
selectedFromColumn?.column_name === column.column_name
|
selectedFromColumn?.column_name === column.column_name
|
||||||
? 'bg-green-100 border-green-300'
|
? "border-green-300 bg-green-100"
|
||||||
: 'hover:bg-gray-50 border-gray-200'
|
: "border-gray-200 hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium">{column.column_name}</div>
|
<div className="font-medium">{column.column_name}</div>
|
||||||
|
|
@ -382,9 +373,7 @@ export default function BatchCreatePage() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{fromColumns.length === 0 && fromTable && (
|
{fromColumns.length === 0 && fromTable && (
|
||||||
<div className="text-center text-gray-500 py-4">
|
<div className="py-4 text-center text-gray-500">컬럼을 불러오는 중...</div>
|
||||||
컬럼을 불러오는 중...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -396,15 +385,13 @@ export default function BatchCreatePage() {
|
||||||
<Card className="border-red-200">
|
<Card className="border-red-200">
|
||||||
<CardHeader className="bg-red-50">
|
<CardHeader className="bg-red-50">
|
||||||
<CardTitle className="text-red-700">TO (대상 데이터베이스)</CardTitle>
|
<CardTitle className="text-red-700">TO (대상 데이터베이스)</CardTitle>
|
||||||
<p className="text-sm text-red-600">
|
<p className="text-sm text-red-600">FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다</p>
|
||||||
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>커넥션 선택</Label>
|
<Label>커넥션 선택</Label>
|
||||||
<Select
|
<Select
|
||||||
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ""}
|
value={toConnection?.type === "internal" ? "internal" : toConnection?.id?.toString() || ""}
|
||||||
onValueChange={handleToConnectionChange}
|
onValueChange={handleToConnectionChange}
|
||||||
disabled={loadingConnections}
|
disabled={loadingConnections}
|
||||||
>
|
>
|
||||||
|
|
@ -412,10 +399,11 @@ export default function BatchCreatePage() {
|
||||||
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Array.isArray(connections) && connections.map((conn) => (
|
{Array.isArray(connections) &&
|
||||||
|
connections.map((conn) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
|
key={conn.type === "internal" ? "internal" : conn.id?.toString() || conn.name}
|
||||||
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
|
value={conn.type === "internal" ? "internal" : conn.id?.toString() || "unknown"}
|
||||||
>
|
>
|
||||||
{conn.name} ({conn.type})
|
{conn.name} ({conn.type})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -426,11 +414,7 @@ export default function BatchCreatePage() {
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>테이블 선택</Label>
|
<Label>테이블 선택</Label>
|
||||||
<Select
|
<Select value={toTable} onValueChange={handleToTableChange} disabled={!toConnection}>
|
||||||
value={toTable}
|
|
||||||
onValueChange={handleToTableChange}
|
|
||||||
disabled={!toConnection}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="테이블을 선택하세요" />
|
<SelectValue placeholder="테이블을 선택하세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -447,16 +431,16 @@ export default function BatchCreatePage() {
|
||||||
{/* TO 컬럼 목록 */}
|
{/* TO 컬럼 목록 */}
|
||||||
{toTable && (
|
{toTable && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-blue-600 font-semibold">{toTable} 테이블</Label>
|
<Label className="font-semibold text-blue-600">{toTable} 테이블</Label>
|
||||||
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
<div className="max-h-80 space-y-2 overflow-y-auto rounded-lg border p-4">
|
||||||
{toColumns.map((column) => (
|
{toColumns.map((column) => (
|
||||||
<div
|
<div
|
||||||
key={column.column_name}
|
key={column.column_name}
|
||||||
onClick={() => handleToColumnClick(column)}
|
onClick={() => handleToColumnClick(column)}
|
||||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
className={`cursor-pointer rounded border p-3 transition-colors ${
|
||||||
selectedFromColumn
|
selectedFromColumn
|
||||||
? 'hover:bg-red-50 border-gray-200'
|
? "border-gray-200 hover:bg-red-50"
|
||||||
: 'bg-gray-100 border-gray-300 cursor-not-allowed'
|
: "cursor-not-allowed border-gray-300 bg-gray-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium">{column.column_name}</div>
|
<div className="font-medium">{column.column_name}</div>
|
||||||
|
|
@ -464,9 +448,7 @@ export default function BatchCreatePage() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{toColumns.length === 0 && toTable && (
|
{toColumns.length === 0 && toTable && (
|
||||||
<div className="text-center text-gray-500 py-4">
|
<div className="py-4 text-center text-gray-500">컬럼을 불러오는 중...</div>
|
||||||
컬럼을 불러오는 중...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -484,24 +466,20 @@ export default function BatchCreatePage() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{mappings.map((mapping, index) => (
|
{mappings.map((mapping, index) => (
|
||||||
<div key={index} className="flex items-center justify-between p-4 border rounded-lg bg-yellow-50">
|
<div key={index} className="flex items-center justify-between rounded-lg border bg-yellow-50 p-4">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{mapping.from_table_name}.{mapping.from_column_name}
|
{mapping.from_table_name}.{mapping.from_column_name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">{mapping.from_column_type}</div>
|
||||||
{mapping.from_column_type}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{mapping.to_table_name}.{mapping.to_column_name}
|
{mapping.to_table_name}.{mapping.to_column_name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">{mapping.to_column_type}</div>
|
||||||
{mapping.to_column_type}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -521,10 +499,7 @@ export default function BatchCreatePage() {
|
||||||
|
|
||||||
{/* 저장 버튼 */}
|
{/* 저장 버튼 */}
|
||||||
<div className="flex justify-end space-x-4">
|
<div className="flex justify-end space-x-4">
|
||||||
<Button
|
<Button variant="outline" onClick={() => router.push("/admin/batchmng")}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/admin/batchmng")}
|
|
||||||
>
|
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -532,11 +507,7 @@ export default function BatchCreatePage() {
|
||||||
disabled={loading || mappings.length === 0}
|
disabled={loading || mappings.length === 0}
|
||||||
className="flex items-center space-x-2"
|
className="flex items-center space-x-2"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>{loading ? "저장 중..." : "배치 매핑 저장"}</span>
|
<span>{loading ? "저장 중..." : "배치 매핑 저장"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,12 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { BatchAPI, BatchConfig, BatchMapping, ConnectionInfo } from "@/lib/api/batch";
|
||||||
BatchAPI,
|
|
||||||
BatchConfig,
|
|
||||||
BatchMapping,
|
|
||||||
ConnectionInfo,
|
|
||||||
} from "@/lib/api/batch";
|
|
||||||
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
||||||
|
|
||||||
interface BatchColumnInfo {
|
interface BatchColumnInfo {
|
||||||
|
|
@ -33,16 +22,16 @@ interface BatchColumnInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배치 타입 감지 함수
|
// 배치 타입 감지 함수
|
||||||
const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' | 'db-to-restapi' => {
|
const detectBatchType = (mapping: BatchMapping): "db-to-db" | "restapi-to-db" | "db-to-restapi" => {
|
||||||
const fromType = mapping.from_connection_type;
|
const fromType = mapping.from_connection_type;
|
||||||
const toType = mapping.to_connection_type;
|
const toType = mapping.to_connection_type;
|
||||||
|
|
||||||
if (fromType === 'restapi' && (toType === 'internal' || toType === 'external')) {
|
if (fromType === "restapi" && (toType === "internal" || toType === "external")) {
|
||||||
return 'restapi-to-db';
|
return "restapi-to-db";
|
||||||
} else if ((fromType === 'internal' || fromType === 'external') && toType === 'restapi') {
|
} else if ((fromType === "internal" || fromType === "external") && toType === "restapi") {
|
||||||
return 'db-to-restapi';
|
return "db-to-restapi";
|
||||||
} else {
|
} else {
|
||||||
return 'db-to-db';
|
return "db-to-db";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -81,7 +70,7 @@ export default function BatchEditPage() {
|
||||||
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
||||||
|
|
||||||
// 배치 타입 감지
|
// 배치 타입 감지
|
||||||
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
|
const [batchType, setBatchType] = useState<"db-to-db" | "restapi-to-db" | "db-to-restapi" | null>(null);
|
||||||
|
|
||||||
// REST API 미리보기 상태
|
// REST API 미리보기 상태
|
||||||
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
||||||
|
|
@ -133,33 +122,35 @@ export default function BatchEditPage() {
|
||||||
console.log("🔗 연결 정보 설정 시작:", firstMapping);
|
console.log("🔗 연결 정보 설정 시작:", firstMapping);
|
||||||
|
|
||||||
// FROM 연결 정보 설정
|
// FROM 연결 정보 설정
|
||||||
if (firstMapping.from_connection_type === 'internal') {
|
if (firstMapping.from_connection_type === "internal") {
|
||||||
setFromConnection({ type: 'internal', name: '내부 DB' });
|
setFromConnection({ type: "internal", name: "내부 DB" });
|
||||||
// 내부 DB 테이블 목록 로드
|
// 내부 DB 테이블 목록 로드
|
||||||
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
|
BatchAPI.getTablesFromConnection({ type: "internal", name: "내부 DB" }).then((tables) => {
|
||||||
console.log("📋 FROM 테이블 목록:", tables);
|
console.log("📋 FROM 테이블 목록:", tables);
|
||||||
setFromTables(tables);
|
setFromTables(tables);
|
||||||
|
|
||||||
// 컬럼 정보도 로드
|
// 컬럼 정보도 로드
|
||||||
if (firstMapping.from_table_name) {
|
if (firstMapping.from_table_name) {
|
||||||
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.from_table_name).then(columns => {
|
BatchAPI.getTableColumns({ type: "internal", name: "내부 DB" }, firstMapping.from_table_name).then(
|
||||||
|
(columns) => {
|
||||||
console.log("📊 FROM 컬럼 목록:", columns);
|
console.log("📊 FROM 컬럼 목록:", columns);
|
||||||
setFromColumns(columns);
|
setFromColumns(columns);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (firstMapping.from_connection_id) {
|
} else if (firstMapping.from_connection_id) {
|
||||||
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
|
const fromConn = connections.find((c) => c.id === firstMapping.from_connection_id);
|
||||||
if (fromConn) {
|
if (fromConn) {
|
||||||
setFromConnection(fromConn);
|
setFromConnection(fromConn);
|
||||||
// 외부 DB 테이블 목록 로드
|
// 외부 DB 테이블 목록 로드
|
||||||
BatchAPI.getTablesFromConnection(fromConn).then(tables => {
|
BatchAPI.getTablesFromConnection(fromConn).then((tables) => {
|
||||||
console.log("📋 FROM 테이블 목록:", tables);
|
console.log("📋 FROM 테이블 목록:", tables);
|
||||||
setFromTables(tables);
|
setFromTables(tables);
|
||||||
|
|
||||||
// 컬럼 정보도 로드
|
// 컬럼 정보도 로드
|
||||||
if (firstMapping.from_table_name) {
|
if (firstMapping.from_table_name) {
|
||||||
BatchAPI.getTableColumns(fromConn, firstMapping.from_table_name).then(columns => {
|
BatchAPI.getTableColumns(fromConn, firstMapping.from_table_name).then((columns) => {
|
||||||
console.log("📊 FROM 컬럼 목록:", columns);
|
console.log("📊 FROM 컬럼 목록:", columns);
|
||||||
setFromColumns(columns);
|
setFromColumns(columns);
|
||||||
});
|
});
|
||||||
|
|
@ -169,33 +160,35 @@ export default function BatchEditPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TO 연결 정보 설정
|
// TO 연결 정보 설정
|
||||||
if (firstMapping.to_connection_type === 'internal') {
|
if (firstMapping.to_connection_type === "internal") {
|
||||||
setToConnection({ type: 'internal', name: '내부 DB' });
|
setToConnection({ type: "internal", name: "내부 DB" });
|
||||||
// 내부 DB 테이블 목록 로드
|
// 내부 DB 테이블 목록 로드
|
||||||
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
|
BatchAPI.getTablesFromConnection({ type: "internal", name: "내부 DB" }).then((tables) => {
|
||||||
console.log("📋 TO 테이블 목록:", tables);
|
console.log("📋 TO 테이블 목록:", tables);
|
||||||
setToTables(tables);
|
setToTables(tables);
|
||||||
|
|
||||||
// 컬럼 정보도 로드
|
// 컬럼 정보도 로드
|
||||||
if (firstMapping.to_table_name) {
|
if (firstMapping.to_table_name) {
|
||||||
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.to_table_name).then(columns => {
|
BatchAPI.getTableColumns({ type: "internal", name: "내부 DB" }, firstMapping.to_table_name).then(
|
||||||
|
(columns) => {
|
||||||
console.log("📊 TO 컬럼 목록:", columns);
|
console.log("📊 TO 컬럼 목록:", columns);
|
||||||
setToColumns(columns);
|
setToColumns(columns);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (firstMapping.to_connection_id) {
|
} else if (firstMapping.to_connection_id) {
|
||||||
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
|
const toConn = connections.find((c) => c.id === firstMapping.to_connection_id);
|
||||||
if (toConn) {
|
if (toConn) {
|
||||||
setToConnection(toConn);
|
setToConnection(toConn);
|
||||||
// 외부 DB 테이블 목록 로드
|
// 외부 DB 테이블 목록 로드
|
||||||
BatchAPI.getTablesFromConnection(toConn).then(tables => {
|
BatchAPI.getTablesFromConnection(toConn).then((tables) => {
|
||||||
console.log("📋 TO 테이블 목록:", tables);
|
console.log("📋 TO 테이블 목록:", tables);
|
||||||
setToTables(tables);
|
setToTables(tables);
|
||||||
|
|
||||||
// 컬럼 정보도 로드
|
// 컬럼 정보도 로드
|
||||||
if (firstMapping.to_table_name) {
|
if (firstMapping.to_table_name) {
|
||||||
BatchAPI.getTableColumns(toConn, firstMapping.to_table_name).then(columns => {
|
BatchAPI.getTableColumns(toConn, firstMapping.to_table_name).then((columns) => {
|
||||||
console.log("📊 TO 컬럼 목록:", columns);
|
console.log("📊 TO 컬럼 목록:", columns);
|
||||||
setToColumns(columns);
|
setToColumns(columns);
|
||||||
});
|
});
|
||||||
|
|
@ -244,7 +237,7 @@ export default function BatchEditPage() {
|
||||||
console.log(`📊 매핑 #${idx + 1}:`, {
|
console.log(`📊 매핑 #${idx + 1}:`, {
|
||||||
from: `${mapping.from_column_name} (${mapping.from_column_type})`,
|
from: `${mapping.from_column_name} (${mapping.from_column_type})`,
|
||||||
to: `${mapping.to_column_name} (${mapping.to_column_type})`,
|
to: `${mapping.to_column_name} (${mapping.to_column_type})`,
|
||||||
type: mapping.mapping_type
|
type: mapping.mapping_type,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
setMappings(config.batch_mappings);
|
setMappings(config.batch_mappings);
|
||||||
|
|
@ -260,12 +253,12 @@ export default function BatchEditPage() {
|
||||||
console.log("🎯 감지된 배치 타입:", detectedBatchType);
|
console.log("🎯 감지된 배치 타입:", detectedBatchType);
|
||||||
|
|
||||||
// FROM 연결 정보 설정
|
// FROM 연결 정보 설정
|
||||||
if (firstMapping.from_connection_type === 'internal') {
|
if (firstMapping.from_connection_type === "internal") {
|
||||||
setFromConnection({ type: 'internal', name: '내부 DB' });
|
setFromConnection({ type: "internal", name: "내부 DB" });
|
||||||
} else if (firstMapping.from_connection_id) {
|
} else if (firstMapping.from_connection_id) {
|
||||||
// 외부 연결은 connections 로드 후 설정
|
// 외부 연결은 connections 로드 후 설정
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
|
const fromConn = connections.find((c) => c.id === firstMapping.from_connection_id);
|
||||||
if (fromConn) {
|
if (fromConn) {
|
||||||
setFromConnection(fromConn);
|
setFromConnection(fromConn);
|
||||||
}
|
}
|
||||||
|
|
@ -273,12 +266,12 @@ export default function BatchEditPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TO 연결 정보 설정
|
// TO 연결 정보 설정
|
||||||
if (firstMapping.to_connection_type === 'internal') {
|
if (firstMapping.to_connection_type === "internal") {
|
||||||
setToConnection({ type: 'internal', name: '내부 DB' });
|
setToConnection({ type: "internal", name: "내부 DB" });
|
||||||
} else if (firstMapping.to_connection_id) {
|
} else if (firstMapping.to_connection_id) {
|
||||||
// 외부 연결은 connections 로드 후 설정
|
// 외부 연결은 connections 로드 후 설정
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
|
const toConn = connections.find((c) => c.id === firstMapping.to_connection_id);
|
||||||
if (toConn) {
|
if (toConn) {
|
||||||
setToConnection(toConn);
|
setToConnection(toConn);
|
||||||
}
|
}
|
||||||
|
|
@ -289,21 +282,20 @@ export default function BatchEditPage() {
|
||||||
fromTable: firstMapping.from_table_name,
|
fromTable: firstMapping.from_table_name,
|
||||||
toTable: firstMapping.to_table_name,
|
toTable: firstMapping.to_table_name,
|
||||||
fromConnectionType: firstMapping.from_connection_type,
|
fromConnectionType: firstMapping.from_connection_type,
|
||||||
toConnectionType: firstMapping.to_connection_type
|
toConnectionType: firstMapping.to_connection_type,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 기존 매핑을 mappingList로 변환
|
// 기존 매핑을 mappingList로 변환
|
||||||
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({
|
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({
|
||||||
id: `mapping-${index}-${Date.now()}`,
|
id: `mapping-${index}-${Date.now()}`,
|
||||||
dbColumn: mapping.to_column_name || "",
|
dbColumn: mapping.to_column_name || "",
|
||||||
sourceType: (mapping as any).mapping_type === "fixed" ? "fixed" as const : "api" as const,
|
sourceType: (mapping as any).mapping_type === "fixed" ? ("fixed" as const) : ("api" as const),
|
||||||
apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "",
|
apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "",
|
||||||
fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "",
|
fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "",
|
||||||
}));
|
}));
|
||||||
setMappingList(convertedMappingList);
|
setMappingList(convertedMappingList);
|
||||||
console.log("🔄 변환된 mappingList:", convertedMappingList);
|
console.log("🔄 변환된 mappingList:", convertedMappingList);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 배치 설정 조회 오류:", error);
|
console.error("❌ 배치 설정 조회 오류:", error);
|
||||||
toast.error("배치 설정을 불러오는데 실패했습니다.");
|
toast.error("배치 설정을 불러오는데 실패했습니다.");
|
||||||
|
|
@ -325,8 +317,9 @@ export default function BatchEditPage() {
|
||||||
|
|
||||||
// FROM 연결 변경 시
|
// FROM 연결 변경 시
|
||||||
const handleFromConnectionChange = async (connectionId: string) => {
|
const handleFromConnectionChange = async (connectionId: string) => {
|
||||||
const connection = connections.find(c => c.id?.toString() === connectionId) ||
|
const connection =
|
||||||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
|
connections.find((c) => c.id?.toString() === connectionId) ||
|
||||||
|
(connectionId === "internal" ? { type: "internal" as const, name: "내부 DB" } : null);
|
||||||
|
|
||||||
if (connection) {
|
if (connection) {
|
||||||
setFromConnection(connection);
|
setFromConnection(connection);
|
||||||
|
|
@ -345,8 +338,9 @@ export default function BatchEditPage() {
|
||||||
|
|
||||||
// TO 연결 변경 시
|
// TO 연결 변경 시
|
||||||
const handleToConnectionChange = async (connectionId: string) => {
|
const handleToConnectionChange = async (connectionId: string) => {
|
||||||
const connection = connections.find(c => c.id?.toString() === connectionId) ||
|
const connection =
|
||||||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
|
connections.find((c) => c.id?.toString() === connectionId) ||
|
||||||
|
(connectionId === "internal" ? { type: "internal" as const, name: "내부 DB" } : null);
|
||||||
|
|
||||||
if (connection) {
|
if (connection) {
|
||||||
setToConnection(connection);
|
setToConnection(connection);
|
||||||
|
|
@ -396,18 +390,18 @@ export default function BatchEditPage() {
|
||||||
// 매핑 추가
|
// 매핑 추가
|
||||||
const addMapping = () => {
|
const addMapping = () => {
|
||||||
const newMapping: BatchMapping = {
|
const newMapping: BatchMapping = {
|
||||||
from_connection_type: fromConnection?.type === 'internal' ? 'internal' : 'external',
|
from_connection_type: fromConnection?.type === "internal" ? "internal" : "external",
|
||||||
from_connection_id: fromConnection?.type === 'internal' ? undefined : fromConnection?.id,
|
from_connection_id: fromConnection?.type === "internal" ? undefined : fromConnection?.id,
|
||||||
from_table_name: fromTable,
|
from_table_name: fromTable,
|
||||||
from_column_name: '',
|
from_column_name: "",
|
||||||
from_column_type: '',
|
from_column_type: "",
|
||||||
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
|
to_connection_type: toConnection?.type === "internal" ? "internal" : "external",
|
||||||
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
|
to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id,
|
||||||
to_table_name: toTable,
|
to_table_name: toTable,
|
||||||
to_column_name: '',
|
to_column_name: "",
|
||||||
to_column_type: '',
|
to_column_type: "",
|
||||||
mapping_type: 'direct',
|
mapping_type: "direct",
|
||||||
mapping_order: mappings.length + 1
|
mapping_order: mappings.length + 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
setMappings([...mappings, newMapping]);
|
setMappings([...mappings, newMapping]);
|
||||||
|
|
@ -485,8 +479,7 @@ export default function BatchEditPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const method =
|
const method = (first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
|
||||||
(first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
|
|
||||||
|
|
||||||
const paramInfo =
|
const paramInfo =
|
||||||
apiParamType !== "none" && apiParamName && apiParamValue
|
apiParamType !== "none" && apiParamName && apiParamValue
|
||||||
|
|
@ -507,15 +500,13 @@ export default function BatchEditPage() {
|
||||||
paramInfo,
|
paramInfo,
|
||||||
first.from_api_body || undefined,
|
first.from_api_body || undefined,
|
||||||
authTokenMode === "db" ? authServiceName : undefined, // DB 선택 모드일 때 서비스명 전달
|
authTokenMode === "db" ? authServiceName : undefined, // DB 선택 모드일 때 서비스명 전달
|
||||||
dataArrayPath || undefined
|
dataArrayPath || undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
setApiPreviewData(result.samples || []);
|
setApiPreviewData(result.samples || []);
|
||||||
setFromApiFields(result.fields || []);
|
setFromApiFields(result.fields || []);
|
||||||
|
|
||||||
toast.success(
|
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`);
|
||||||
`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("REST API 미리보기 오류:", error);
|
console.error("REST API 미리보기 오류:", error);
|
||||||
toast.error(error?.message || "API 데이터 미리보기에 실패했습니다.");
|
toast.error(error?.message || "API 데이터 미리보기에 실패했습니다.");
|
||||||
|
|
@ -530,7 +521,7 @@ export default function BatchEditPage() {
|
||||||
|
|
||||||
// 매핑 업데이트
|
// 매핑 업데이트
|
||||||
const updateMapping = (index: number, field: keyof BatchMapping, value: any) => {
|
const updateMapping = (index: number, field: keyof BatchMapping, value: any) => {
|
||||||
setMappings(prevMappings => {
|
setMappings((prevMappings) => {
|
||||||
const updatedMappings = [...prevMappings];
|
const updatedMappings = [...prevMappings];
|
||||||
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
|
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
|
||||||
return updatedMappings;
|
return updatedMappings;
|
||||||
|
|
@ -588,12 +579,11 @@ export default function BatchEditPage() {
|
||||||
saveMode,
|
saveMode,
|
||||||
conflictKey: saveMode === "UPSERT" ? conflictKey : null, // INSERT면 null로 명시적 삭제
|
conflictKey: saveMode === "UPSERT" ? conflictKey : null, // INSERT면 null로 명시적 삭제
|
||||||
authServiceName: authTokenMode === "db" ? authServiceName : null, // 직접입력이면 null로 명시적 삭제
|
authServiceName: authTokenMode === "db" ? authServiceName : null, // 직접입력이면 null로 명시적 삭제
|
||||||
dataArrayPath: dataArrayPath || null
|
dataArrayPath: dataArrayPath || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
||||||
router.push("/admin/batchmng");
|
router.push("/admin/batchmng");
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 설정 수정 실패:", error);
|
console.error("배치 설정 수정 실패:", error);
|
||||||
toast.error("배치 설정 수정에 실패했습니다.");
|
toast.error("배치 설정 수정에 실패했습니다.");
|
||||||
|
|
@ -605,8 +595,8 @@ export default function BatchEditPage() {
|
||||||
if (loading && !batchConfig) {
|
if (loading && !batchConfig) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<RefreshCw className="w-8 h-8 animate-spin" />
|
<RefreshCw className="h-8 w-8 animate-spin" />
|
||||||
<span className="ml-2">배치 설정을 불러오는 중...</span>
|
<span className="ml-2">배치 설정을 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -617,11 +607,7 @@ export default function BatchEditPage() {
|
||||||
<div className="container mx-auto space-y-6 p-6">
|
<div className="container mx-auto space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center gap-4 border-b pb-4">
|
<div className="flex items-center gap-4 border-b pb-4">
|
||||||
<Button
|
<Button variant="outline" onClick={() => router.push("/admin/batchmng")} className="gap-2">
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/admin/batchmng")}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
목록으로
|
목록으로
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -699,11 +685,7 @@ export default function BatchEditPage() {
|
||||||
<div>
|
<div>
|
||||||
<Label>연결</Label>
|
<Label>연결</Label>
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={fromConnection?.type === "internal" ? "internal" : fromConnection?.id?.toString() || ""}
|
||||||
fromConnection?.type === "internal"
|
|
||||||
? "internal"
|
|
||||||
: fromConnection?.id?.toString() || ""
|
|
||||||
}
|
|
||||||
onValueChange={handleFromConnectionChange}
|
onValueChange={handleFromConnectionChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -954,9 +936,7 @@ export default function BatchEditPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label>
|
<Label>{apiParamSource === "static" ? "파라미터 값" : "파라미터 템플릿"} *</Label>
|
||||||
{apiParamSource === "static" ? "파라미터 값" : "파라미터 템플릿"} *
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
value={apiParamValue}
|
value={apiParamValue}
|
||||||
onChange={(e) => setApiParamValue(e.target.value)}
|
onChange={(e) => setApiParamValue(e.target.value)}
|
||||||
|
|
@ -1020,11 +1000,7 @@ export default function BatchEditPage() {
|
||||||
<div>
|
<div>
|
||||||
<Label>연결</Label>
|
<Label>연결</Label>
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={toConnection?.type === "internal" ? "internal" : toConnection?.id?.toString() || ""}
|
||||||
toConnection?.type === "internal"
|
|
||||||
? "internal"
|
|
||||||
: toConnection?.id?.toString() || ""
|
|
||||||
}
|
|
||||||
onValueChange={handleToConnectionChange}
|
onValueChange={handleToConnectionChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -1045,11 +1021,7 @@ export default function BatchEditPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label>테이블</Label>
|
<Label>테이블</Label>
|
||||||
<Select
|
<Select value={toTable} onValueChange={handleToTableChange} disabled={!toConnection}>
|
||||||
value={toTable}
|
|
||||||
onValueChange={handleToTableChange}
|
|
||||||
disabled={!toConnection}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="대상 테이블을 선택하세요" />
|
<SelectValue placeholder="대상 테이블을 선택하세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -1070,11 +1042,7 @@ export default function BatchEditPage() {
|
||||||
<div>
|
<div>
|
||||||
<Label>데이터베이스 연결 *</Label>
|
<Label>데이터베이스 연결 *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={toConnection?.type === "internal" ? "internal" : toConnection?.id?.toString() || ""}
|
||||||
toConnection?.type === "internal"
|
|
||||||
? "internal"
|
|
||||||
: toConnection?.id?.toString() || ""
|
|
||||||
}
|
|
||||||
onValueChange={handleToConnectionChange}
|
onValueChange={handleToConnectionChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -1095,11 +1063,7 @@ export default function BatchEditPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label>대상 테이블 *</Label>
|
<Label>대상 테이블 *</Label>
|
||||||
<Select
|
<Select value={toTable} onValueChange={handleToTableChange} disabled={!toConnection}>
|
||||||
value={toTable}
|
|
||||||
onValueChange={handleToTableChange}
|
|
||||||
disabled={!toConnection}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={toConnection ? "테이블을 선택하세요" : "먼저 연결을 선택하세요"} />
|
<SelectValue placeholder={toConnection ? "테이블을 선택하세요" : "먼저 연결을 선택하세요"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -1151,9 +1115,7 @@ export default function BatchEditPage() {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="INSERT">INSERT (항상 새로 추가)</SelectItem>
|
<SelectItem value="INSERT">INSERT (항상 새로 추가)</SelectItem>
|
||||||
<SelectItem value="UPSERT">
|
<SelectItem value="UPSERT">UPSERT (있으면 업데이트, 없으면 추가)</SelectItem>
|
||||||
UPSERT (있으면 업데이트, 없으면 추가)
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-muted-foreground mt-1.5 text-xs">
|
<p className="text-muted-foreground mt-1.5 text-xs">
|
||||||
|
|
@ -1184,9 +1146,7 @@ export default function BatchEditPage() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-muted-foreground mt-1.5 text-xs">
|
<p className="text-muted-foreground mt-1.5 text-xs">UPSERT 시 중복 여부를 판단할 컬럼을 선택하세요.</p>
|
||||||
UPSERT 시 중복 여부를 판단할 컬럼을 선택하세요.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -1195,11 +1155,7 @@ export default function BatchEditPage() {
|
||||||
{/* API 데이터 미리보기 버튼 */}
|
{/* API 데이터 미리보기 버튼 */}
|
||||||
{batchType === "restapi-to-db" && (
|
{batchType === "restapi-to-db" && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button
|
<Button variant="outline" onClick={previewRestApiData} disabled={mappings.length === 0}>
|
||||||
variant="outline"
|
|
||||||
onClick={previewRestApiData}
|
|
||||||
disabled={mappings.length === 0}
|
|
||||||
>
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
API 데이터 미리보기
|
API 데이터 미리보기
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1230,9 +1186,7 @@ export default function BatchEditPage() {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{apiPreviewData.slice(0, 3).map((item, index) => (
|
{apiPreviewData.slice(0, 3).map((item, index) => (
|
||||||
<div key={index} className="bg-background rounded border p-2">
|
<div key={index} className="bg-background rounded border p-2">
|
||||||
<pre className="whitespace-pre-wrap font-mono text-xs">
|
<pre className="font-mono text-xs whitespace-pre-wrap">{JSON.stringify(item, null, 2)}</pre>
|
||||||
{JSON.stringify(item, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1405,24 +1359,15 @@ export default function BatchEditPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
|
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
|
||||||
{mappings.map((mapping, index) => (
|
{mappings.map((mapping, index) => (
|
||||||
<div
|
<div key={index} className="bg-background flex items-center gap-2 rounded-lg border p-3">
|
||||||
key={index}
|
|
||||||
className="bg-background flex items-center gap-2 rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Select
|
<Select
|
||||||
value={mapping.from_column_name || ""}
|
value={mapping.from_column_name || ""}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateMapping(index, "from_column_name", value);
|
updateMapping(index, "from_column_name", value);
|
||||||
const selectedColumn = fromColumns.find(
|
const selectedColumn = fromColumns.find((col) => col.column_name === value);
|
||||||
(col) => col.column_name === value
|
|
||||||
);
|
|
||||||
if (selectedColumn) {
|
if (selectedColumn) {
|
||||||
updateMapping(
|
updateMapping(index, "from_column_type", selectedColumn.data_type);
|
||||||
index,
|
|
||||||
"from_column_type",
|
|
||||||
selectedColumn.data_type
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1431,10 +1376,7 @@ export default function BatchEditPage() {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{fromColumns.map((column) => (
|
{fromColumns.map((column) => (
|
||||||
<SelectItem
|
<SelectItem key={column.column_name} value={column.column_name}>
|
||||||
key={column.column_name}
|
|
||||||
value={column.column_name}
|
|
||||||
>
|
|
||||||
{column.column_name}
|
{column.column_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1447,15 +1389,9 @@ export default function BatchEditPage() {
|
||||||
value={mapping.to_column_name || ""}
|
value={mapping.to_column_name || ""}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateMapping(index, "to_column_name", value);
|
updateMapping(index, "to_column_name", value);
|
||||||
const selectedColumn = toColumns.find(
|
const selectedColumn = toColumns.find((col) => col.column_name === value);
|
||||||
(col) => col.column_name === value
|
|
||||||
);
|
|
||||||
if (selectedColumn) {
|
if (selectedColumn) {
|
||||||
updateMapping(
|
updateMapping(index, "to_column_type", selectedColumn.data_type);
|
||||||
index,
|
|
||||||
"to_column_type",
|
|
||||||
selectedColumn.data_type
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1464,22 +1400,14 @@ export default function BatchEditPage() {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{toColumns.map((column) => (
|
{toColumns.map((column) => (
|
||||||
<SelectItem
|
<SelectItem key={column.column_name} value={column.column_name}>
|
||||||
key={column.column_name}
|
|
||||||
value={column.column_name}
|
|
||||||
>
|
|
||||||
{column.column_name}
|
{column.column_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeMapping(index)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => removeMapping(index)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1499,24 +1427,13 @@ export default function BatchEditPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
|
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
|
||||||
{mappings.map((mapping, index) => (
|
{mappings.map((mapping, index) => (
|
||||||
<div
|
<div key={index} className="bg-background flex items-center gap-2 rounded-lg border p-3">
|
||||||
key={index}
|
|
||||||
className="bg-background flex items-center gap-2 rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<Input value={mapping.from_column_name || ""} readOnly className="h-9 text-xs" />
|
||||||
value={mapping.from_column_name || ""}
|
|
||||||
readOnly
|
|
||||||
className="h-9 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground text-xs">-></span>
|
<span className="text-muted-foreground text-xs">-></span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<Input value={mapping.to_column_name || ""} readOnly className="h-9 text-xs" />
|
||||||
value={mapping.to_column_name || ""}
|
|
||||||
readOnly
|
|
||||||
className="h-9 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1538,11 +1455,7 @@ export default function BatchEditPage() {
|
||||||
onClick={saveBatchConfig}
|
onClick={saveBatchConfig}
|
||||||
disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)}
|
disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{loading ? "저장 중..." : "배치 설정 저장"}
|
{loading ? "저장 중..." : "배치 설정 저장"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,11 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import { Plus, Search, RefreshCw, Database } from "lucide-react";
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
RefreshCw,
|
|
||||||
Database
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import { BatchAPI, BatchConfig, BatchMapping } from "@/lib/api/batch";
|
||||||
BatchAPI,
|
|
||||||
BatchConfig,
|
|
||||||
BatchMapping,
|
|
||||||
} from "@/lib/api/batch";
|
|
||||||
import BatchCard from "@/components/admin/BatchCard";
|
import BatchCard from "@/components/admin/BatchCard";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
|
|
@ -70,7 +61,9 @@ export default function BatchManagementPage() {
|
||||||
try {
|
try {
|
||||||
const response = await BatchAPI.executeBatchConfig(batchId);
|
const response = await BatchAPI.executeBatchConfig(batchId);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
|
toast.success(
|
||||||
|
`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error("배치 실행에 실패했습니다.");
|
toast.error("배치 실행에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -89,13 +82,13 @@ export default function BatchManagementPage() {
|
||||||
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
|
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
|
const newStatus = currentStatus === "Y" ? "N" : "Y";
|
||||||
console.log("📝 새로운 상태:", newStatus);
|
console.log("📝 새로운 상태:", newStatus);
|
||||||
|
|
||||||
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
|
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
|
||||||
console.log("✅ API 호출 성공:", result);
|
console.log("✅ API 호출 성공:", result);
|
||||||
|
|
||||||
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
|
toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`);
|
||||||
loadBatchConfigs(); // 목록 새로고침
|
loadBatchConfigs(); // 목록 새로고침
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 배치 상태 변경 실패:", error);
|
console.error("❌ 배치 상태 변경 실패:", error);
|
||||||
|
|
@ -132,14 +125,12 @@ export default function BatchManagementPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableGroups = new Map<string, number>();
|
const tableGroups = new Map<string, number>();
|
||||||
mappings.forEach(mapping => {
|
mappings.forEach((mapping) => {
|
||||||
const key = `${mapping.from_table_name} → ${mapping.to_table_name}`;
|
const key = `${mapping.from_table_name} → ${mapping.to_table_name}`;
|
||||||
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
|
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
|
const summaries = Array.from(tableGroups.entries()).map(([key, count]) => `${key} (${count}개 컬럼)`);
|
||||||
`${key} (${count}개 컬럼)`
|
|
||||||
);
|
|
||||||
|
|
||||||
return summaries.join(", ");
|
return summaries.join(", ");
|
||||||
};
|
};
|
||||||
|
|
@ -150,35 +141,35 @@ export default function BatchManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 배치 타입 선택 핸들러
|
// 배치 타입 선택 핸들러
|
||||||
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
|
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => {
|
||||||
console.log("배치 타입 선택:", type);
|
console.log("배치 타입 선택:", type);
|
||||||
setIsBatchTypeModalOpen(false);
|
setIsBatchTypeModalOpen(false);
|
||||||
|
|
||||||
if (type === 'db-to-db') {
|
if (type === "db-to-db") {
|
||||||
// 기존 DB → DB 배치 생성 페이지로 이동
|
// 기존 DB → DB 배치 생성 페이지로 이동
|
||||||
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
|
console.log("DB → DB 페이지로 이동:", "/admin/batchmng/create");
|
||||||
router.push('/admin/batchmng/create');
|
router.push("/admin/batchmng/create");
|
||||||
} else if (type === 'restapi-to-db') {
|
} else if (type === "restapi-to-db") {
|
||||||
// 새로운 REST API 배치 페이지로 이동
|
// 새로운 REST API 배치 페이지로 이동
|
||||||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
console.log("REST API → DB 페이지로 이동:", "/admin/batch-management-new");
|
||||||
try {
|
try {
|
||||||
router.push('/admin/batch-management-new');
|
router.push("/admin/batch-management-new");
|
||||||
console.log("라우터 push 실행 완료");
|
console.log("라우터 push 실행 완료");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("라우터 push 오류:", error);
|
console.error("라우터 push 오류:", error);
|
||||||
// 대안: window.location 사용
|
// 대안: window.location 사용
|
||||||
window.location.href = '/admin/batch-management-new';
|
window.location.href = "/admin/batch-management-new";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">배치 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">배치 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">데이터베이스 간 배치 작업을 관리합니다.</p>
|
<p className="text-muted-foreground text-sm">데이터베이스 간 배치 작업을 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 및 액션 영역 */}
|
{/* 검색 및 액션 영역 */}
|
||||||
|
|
@ -187,7 +178,7 @@ export default function BatchManagementPage() {
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
<div className="w-full sm:w-[400px]">
|
<div className="w-full sm:w-[400px]">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="배치명 또는 설명으로 검색..."
|
placeholder="배치명 또는 설명으로 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -203,24 +194,17 @@ export default function BatchManagementPage() {
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 액션 버튼 영역 */}
|
{/* 액션 버튼 영역 */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">
|
||||||
총{" "}
|
총 <span className="text-foreground font-semibold">{batchConfigs.length.toLocaleString()}</span> 건
|
||||||
<span className="font-semibold text-foreground">
|
|
||||||
{batchConfigs.length.toLocaleString()}
|
|
||||||
</span>{" "}
|
|
||||||
건
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button onClick={handleCreateBatch} className="h-10 gap-2 text-sm font-medium">
|
||||||
onClick={handleCreateBatch}
|
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
배치 추가
|
배치 추가
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -229,22 +213,18 @@ export default function BatchManagementPage() {
|
||||||
|
|
||||||
{/* 배치 목록 */}
|
{/* 배치 목록 */}
|
||||||
{batchConfigs.length === 0 ? (
|
{batchConfigs.length === 0 ? (
|
||||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
<Database className="h-12 w-12 text-muted-foreground" />
|
<Database className="text-muted-foreground h-12 w-12" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-lg font-semibold">배치가 없습니다</h3>
|
<h3 className="text-lg font-semibold">배치가 없습니다</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!searchTerm && (
|
{!searchTerm && (
|
||||||
<Button
|
<Button onClick={handleCreateBatch} className="h-10 gap-2 text-sm font-medium">
|
||||||
onClick={handleCreateBatch}
|
<Plus className="h-4 w-4" />첫 번째 배치 추가
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
첫 번째 배치 추가
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -273,7 +253,7 @@ export default function BatchManagementPage() {
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="h-10 text-sm font-medium"
|
className="h-10 text-sm font-medium"
|
||||||
>
|
>
|
||||||
|
|
@ -298,7 +278,7 @@ export default function BatchManagementPage() {
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="h-10 text-sm font-medium"
|
className="h-10 text-sm font-medium"
|
||||||
>
|
>
|
||||||
|
|
@ -309,41 +289,41 @@ export default function BatchManagementPage() {
|
||||||
|
|
||||||
{/* 배치 타입 선택 모달 */}
|
{/* 배치 타입 선택 모달 */}
|
||||||
{isBatchTypeModalOpen && (
|
{isBatchTypeModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
<div className="bg-background/80 fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||||
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
|
<div className="bg-card w-full max-w-2xl rounded-lg border p-6 shadow-lg">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-semibold text-center">배치 타입 선택</h2>
|
<h2 className="text-center text-xl font-semibold">배치 타입 선택</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{/* DB → DB */}
|
{/* DB → DB */}
|
||||||
<button
|
<button
|
||||||
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
className="bg-card hover:border-primary hover:bg-accent flex flex-col items-center gap-4 rounded-lg border p-6 shadow-sm transition-all"
|
||||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
onClick={() => handleBatchTypeSelect("db-to-db")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="h-8 w-8 text-primary" />
|
<Database className="text-primary h-8 w-8" />
|
||||||
<span className="text-muted-foreground">→</span>
|
<span className="text-muted-foreground">→</span>
|
||||||
<Database className="h-8 w-8 text-primary" />
|
<Database className="text-primary h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-center">
|
<div className="space-y-1 text-center">
|
||||||
<div className="text-lg font-medium">DB → DB</div>
|
<div className="text-lg font-medium">DB → DB</div>
|
||||||
<div className="text-sm text-muted-foreground">데이터베이스 간 데이터 동기화</div>
|
<div className="text-muted-foreground text-sm">데이터베이스 간 데이터 동기화</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* REST API → DB */}
|
{/* REST API → DB */}
|
||||||
<button
|
<button
|
||||||
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
className="bg-card hover:border-primary hover:bg-accent flex flex-col items-center gap-4 rounded-lg border p-6 shadow-sm transition-all"
|
||||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
onClick={() => handleBatchTypeSelect("restapi-to-db")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-2xl">🌐</span>
|
<span className="text-2xl">🌐</span>
|
||||||
<span className="text-muted-foreground">→</span>
|
<span className="text-muted-foreground">→</span>
|
||||||
<Database className="h-8 w-8 text-primary" />
|
<Database className="text-primary h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-center">
|
<div className="space-y-1 text-center">
|
||||||
<div className="text-lg font-medium">REST API → DB</div>
|
<div className="text-lg font-medium">REST API → DB</div>
|
||||||
<div className="text-sm text-muted-foreground">REST API에서 데이터베이스로 데이터 수집</div>
|
<div className="text-muted-foreground text-sm">REST API에서 데이터베이스로 데이터 수집</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -172,12 +172,12 @@ export default function ExternalCallConfigsPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">외부 호출 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">외부 호출 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">Discord, Slack, 카카오톡 등 외부 호출 설정을 관리합니다.</p>
|
<p className="text-muted-foreground text-sm">Discord, Slack, 카카오톡 등 외부 호출 설정을 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 및 필터 영역 */}
|
{/* 검색 및 필터 영역 */}
|
||||||
|
|
@ -187,7 +187,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<div className="w-full sm:w-[320px]">
|
<div className="w-full sm:w-[320px]">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="설정 이름 또는 설명으로 검색..."
|
placeholder="설정 이름 또는 설명으로 검색..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
|
|
@ -203,8 +203,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium">
|
<Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />새 외부 호출 추가
|
||||||
새 외부 호출 추가
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -278,25 +277,25 @@ export default function ExternalCallConfigsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 설정 목록 */}
|
{/* 설정 목록 */}
|
||||||
<div className="rounded-lg border bg-card shadow-sm">
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
// 로딩 상태
|
// 로딩 상태
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
) : configs.length === 0 ? (
|
) : configs.length === 0 ? (
|
||||||
// 빈 상태
|
// 빈 상태
|
||||||
<div className="flex h-64 flex-col items-center justify-center">
|
<div className="flex h-64 flex-col items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<p className="text-sm text-muted-foreground">등록된 외부 호출 설정이 없습니다.</p>
|
<p className="text-muted-foreground text-sm">등록된 외부 호출 설정이 없습니다.</p>
|
||||||
<p className="text-xs text-muted-foreground">새 외부 호출을 추가해보세요.</p>
|
<p className="text-muted-foreground text-xs">새 외부 호출을 추가해보세요.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 설정 테이블 목록
|
// 설정 테이블 목록
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
<TableHead className="h-12 text-sm font-semibold">설정명</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">설정명</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">호출 타입</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">호출 타입</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">API 타입</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">API 타입</TableHead>
|
||||||
|
|
@ -308,7 +307,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{configs.map((config) => (
|
{configs.map((config) => (
|
||||||
<TableRow key={config.id} className="border-b transition-colors hover:bg-muted/50">
|
<TableRow key={config.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||||
<TableCell className="h-16 text-sm font-medium">{config.config_name}</TableCell>
|
<TableCell className="h-16 text-sm font-medium">{config.config_name}</TableCell>
|
||||||
<TableCell className="h-16 text-sm">
|
<TableCell className="h-16 text-sm">
|
||||||
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge>
|
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge>
|
||||||
|
|
@ -323,7 +322,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
<TableCell className="h-16 text-sm">
|
<TableCell className="h-16 text-sm">
|
||||||
<div className="max-w-xs">
|
<div className="max-w-xs">
|
||||||
{config.description ? (
|
{config.description ? (
|
||||||
<span className="block truncate text-muted-foreground" title={config.description}>
|
<span className="text-muted-foreground block truncate" title={config.description}>
|
||||||
{config.description}
|
{config.description}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -336,7 +335,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
{config.is_active === "Y" ? "활성" : "비활성"}
|
{config.is_active === "Y" ? "활성" : "비활성"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 text-sm text-muted-foreground">
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
|
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 text-sm">
|
<TableCell className="h-16 text-sm">
|
||||||
|
|
@ -362,7 +361,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive h-8 w-8"
|
||||||
onClick={() => handleDeleteConfig(config)}
|
onClick={() => handleDeleteConfig(config)}
|
||||||
title="삭제"
|
title="삭제"
|
||||||
>
|
>
|
||||||
|
|
@ -396,12 +395,10 @@ export default function ExternalCallConfigsPage() {
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</AlertDialogCancel>
|
||||||
취소
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={confirmDeleteConfig}
|
onClick={confirmDeleteConfig}
|
||||||
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|
|
||||||
|
|
@ -227,12 +227,12 @@ export default function ExternalConnectionsPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">외부 커넥션 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">외부 커넥션 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
<p className="text-muted-foreground text-sm">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 */}
|
{/* 탭 */}
|
||||||
|
|
@ -255,7 +255,7 @@ export default function ExternalConnectionsPage() {
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="relative w-full sm:w-[300px]">
|
<div className="relative w-full sm:w-[300px]">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="연결명 또는 설명으로 검색..."
|
placeholder="연결명 또는 설명으로 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -295,20 +295,19 @@ export default function ExternalConnectionsPage() {
|
||||||
|
|
||||||
{/* 추가 버튼 */}
|
{/* 추가 버튼 */}
|
||||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />새 연결 추가
|
||||||
새 연결 추가
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 연결 목록 */}
|
{/* 연결 목록 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-64 items-center justify-center bg-card">
|
<div className="bg-card flex h-64 items-center justify-center">
|
||||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
) : connections.length === 0 ? (
|
) : connections.length === 0 ? (
|
||||||
<div className="flex h-64 flex-col items-center justify-center bg-card">
|
<div className="bg-card flex h-64 flex-col items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<p className="text-sm text-muted-foreground">등록된 연결이 없습니다</p>
|
<p className="text-muted-foreground text-sm">등록된 연결이 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -330,7 +329,7 @@ export default function ExternalConnectionsPage() {
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{connections.map((connection) => (
|
{connections.map((connection) => (
|
||||||
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50">
|
<TableRow key={connection.id} className="bg-background hover:bg-muted/50 transition-colors">
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<div className="font-medium">{connection.connection_name}</div>
|
<div className="font-medium">{connection.connection_name}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -338,9 +337,7 @@ export default function ExternalConnectionsPage() {
|
||||||
{(connection as any).company_name || connection.company_code}
|
{(connection as any).company_name || connection.company_code}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">{DB_TYPE_LABELS[connection.db_type] || connection.db_type}</Badge>
|
||||||
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
||||||
{connection.host}:{connection.port}
|
{connection.host}:{connection.port}
|
||||||
|
|
@ -400,7 +397,7 @@ export default function ExternalConnectionsPage() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleDeleteConnection(connection)}
|
onClick={() => handleDeleteConnection(connection)}
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
className="text-destructive hover:bg-destructive/10 h-8 w-8"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -431,8 +428,7 @@ export default function ExternalConnectionsPage() {
|
||||||
<AlertDialogTitle className="text-base sm:text-lg">연결 삭제 확인</AlertDialogTitle>
|
<AlertDialogTitle className="text-base sm:text-lg">연결 삭제 확인</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
"{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
|
"{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
|
||||||
<br />
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
이 작업은 되돌릴 수 없습니다.
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
|
|
@ -444,7 +440,7 @@ export default function ExternalConnectionsPage() {
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={confirmDeleteConnection}
|
onClick={confirmDeleteConnection}
|
||||||
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|
|
||||||
|
|
@ -232,11 +232,11 @@ export default function FlowManagementPage() {
|
||||||
|
|
||||||
// 다중 외부 DB 추가
|
// 다중 외부 DB 추가
|
||||||
const addExternalDbConfig = async (connectionId: number) => {
|
const addExternalDbConfig = async (connectionId: number) => {
|
||||||
const connection = externalConnections.find(c => c.id === connectionId);
|
const connection = externalConnections.find((c) => c.id === connectionId);
|
||||||
if (!connection) return;
|
if (!connection) return;
|
||||||
|
|
||||||
// 이미 추가된 경우 스킵
|
// 이미 추가된 경우 스킵
|
||||||
if (selectedExternalDbs.some(db => db.connectionId === connectionId)) {
|
if (selectedExternalDbs.some((db) => db.connectionId === connectionId)) {
|
||||||
toast({
|
toast({
|
||||||
title: "이미 추가됨",
|
title: "이미 추가됨",
|
||||||
description: "해당 외부 DB가 이미 추가되어 있습니다.",
|
description: "해당 외부 DB가 이미 추가되어 있습니다.",
|
||||||
|
|
@ -255,7 +255,7 @@ export default function FlowManagementPage() {
|
||||||
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
|
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
|
||||||
)
|
)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames }));
|
setMultiDbTableLists((prev) => ({ ...prev, [connectionId]: tableNames }));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||||
|
|
@ -274,23 +274,23 @@ export default function FlowManagementPage() {
|
||||||
|
|
||||||
// 다중 외부 DB 삭제
|
// 다중 외부 DB 삭제
|
||||||
const removeExternalDbConfig = (connectionId: number) => {
|
const removeExternalDbConfig = (connectionId: number) => {
|
||||||
setSelectedExternalDbs(selectedExternalDbs.filter(db => db.connectionId !== connectionId));
|
setSelectedExternalDbs(selectedExternalDbs.filter((db) => db.connectionId !== connectionId));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다중 외부 DB 설정 업데이트
|
// 다중 외부 DB 설정 업데이트
|
||||||
const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => {
|
const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => {
|
||||||
setSelectedExternalDbs(selectedExternalDbs.map(db =>
|
setSelectedExternalDbs(
|
||||||
db.connectionId === connectionId ? { ...db, [field]: value } : db
|
selectedExternalDbs.map((db) => (db.connectionId === connectionId ? { ...db, [field]: value } : db)),
|
||||||
));
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다중 REST API 추가
|
// 다중 REST API 추가
|
||||||
const addRestApiConfig = (connectionId: number) => {
|
const addRestApiConfig = (connectionId: number) => {
|
||||||
const connection = restApiConnections.find(c => c.id === connectionId);
|
const connection = restApiConnections.find((c) => c.id === connectionId);
|
||||||
if (!connection) return;
|
if (!connection) return;
|
||||||
|
|
||||||
// 이미 추가된 경우 스킵
|
// 이미 추가된 경우 스킵
|
||||||
if (selectedRestApis.some(api => api.connectionId === connectionId)) {
|
if (selectedRestApis.some((api) => api.connectionId === connectionId)) {
|
||||||
toast({
|
toast({
|
||||||
title: "이미 추가됨",
|
title: "이미 추가됨",
|
||||||
description: "해당 REST API가 이미 추가되어 있습니다.",
|
description: "해당 REST API가 이미 추가되어 있습니다.",
|
||||||
|
|
@ -313,14 +313,14 @@ export default function FlowManagementPage() {
|
||||||
|
|
||||||
// 다중 REST API 삭제
|
// 다중 REST API 삭제
|
||||||
const removeRestApiConfig = (connectionId: number) => {
|
const removeRestApiConfig = (connectionId: number) => {
|
||||||
setSelectedRestApis(selectedRestApis.filter(api => api.connectionId !== connectionId));
|
setSelectedRestApis(selectedRestApis.filter((api) => api.connectionId !== connectionId));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다중 REST API 설정 업데이트
|
// 다중 REST API 설정 업데이트
|
||||||
const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => {
|
const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => {
|
||||||
setSelectedRestApis(selectedRestApis.map(api =>
|
setSelectedRestApis(
|
||||||
api.connectionId === connectionId ? { ...api, [field]: value } : api
|
selectedRestApis.map((api) => (api.connectionId === connectionId ? { ...api, [field]: value } : api)),
|
||||||
));
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 플로우 생성
|
// 플로우 생성
|
||||||
|
|
@ -332,10 +332,15 @@ export default function FlowManagementPage() {
|
||||||
const isMultiMode = isMultiRestApi || isMultiExternalDb;
|
const isMultiMode = isMultiRestApi || isMultiExternalDb;
|
||||||
|
|
||||||
if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) {
|
if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) {
|
||||||
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode });
|
console.log("❌ Validation failed:", {
|
||||||
|
name: formData.name,
|
||||||
|
tableName: formData.tableName,
|
||||||
|
isRestApi,
|
||||||
|
isMultiMode,
|
||||||
|
});
|
||||||
toast({
|
toast({
|
||||||
title: "입력 오류",
|
title: "입력 오류",
|
||||||
description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.",
|
description: isRestApi || isMultiMode ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -353,7 +358,7 @@ export default function FlowManagementPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 각 API의 엔드포인트 검증
|
// 각 API의 엔드포인트 검증
|
||||||
const missingEndpoint = selectedRestApis.find(api => !api.endpoint);
|
const missingEndpoint = selectedRestApis.find((api) => !api.endpoint);
|
||||||
if (missingEndpoint) {
|
if (missingEndpoint) {
|
||||||
toast({
|
toast({
|
||||||
title: "입력 오류",
|
title: "입력 오류",
|
||||||
|
|
@ -374,7 +379,7 @@ export default function FlowManagementPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 각 DB의 테이블 선택 검증
|
// 각 DB의 테이블 선택 검증
|
||||||
const missingTable = selectedExternalDbs.find(db => !db.tableName);
|
const missingTable = selectedExternalDbs.find((db) => !db.tableName);
|
||||||
if (missingTable) {
|
if (missingTable) {
|
||||||
toast({
|
toast({
|
||||||
title: "입력 오류",
|
title: "입력 오류",
|
||||||
|
|
@ -428,14 +433,14 @@ export default function FlowManagementPage() {
|
||||||
requestData.restApiEndpoint = selectedRestApis[0]?.endpoint;
|
requestData.restApiEndpoint = selectedRestApis[0]?.endpoint;
|
||||||
requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response";
|
requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response";
|
||||||
// 가상 테이블명: 모든 연결 ID를 조합
|
// 가상 테이블명: 모든 연결 ID를 조합
|
||||||
requestData.tableName = `_multi_restapi_${selectedRestApis.map(a => a.connectionId).join("_")}`;
|
requestData.tableName = `_multi_restapi_${selectedRestApis.map((a) => a.connectionId).join("_")}`;
|
||||||
} else if (dbSourceType === "multi_external_db") {
|
} else if (dbSourceType === "multi_external_db") {
|
||||||
// 다중 외부 DB인 경우
|
// 다중 외부 DB인 경우
|
||||||
requestData.externalDbConnections = selectedExternalDbs;
|
requestData.externalDbConnections = selectedExternalDbs;
|
||||||
// 첫 번째 DB의 ID를 기본으로 사용
|
// 첫 번째 DB의 ID를 기본으로 사용
|
||||||
requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId;
|
requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId;
|
||||||
// 가상 테이블명: 모든 연결 ID와 테이블명 조합
|
// 가상 테이블명: 모든 연결 ID와 테이블명 조합
|
||||||
requestData.tableName = `_multi_external_db_${selectedExternalDbs.map(db => `${db.connectionId}_${db.tableName}`).join("_")}`;
|
requestData.tableName = `_multi_external_db_${selectedExternalDbs.map((db) => `${db.connectionId}_${db.tableName}`).join("_")}`;
|
||||||
} else if (dbSourceType === "restapi") {
|
} else if (dbSourceType === "restapi") {
|
||||||
// 단일 REST API인 경우
|
// 단일 REST API인 경우
|
||||||
requestData.restApiConnectionId = restApiConnectionId;
|
requestData.restApiConnectionId = restApiConnectionId;
|
||||||
|
|
@ -733,14 +738,10 @@ export default function FlowManagementPage() {
|
||||||
-- 다중 연결 (데이터 병합) --
|
-- 다중 연결 (데이터 병합) --
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{externalConnections.length > 0 && (
|
{externalConnections.length > 0 && (
|
||||||
<SelectItem value="multi_external_db">
|
<SelectItem value="multi_external_db">다중 외부 DB (데이터 병합)</SelectItem>
|
||||||
다중 외부 DB (데이터 병합)
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
)}
|
||||||
{restApiConnections.length > 0 && (
|
{restApiConnections.length > 0 && (
|
||||||
<SelectItem value="multi_restapi">
|
<SelectItem value="multi_restapi">다중 REST API (데이터 병합)</SelectItem>
|
||||||
다중 REST API (데이터 병합)
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -769,7 +770,7 @@ export default function FlowManagementPage() {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{restApiConnections
|
{restApiConnections
|
||||||
.filter(conn => !selectedRestApis.some(api => api.connectionId === conn.id))
|
.filter((conn) => !selectedRestApis.some((api) => api.connectionId === conn.id))
|
||||||
.map((conn) => (
|
.map((conn) => (
|
||||||
<SelectItem key={conn.id} value={String(conn.id)}>
|
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||||
{conn.connection_name}
|
{conn.connection_name}
|
||||||
|
|
@ -781,19 +782,18 @@ export default function FlowManagementPage() {
|
||||||
|
|
||||||
{selectedRestApis.length === 0 ? (
|
{selectedRestApis.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed p-4 text-center">
|
<div className="rounded-md border border-dashed p-4 text-center">
|
||||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
<p className="text-muted-foreground text-xs sm:text-sm">위에서 REST API를 추가해주세요</p>
|
||||||
위에서 REST API를 추가해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedRestApis.map((api) => (
|
{selectedRestApis.map((api) => (
|
||||||
<div key={api.connectionId} className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
|
<div
|
||||||
|
key={api.connectionId}
|
||||||
|
className="bg-muted/30 flex items-center justify-between rounded-md border px-3 py-2"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{api.connectionName}</span>
|
<span className="text-sm font-medium">{api.connectionName}</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">({api.endpoint || "기본 엔드포인트"})</span>
|
||||||
({api.endpoint || "기본 엔드포인트"})
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -831,7 +831,7 @@ export default function FlowManagementPage() {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{externalConnections
|
{externalConnections
|
||||||
.filter(conn => !selectedExternalDbs.some(db => db.connectionId === conn.id))
|
.filter((conn) => !selectedExternalDbs.some((db) => db.connectionId === conn.id))
|
||||||
.map((conn) => (
|
.map((conn) => (
|
||||||
<SelectItem key={conn.id} value={String(conn.id)}>
|
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||||
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
||||||
|
|
@ -843,14 +843,12 @@ export default function FlowManagementPage() {
|
||||||
|
|
||||||
{selectedExternalDbs.length === 0 ? (
|
{selectedExternalDbs.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed p-4 text-center">
|
<div className="rounded-md border border-dashed p-4 text-center">
|
||||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
<p className="text-muted-foreground text-xs sm:text-sm">위에서 외부 DB를 추가해주세요</p>
|
||||||
위에서 외부 DB를 추가해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{selectedExternalDbs.map((db) => (
|
{selectedExternalDbs.map((db) => (
|
||||||
<div key={db.connectionId} className="rounded-md border p-3 space-y-2">
|
<div key={db.connectionId} className="space-y-2 rounded-md border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{db.connectionName} ({db.dbType?.toUpperCase()})
|
{db.connectionName} ({db.dbType?.toUpperCase()})
|
||||||
|
|
@ -965,7 +963,11 @@ export default function FlowManagementPage() {
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export default function MailAccountsPage() {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedAccount, setSelectedAccount] = useState<MailAccount | null>(null);
|
const [selectedAccount, setSelectedAccount] = useState<MailAccount | null>(null);
|
||||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -55,13 +55,13 @@ export default function MailAccountsPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenCreateModal = () => {
|
const handleOpenCreateModal = () => {
|
||||||
setModalMode('create');
|
setModalMode("create");
|
||||||
setSelectedAccount(null);
|
setSelectedAccount(null);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenEditModal = (account: MailAccount) => {
|
const handleOpenEditModal = (account: MailAccount) => {
|
||||||
setModalMode('edit');
|
setModalMode("edit");
|
||||||
setSelectedAccount(account);
|
setSelectedAccount(account);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
@ -73,9 +73,9 @@ export default function MailAccountsPage() {
|
||||||
|
|
||||||
const handleSaveAccount = async (data: CreateMailAccountDto | UpdateMailAccountDto) => {
|
const handleSaveAccount = async (data: CreateMailAccountDto | UpdateMailAccountDto) => {
|
||||||
try {
|
try {
|
||||||
if (modalMode === 'create') {
|
if (modalMode === "create") {
|
||||||
await createMailAccount(data as CreateMailAccountDto);
|
await createMailAccount(data as CreateMailAccountDto);
|
||||||
} else if (modalMode === 'edit' && selectedAccount) {
|
} else if (modalMode === "edit" && selectedAccount) {
|
||||||
await updateMailAccount(selectedAccount.id, data as UpdateMailAccountDto);
|
await updateMailAccount(selectedAccount.id, data as UpdateMailAccountDto);
|
||||||
}
|
}
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
|
|
@ -91,21 +91,21 @@ export default function MailAccountsPage() {
|
||||||
try {
|
try {
|
||||||
await deleteMailAccount(selectedAccount.id);
|
await deleteMailAccount(selectedAccount.id);
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
alert('계정이 삭제되었습니다.');
|
alert("계정이 삭제되었습니다.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('계정 삭제 실패:', error);
|
// console.error('계정 삭제 실패:', error);
|
||||||
alert('계정 삭제에 실패했습니다.');
|
alert("계정 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleStatus = async (account: MailAccount) => {
|
const handleToggleStatus = async (account: MailAccount) => {
|
||||||
try {
|
try {
|
||||||
const newStatus = account.status === 'active' ? 'inactive' : 'active';
|
const newStatus = account.status === "active" ? "inactive" : "active";
|
||||||
await updateMailAccount(account.id, { status: newStatus });
|
await updateMailAccount(account.id, { status: newStatus });
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('상태 변경 실패:', error);
|
// console.error('상태 변경 실패:', error);
|
||||||
alert('상태 변경에 실패했습니다.');
|
alert("상태 변경에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -115,23 +115,23 @@ export default function MailAccountsPage() {
|
||||||
const result = await testMailAccountConnection(account.id);
|
const result = await testMailAccountConnection(account.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert(`✅ SMTP 연결 성공!\n\n${result.message || '정상적으로 연결되었습니다.'}`);
|
alert(`✅ SMTP 연결 성공!\n\n${result.message || "정상적으로 연결되었습니다."}`);
|
||||||
} else {
|
} else {
|
||||||
alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`);
|
alert(`❌ SMTP 연결 실패\n\n${result.message || "연결에 실패했습니다."}`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// console.error('연결 테스트 실패:', error);
|
// console.error('연결 테스트 실패:', error);
|
||||||
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`);
|
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || "알 수 없는 오류가 발생했습니다."}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
<div className="bg-card space-y-4 rounded-lg border p-6">
|
||||||
{/* 브레드크럼브 */}
|
{/* 브레드크럼브 */}
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -140,7 +140,7 @@ export default function MailAccountsPage() {
|
||||||
>
|
>
|
||||||
메일 관리
|
메일 관리
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-foreground font-medium">계정 관리</span>
|
<span className="text-foreground font-medium">계정 관리</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -149,25 +149,16 @@ export default function MailAccountsPage() {
|
||||||
{/* 제목 + 액션 버튼들 */}
|
{/* 제목 + 액션 버튼들 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">메일 계정 관리</h1>
|
<h1 className="text-foreground text-3xl font-bold">메일 계정 관리</h1>
|
||||||
<p className="mt-2 text-muted-foreground">SMTP 메일 계정을 관리하고 발송 통계를 확인합니다</p>
|
<p className="text-muted-foreground mt-2">SMTP 메일 계정을 관리하고 발송 통계를 확인합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={loadAccounts} disabled={loading}>
|
||||||
variant="outline"
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
size="sm"
|
|
||||||
onClick={loadAccounts}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="default" onClick={handleOpenCreateModal}>
|
||||||
variant="default"
|
<Plus className="mr-2 h-4 w-4" />새 계정 추가
|
||||||
onClick={handleOpenCreateModal}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
새 계정 추가
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -176,8 +167,8 @@ export default function MailAccountsPage() {
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 메인 컨텐츠 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex justify-center items-center py-16">
|
<CardContent className="flex items-center justify-center py-16">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -197,16 +188,14 @@ export default function MailAccountsPage() {
|
||||||
{/* 안내 정보 */}
|
{/* 안내 정보 */}
|
||||||
<Card className="bg-muted/50">
|
<Card className="bg-muted/50">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center">
|
<CardTitle className="flex items-center text-lg">
|
||||||
<Mail className="w-5 h-5 mr-2 text-foreground" />
|
<Mail className="text-foreground mr-2 h-5 w-5" />
|
||||||
메일 계정 관리
|
메일 계정 관리
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-foreground mb-4">
|
<p className="text-foreground mb-4">💡 SMTP 계정을 등록하여 시스템에서 메일을 발송할 수 있어요!</p>
|
||||||
💡 SMTP 계정을 등록하여 시스템에서 메일을 발송할 수 있어요!
|
<ul className="text-muted-foreground space-y-2 text-sm">
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-foreground mr-2">✓</span>
|
<span className="text-foreground mr-2">✓</span>
|
||||||
<span>Gmail, Naver, 자체 SMTP 서버 지원</span>
|
<span>Gmail, Naver, 자체 SMTP 서버 지원</span>
|
||||||
|
|
|
||||||
|
|
@ -6,34 +6,12 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
import { Upload, Send, FileText, Users, AlertCircle, CheckCircle2, Loader2, Download, X } from "lucide-react";
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Upload,
|
|
||||||
Send,
|
|
||||||
FileText,
|
|
||||||
Users,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
Loader2,
|
|
||||||
Download,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import { MailAccount, MailTemplate, getMailAccounts, getMailTemplates, sendBulkMail } from "@/lib/api/mail";
|
||||||
MailAccount,
|
|
||||||
MailTemplate,
|
|
||||||
getMailAccounts,
|
|
||||||
getMailTemplates,
|
|
||||||
sendBulkMail,
|
|
||||||
} from "@/lib/api/mail";
|
|
||||||
|
|
||||||
interface RecipientData {
|
interface RecipientData {
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -65,7 +43,7 @@ export default function BulkSendPage() {
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getMailAccounts();
|
const data = await getMailAccounts();
|
||||||
setAccounts(data.filter((acc) => acc.status === 'active'));
|
setAccounts(data.filter((acc) => acc.status === "active"));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -254,16 +232,16 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="mx-auto w-full space-y-6 px-6 py-8">
|
<div className="mx-auto w-full space-y-6 px-6 py-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-card p-8">
|
<div className="bg-card flex items-center justify-between rounded-lg border p-8">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="rounded-lg bg-primary/10 p-4">
|
<div className="bg-primary/10 rounded-lg p-4">
|
||||||
<Users className="h-8 w-8 text-primary" />
|
<Users className="text-primary h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="mb-1 text-3xl font-bold text-foreground">대량 메일 발송</h1>
|
<h1 className="text-foreground mb-1 text-3xl font-bold">대량 메일 발송</h1>
|
||||||
<p className="text-muted-foreground">CSV 파일로 여러 수신자에게 메일을 발송하세요</p>
|
<p className="text-muted-foreground">CSV 파일로 여러 수신자에게 메일을 발송하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -301,7 +279,10 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="mode">발송 방식</Label>
|
<Label htmlFor="mode">발송 방식</Label>
|
||||||
<Select value={useTemplate ? "template" : "custom"} onValueChange={(v) => setUseTemplate(v === "template")}>
|
<Select
|
||||||
|
value={useTemplate ? "template" : "custom"}
|
||||||
|
onValueChange={(v) => setUseTemplate(v === "template")}
|
||||||
|
>
|
||||||
<SelectTrigger id="mode">
|
<SelectTrigger id="mode">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -366,31 +347,20 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="csv">CSV 파일</Label>
|
<Label htmlFor="csv">CSV 파일</Label>
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Input
|
<Input id="csv" type="file" accept=".csv" onChange={handleFileUpload} disabled={loading} />
|
||||||
id="csv"
|
<Button variant="outline" size="icon" onClick={downloadSampleCsv} title="샘플 다운로드">
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={downloadSampleCsv}
|
|
||||||
title="샘플 다운로드"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
첫 번째 줄은 헤더(email, name, company 등)여야 합니다.
|
첫 번째 줄은 헤더(email, name, company 등)여야 합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{csvFile && (
|
{csvFile && (
|
||||||
<div className="flex items-center justify-between rounded-md border bg-muted p-3">
|
<div className="bg-muted flex items-center justify-between rounded-md border p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
<FileText className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-sm">{csvFile.name}</span>
|
<span className="text-sm">{csvFile.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -407,12 +377,12 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
{recipients.length > 0 && (
|
||||||
<div className="rounded-md border bg-muted p-4">
|
<div className="bg-muted rounded-md border p-4">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
<span className="font-medium">{recipients.length}명의 수신자</span>
|
<span className="font-medium">{recipients.length}명의 수신자</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
변수: {Object.keys(recipients[0]?.variables || {}).join(", ")}
|
변수: {Object.keys(recipients[0]?.variables || {}).join(", ")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -437,9 +407,9 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
{sendProgress.sent} / {sendProgress.total}
|
{sendProgress.sent} / {sendProgress.total}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
<div className="bg-muted h-2 overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all duration-300"
|
className="bg-primary h-full transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
width: `${(sendProgress.sent / sendProgress.total) * 100}%`,
|
width: `${(sendProgress.sent / sendProgress.total) * 100}%`,
|
||||||
}}
|
}}
|
||||||
|
|
@ -448,12 +418,7 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button onClick={handleSend} disabled={sending || recipients.length === 0} className="w-full" size="lg">
|
||||||
onClick={handleSend}
|
|
||||||
disabled={sending || recipients.length === 0}
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{sending ? (
|
{sending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
|
@ -467,10 +432,10 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="rounded-md border bg-muted p-4">
|
<div className="bg-muted rounded-md border p-4">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
<AlertCircle className="text-muted-foreground mt-0.5 h-4 w-4" />
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-muted-foreground text-xs">
|
||||||
<p className="font-medium">주의사항</p>
|
<p className="font-medium">주의사항</p>
|
||||||
<ul className="mt-1 list-inside list-disc space-y-1">
|
<ul className="mt-1 list-inside list-disc space-y-1">
|
||||||
<li>발송 속도는 계정 설정에 따라 제한됩니다</li>
|
<li>발송 속도는 계정 설정에 따라 제한됩니다</li>
|
||||||
|
|
@ -492,12 +457,9 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||||
{recipients.slice(0, 10).map((recipient, index) => (
|
{recipients.slice(0, 10).map((recipient, index) => (
|
||||||
<div
|
<div key={index} className="bg-muted rounded-md border p-3 text-sm">
|
||||||
key={index}
|
|
||||||
className="rounded-md border bg-muted p-3 text-sm"
|
|
||||||
>
|
|
||||||
<div className="font-medium">{recipient.email}</div>
|
<div className="font-medium">{recipient.email}</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-1 text-xs">
|
||||||
{Object.entries(recipient.variables).map(([key, value]) => (
|
{Object.entries(recipient.variables).map(([key, value]) => (
|
||||||
<span key={key} className="mr-2">
|
<span key={key} className="mr-2">
|
||||||
{key}: {value}
|
{key}: {value}
|
||||||
|
|
@ -507,9 +469,7 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{recipients.length > 10 && (
|
{recipients.length > 10 && (
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-center text-xs">외 {recipients.length - 10}명</p>
|
||||||
외 {recipients.length - 10}명
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -521,4 +481,3 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
Calendar,
|
Calendar,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Trash2,
|
Trash2,
|
||||||
Edit
|
Edit,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
|
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
|
||||||
import MailNotifications from "@/components/mail/MailNotifications";
|
import MailNotifications from "@/components/mail/MailNotifications";
|
||||||
|
|
@ -55,7 +55,7 @@ export default function MailDashboardPage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await getMailStatistics();
|
const stats = await getMailStatistics();
|
||||||
if (stats && typeof stats === 'object') {
|
if (stats && typeof stats === "object") {
|
||||||
mailStats = {
|
mailStats = {
|
||||||
todayCount: stats.todayCount || 0,
|
todayCount: stats.todayCount || 0,
|
||||||
thisMonthCount: stats.thisMonthCount || 0,
|
thisMonthCount: stats.thisMonthCount || 0,
|
||||||
|
|
@ -194,56 +194,47 @@ export default function MailDashboardPage() {
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full px-3 py-3 space-y-3">
|
<div className="w-full space-y-3 px-3 py-3">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-card rounded-lg border p-8">
|
<div className="bg-card flex items-center justify-between rounded-lg border p-8">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="p-4 bg-primary/10 rounded-lg">
|
<div className="bg-primary/10 rounded-lg p-4">
|
||||||
<Mail className="w-8 h-8 text-primary" />
|
<Mail className="text-primary h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-1">메일 관리 대시보드</h1>
|
<h1 className="text-foreground mb-1 text-3xl font-bold">메일 관리 대시보드</h1>
|
||||||
<p className="text-muted-foreground">메일 시스템의 전체 현황을 한눈에 확인하세요</p>
|
<p className="text-muted-foreground">메일 시스템의 전체 현황을 한눈에 확인하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<MailNotifications />
|
<MailNotifications />
|
||||||
<Button
|
<Button variant="outline" size="lg" onClick={loadStats} disabled={loading}>
|
||||||
variant="outline"
|
<RefreshCw className={`mr-2 h-5 w-5 ${loading ? "animate-spin" : ""}`} />
|
||||||
size="lg"
|
|
||||||
onClick={loadStats}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-5 h-5 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{statCards.map((stat, index) => (
|
{statCards.map((stat, index) => (
|
||||||
<Link key={index} href={stat.href}>
|
<Link key={index} href={stat.href}>
|
||||||
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
|
<Card className="cursor-pointer transition-all hover:scale-105 hover:shadow-md">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="mb-4 flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-3">
|
<p className="text-muted-foreground mb-3 text-sm font-medium">{stat.title}</p>
|
||||||
{stat.title}
|
<p className="text-foreground text-4xl font-bold">{stat.value}</p>
|
||||||
</p>
|
|
||||||
<p className="text-4xl font-bold text-foreground">
|
|
||||||
{stat.value}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-muted rounded-lg">
|
<div className="bg-muted rounded-lg p-4">
|
||||||
<stat.icon className="w-7 h-7 text-muted-foreground" />
|
<stat.icon className="text-muted-foreground h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 진행 바 */}
|
{/* 진행 바 */}
|
||||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
<div className="bg-muted h-2 overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all duration-1000"
|
className="bg-primary h-full transition-all duration-1000"
|
||||||
style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }}
|
style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -254,25 +245,25 @@ export default function MailDashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 이번 달 통계 */}
|
{/* 이번 달 통계 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<CardTitle className="text-lg flex items-center">
|
<CardTitle className="flex items-center text-lg">
|
||||||
<div className="p-2 bg-muted rounded-lg mr-3">
|
<div className="bg-muted mr-3 rounded-lg p-2">
|
||||||
<Calendar className="w-5 h-5 text-foreground" />
|
<Calendar className="text-foreground h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>이번 달 발송 통계</span>
|
<span>이번 달 발송 통계</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
<div className="bg-muted flex items-center justify-between rounded-lg p-4">
|
||||||
<span className="text-sm font-medium text-muted-foreground">총 발송 건수</span>
|
<span className="text-muted-foreground text-sm font-medium">총 발송 건수</span>
|
||||||
<span className="text-2xl font-bold text-foreground">{stats.sentThisMonth} 건</span>
|
<span className="text-foreground text-2xl font-bold">{stats.sentThisMonth} 건</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
<div className="bg-muted flex items-center justify-between rounded-lg p-4">
|
||||||
<span className="text-sm font-medium text-muted-foreground">성공률</span>
|
<span className="text-muted-foreground text-sm font-medium">성공률</span>
|
||||||
<span className="text-2xl font-bold text-foreground">{stats.successRate}%</span>
|
<span className="text-foreground text-2xl font-bold">{stats.successRate}%</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 전월 대비 통계는 현재 불필요하여 주석처리
|
{/* 전월 대비 통계는 현재 불필요하여 주석처리
|
||||||
<div className="flex items-center justify-between pt-3 border-t">
|
<div className="flex items-center justify-between pt-3 border-t">
|
||||||
|
|
@ -289,35 +280,35 @@ export default function MailDashboardPage() {
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<CardTitle className="text-lg flex items-center">
|
<CardTitle className="flex items-center text-lg">
|
||||||
<div className="p-2 bg-muted rounded-lg mr-3">
|
<div className="bg-muted mr-3 rounded-lg p-2">
|
||||||
<Mail className="w-5 h-5 text-foreground" />
|
<Mail className="text-foreground h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>시스템 상태</span>
|
<span>시스템 상태</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
<div className="bg-muted flex items-center justify-between rounded-lg p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-3 h-3 bg-primary rounded-full animate-pulse"></div>
|
<div className="bg-primary h-3 w-3 animate-pulse rounded-full"></div>
|
||||||
<span className="text-sm font-medium text-muted-foreground">메일 서버</span>
|
<span className="text-muted-foreground text-sm font-medium">메일 서버</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-bold text-foreground">정상 작동</span>
|
<span className="text-foreground text-sm font-bold">정상 작동</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
<div className="bg-muted flex items-center justify-between rounded-lg p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-3 h-3 bg-primary rounded-full"></div>
|
<div className="bg-primary h-3 w-3 rounded-full"></div>
|
||||||
<span className="text-sm font-medium text-muted-foreground">활성 계정</span>
|
<span className="text-muted-foreground text-sm font-medium">활성 계정</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-bold text-foreground">{stats.totalAccounts} 개</span>
|
<span className="text-foreground text-lg font-bold">{stats.totalAccounts} 개</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
<div className="bg-muted flex items-center justify-between rounded-lg p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-3 h-3 bg-primary rounded-full"></div>
|
<div className="bg-primary h-3 w-3 rounded-full"></div>
|
||||||
<span className="text-sm font-medium text-muted-foreground">사용 가능 템플릿</span>
|
<span className="text-muted-foreground text-sm font-medium">사용 가능 템플릿</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-bold text-foreground">{stats.totalTemplates} 개</span>
|
<span className="text-foreground text-lg font-bold">{stats.totalTemplates} 개</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -330,21 +321,21 @@ export default function MailDashboardPage() {
|
||||||
<CardTitle className="text-lg">빠른 액세스</CardTitle>
|
<CardTitle className="text-lg">빠른 액세스</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{quickLinks.map((link, index) => (
|
{quickLinks.map((link, index) => (
|
||||||
<a
|
<a
|
||||||
key={index}
|
key={index}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className="group flex items-center gap-4 p-5 rounded-lg border hover:border-primary/50 hover:shadow-md transition-all bg-card hover:bg-muted/50"
|
className="group hover:border-primary/50 bg-card hover:bg-muted/50 flex items-center gap-4 rounded-lg border p-5 transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="p-3 bg-muted rounded-lg group-hover:scale-105 transition-transform">
|
<div className="bg-muted rounded-lg p-3 transition-transform group-hover:scale-105">
|
||||||
<link.icon className="w-6 h-6 text-muted-foreground" />
|
<link.icon className="text-muted-foreground h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-semibold text-foreground text-base mb-1">{link.title}</p>
|
<p className="text-foreground mb-1 text-base font-semibold">{link.title}</p>
|
||||||
<p className="text-sm text-muted-foreground truncate">{link.description}</p>
|
<p className="text-muted-foreground truncate text-sm">{link.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-foreground group-hover:translate-x-1 transition-all" />
|
<ArrowRight className="text-muted-foreground group-hover:text-foreground h-5 w-5 transition-all group-hover:translate-x-1" />
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -110,48 +110,39 @@ export default function DraftsPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 space-y-3">
|
<div className="space-y-3 p-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">임시보관함</h1>
|
<h1 className="text-foreground text-3xl font-bold">임시보관함</h1>
|
||||||
<p className="mt-2 text-muted-foreground">작성 중인 메일이 자동으로 저장됩니다</p>
|
<p className="text-muted-foreground mt-2">작성 중인 메일이 자동으로 저장됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{drafts.length === 0 ? (
|
{drafts.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
|
<Mail className="text-muted-foreground mb-4 h-12 w-12" />
|
||||||
<p className="text-muted-foreground">임시 저장된 메일이 없습니다</p>
|
<p className="text-muted-foreground">임시 저장된 메일이 없습니다</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{drafts.map((draft) => (
|
{drafts.map((draft) => (
|
||||||
<Card key={draft.id} className="hover:shadow-md transition-shadow">
|
<Card key={draft.id} className="transition-shadow hover:shadow-md">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle className="text-lg truncate">
|
<CardTitle className="truncate text-lg">{draft.subject || "(제목 없음)"}</CardTitle>
|
||||||
{draft.subject || "(제목 없음)"}
|
<CardDescription className="mt-1">받는 사람: {draft.to.join(", ") || "(없음)"}</CardDescription>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1">
|
|
||||||
받는 사람: {draft.to.join(", ") || "(없음)"}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="ml-4 flex items-center gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => handleEdit(draft)} className="h-8">
|
||||||
variant="outline"
|
<Edit className="mr-1 h-4 w-4" />
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEdit(draft)}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4 mr-1" />
|
|
||||||
편집
|
편집
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -162,10 +153,10 @@ export default function DraftsPage() {
|
||||||
className="h-8"
|
className="h-8"
|
||||||
>
|
>
|
||||||
{deleting === draft.id ? (
|
{deleting === draft.id ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="mr-1 h-4 w-4" />
|
||||||
삭제
|
삭제
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -174,7 +165,7 @@ export default function DraftsPage() {
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex items-center justify-between text-sm">
|
||||||
<span>계정: {draft.accountName || draft.accountEmail}</span>
|
<span>계정: {draft.accountName || draft.accountEmail}</span>
|
||||||
<span>
|
<span>
|
||||||
{draft.updatedAt
|
{draft.updatedAt
|
||||||
|
|
@ -184,7 +175,7 @@ export default function DraftsPage() {
|
||||||
</div>
|
</div>
|
||||||
{draft.htmlContent && (
|
{draft.htmlContent && (
|
||||||
<div
|
<div
|
||||||
className="mt-2 text-sm text-muted-foreground line-clamp-2"
|
className="text-muted-foreground mt-2 line-clamp-2 text-sm"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: draft.htmlContent.replace(/<[^>]*>/g, "").substring(0, 100),
|
__html: draft.htmlContent.replace(/<[^>]*>/g, "").substring(0, 100),
|
||||||
}}
|
}}
|
||||||
|
|
@ -198,4 +189,3 @@ export default function DraftsPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,8 @@ export default function MailReceivePage() {
|
||||||
|
|
||||||
// URL 파라미터에서 mailId 읽기 및 자동 선택
|
// URL 파라미터에서 mailId 읽기 및 자동 선택
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mailId = searchParams.get('mailId');
|
const mailId = searchParams.get("mailId");
|
||||||
const accountId = searchParams.get('accountId');
|
const accountId = searchParams.get("accountId");
|
||||||
|
|
||||||
if (mailId && accountId) {
|
if (mailId && accountId) {
|
||||||
// console.log('📧 URL에서 메일 ID 감지:', mailId, accountId);
|
// console.log('📧 URL에서 메일 ID 감지:', mailId, accountId);
|
||||||
|
|
@ -103,7 +103,7 @@ export default function MailReceivePage() {
|
||||||
// 메일 목록 로드 후 URL에서 지정된 메일 자동 선택
|
// 메일 목록 로드 후 URL에서 지정된 메일 자동 선택
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedMailId && mails.length > 0 && !selectedMailDetail) {
|
if (selectedMailId && mails.length > 0 && !selectedMailDetail) {
|
||||||
const mail = mails.find(m => m.id === selectedMailId);
|
const mail = mails.find((m) => m.id === selectedMailId);
|
||||||
if (mail) {
|
if (mail) {
|
||||||
// console.log('🎯 URL에서 지정된 메일 자동 선택:', selectedMailId);
|
// console.log('🎯 URL에서 지정된 메일 자동 선택:', selectedMailId);
|
||||||
handleMailClick(mail);
|
handleMailClick(mail);
|
||||||
|
|
@ -146,9 +146,9 @@ export default function MailReceivePage() {
|
||||||
const data = await getReceivedMails(selectedAccountId, 200); // 더 많이 가져오기
|
const data = await getReceivedMails(selectedAccountId, 200); // 더 많이 가져오기
|
||||||
|
|
||||||
// 현재 로컬에서 읽음 처리한 메일들의 상태를 유지
|
// 현재 로컬에서 읽음 처리한 메일들의 상태를 유지
|
||||||
const processedMails = data.map(mail => ({
|
const processedMails = data.map((mail) => ({
|
||||||
...mail,
|
...mail,
|
||||||
isRead: mail.isRead
|
isRead: mail.isRead,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setAllMails(processedMails); // 전체 메일 저장
|
setAllMails(processedMails); // 전체 메일 저장
|
||||||
|
|
@ -157,14 +157,10 @@ export default function MailReceivePage() {
|
||||||
applyPagination(processedMails);
|
applyPagination(processedMails);
|
||||||
|
|
||||||
// 알림 갱신 이벤트 발생 (새 메일이 있을 수 있음)
|
// 알림 갱신 이벤트 발생 (새 메일이 있을 수 있음)
|
||||||
window.dispatchEvent(new CustomEvent('mail-received'));
|
window.dispatchEvent(new CustomEvent("mail-received"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("메일 로드 실패:", error);
|
// console.error("메일 로드 실패:", error);
|
||||||
alert(
|
alert(error instanceof Error ? error.message : "메일을 불러오는데 실패했습니다.");
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "메일을 불러오는데 실패했습니다."
|
|
||||||
);
|
|
||||||
setMails([]);
|
setMails([]);
|
||||||
setAllMails([]);
|
setAllMails([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -199,10 +195,7 @@ export default function MailReceivePage() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: false,
|
success: false,
|
||||||
message:
|
message: error instanceof Error ? error.message : "IMAP 연결 테스트 실패",
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "IMAP 연결 테스트 실패",
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
|
|
@ -234,16 +227,12 @@ export default function MailReceivePage() {
|
||||||
|
|
||||||
// 즉시 로컬 상태 업데이트 (UI 반응성 향상)
|
// 즉시 로컬 상태 업데이트 (UI 반응성 향상)
|
||||||
// console.log('📧 메일 클릭:', mail.id, '현재 읽음 상태:', mail.isRead);
|
// console.log('📧 메일 클릭:', mail.id, '현재 읽음 상태:', mail.isRead);
|
||||||
setMails((prevMails) =>
|
setMails((prevMails) => prevMails.map((m) => (m.id === mail.id ? { ...m, isRead: true } : m)));
|
||||||
prevMails.map((m) =>
|
|
||||||
m.id === mail.id ? { ...m, isRead: true } : m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 메일 상세 정보 로드
|
// 메일 상세 정보 로드
|
||||||
try {
|
try {
|
||||||
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
|
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
|
||||||
const mailIdParts = mail.id.split('-');
|
const mailIdParts = mail.id.split("-");
|
||||||
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
|
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
|
||||||
const seqno = parseInt(mailIdParts[2], 10); // 13
|
const seqno = parseInt(mailIdParts[2], 10); // 13
|
||||||
|
|
||||||
|
|
@ -273,13 +262,19 @@ export default function MailReceivePage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteMail = async () => {
|
const handleDeleteMail = async () => {
|
||||||
if (!selectedMailId || !confirm("이 메일을 IMAP 서버에서 삭제하시겠습니까?\n(Gmail/Naver 휴지통으로 이동됩니다)\n\n⚠️ IMAP 연결에 시간이 걸릴 수 있습니다.")) return;
|
if (
|
||||||
|
!selectedMailId ||
|
||||||
|
!confirm(
|
||||||
|
"이 메일을 IMAP 서버에서 삭제하시겠습니까?\n(Gmail/Naver 휴지통으로 이동됩니다)\n\n⚠️ IMAP 연결에 시간이 걸릴 수 있습니다.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
|
|
||||||
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
|
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
|
||||||
const mailIdParts = selectedMailId.split('-');
|
const mailIdParts = selectedMailId.split("-");
|
||||||
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
|
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
|
||||||
const seqno = parseInt(mailIdParts[2], 10); // 10
|
const seqno = parseInt(mailIdParts[2], 10); // 10
|
||||||
|
|
||||||
|
|
@ -306,7 +301,7 @@ export default function MailReceivePage() {
|
||||||
|
|
||||||
let errorMessage = "메일 삭제에 실패했습니다.";
|
let errorMessage = "메일 삭제에 실패했습니다.";
|
||||||
|
|
||||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
|
||||||
errorMessage = "IMAP 서버 연결 시간 초과\n네트워크 상태를 확인하거나 나중에 다시 시도해주세요.";
|
errorMessage = "IMAP 서버 연결 시간 초과\n네트워크 상태를 확인하거나 나중에 다시 시도해주세요.";
|
||||||
} else if (error.response?.data?.message) {
|
} else if (error.response?.data?.message) {
|
||||||
errorMessage = error.response.data.message;
|
errorMessage = error.response.data.message;
|
||||||
|
|
@ -329,7 +324,7 @@ export default function MailReceivePage() {
|
||||||
(mail) =>
|
(mail) =>
|
||||||
mail.subject.toLowerCase().includes(searchLower) ||
|
mail.subject.toLowerCase().includes(searchLower) ||
|
||||||
mail.from.toLowerCase().includes(searchLower) ||
|
mail.from.toLowerCase().includes(searchLower) ||
|
||||||
mail.preview.toLowerCase().includes(searchLower)
|
mail.preview.toLowerCase().includes(searchLower),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,10 +352,10 @@ export default function MailReceivePage() {
|
||||||
}, [mails, searchTerm, filterStatus, sortBy]);
|
}, [mails, searchTerm, filterStatus, sortBy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
<div className="bg-card space-y-4 rounded-lg border p-6">
|
||||||
{/* 브레드크럼브 */}
|
{/* 브레드크럼브 */}
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -369,7 +364,7 @@ export default function MailReceivePage() {
|
||||||
>
|
>
|
||||||
메일 관리
|
메일 관리
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-foreground font-medium">메일 수신함</span>
|
<span className="text-foreground font-medium">메일 수신함</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -378,21 +373,12 @@ export default function MailReceivePage() {
|
||||||
{/* 제목 + 액션 버튼들 */}
|
{/* 제목 + 액션 버튼들 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">메일 수신함</h1>
|
<h1 className="text-foreground text-3xl font-bold">메일 수신함</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="text-muted-foreground mt-2">IMAP으로 받은 메일을 확인합니다</p>
|
||||||
IMAP으로 받은 메일을 확인합니다
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={loadMails} disabled={loading || !selectedAccountId}>
|
||||||
variant="outline"
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
size="sm"
|
|
||||||
onClick={loadMails}
|
|
||||||
disabled={loading || !selectedAccountId}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -401,11 +387,7 @@ export default function MailReceivePage() {
|
||||||
onClick={handleTestConnection}
|
onClick={handleTestConnection}
|
||||||
disabled={testing || !selectedAccountId}
|
disabled={testing || !selectedAccountId}
|
||||||
>
|
>
|
||||||
{testing ? (
|
{testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle className="mr-2 h-4 w-4" />}
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
연결 테스트
|
연결 테스트
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -416,13 +398,11 @@ export default function MailReceivePage() {
|
||||||
<Card className="">
|
<Card className="">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<label className="text-sm font-medium text-foreground whitespace-nowrap">
|
<label className="text-foreground text-sm font-medium whitespace-nowrap">메일 계정:</label>
|
||||||
메일 계정:
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={selectedAccountId}
|
value={selectedAccountId}
|
||||||
onChange={(e) => setSelectedAccountId(e.target.value)}
|
onChange={(e) => setSelectedAccountId(e.target.value)}
|
||||||
className="flex-1 px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
className="flex-1 rounded-lg border px-4 py-2 focus:border-orange-500 focus:ring-2 focus:ring-orange-500"
|
||||||
>
|
>
|
||||||
<option value="">계정 선택</option>
|
<option value="">계정 선택</option>
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
|
|
@ -436,17 +416,13 @@ export default function MailReceivePage() {
|
||||||
{/* 연결 테스트 결과 */}
|
{/* 연결 테스트 결과 */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div
|
<div
|
||||||
className={`mt-4 p-3 rounded-lg flex items-center gap-2 ${
|
className={`mt-4 flex items-center gap-2 rounded-lg p-3 ${
|
||||||
testResult.success
|
testResult.success
|
||||||
? "bg-green-50 text-green-800 border border-green-200"
|
? "border border-green-200 bg-green-50 text-green-800"
|
||||||
: "bg-red-50 text-red-800 border border-red-200"
|
: "border border-red-200 bg-red-50 text-red-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{testResult.success ? (
|
{testResult.success ? <CheckCircle className="h-5 w-5" /> : <AlertCircle className="h-5 w-5" />}
|
||||||
<CheckCircle className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
<span>{testResult.message}</span>
|
<span>{testResult.message}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -457,26 +433,26 @@ export default function MailReceivePage() {
|
||||||
{selectedAccountId && (
|
{selectedAccountId && (
|
||||||
<Card className="">
|
<Card className="">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col md:flex-row gap-3">
|
<div className="flex flex-col gap-3 md:flex-row">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="flex-1 relative">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
placeholder="제목, 발신자, 내용으로 검색..."
|
placeholder="제목, 발신자, 내용으로 검색..."
|
||||||
className="w-full pl-10 pr-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
className="w-full rounded-lg border py-2 pr-4 pl-10 focus:border-orange-500 focus:ring-2 focus:ring-orange-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 */}
|
{/* 필터 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
<Filter className="text-muted-foreground h-4 w-4" />
|
||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
className="rounded-lg border px-3 py-2 focus:border-orange-500 focus:ring-2 focus:ring-orange-500"
|
||||||
>
|
>
|
||||||
<option value="all">전체</option>
|
<option value="all">전체</option>
|
||||||
<option value="unread">읽지 않음</option>
|
<option value="unread">읽지 않음</option>
|
||||||
|
|
@ -488,14 +464,14 @@ export default function MailReceivePage() {
|
||||||
{/* 정렬 */}
|
{/* 정렬 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{sortBy.includes("desc") ? (
|
{sortBy.includes("desc") ? (
|
||||||
<SortDesc className="w-4 h-4 text-muted-foreground" />
|
<SortDesc className="text-muted-foreground h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<SortAsc className="w-4 h-4 text-muted-foreground" />
|
<SortAsc className="text-muted-foreground h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value)}
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
className="rounded-lg border px-3 py-2 focus:border-orange-500 focus:ring-2 focus:ring-orange-500"
|
||||||
>
|
>
|
||||||
<option value="date-desc">날짜 ↓ (최신순)</option>
|
<option value="date-desc">날짜 ↓ (최신순)</option>
|
||||||
<option value="date-asc">날짜 ↑ (오래된순)</option>
|
<option value="date-asc">날짜 ↑ (오래된순)</option>
|
||||||
|
|
@ -507,7 +483,7 @@ export default function MailReceivePage() {
|
||||||
|
|
||||||
{/* 검색 결과 카운트 */}
|
{/* 검색 결과 카운트 */}
|
||||||
{(searchTerm || filterStatus !== "all") && (
|
{(searchTerm || filterStatus !== "all") && (
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
<div className="text-muted-foreground mt-3 text-sm">
|
||||||
{filteredAndSortedMails.length}개의 메일이 검색되었습니다
|
{filteredAndSortedMails.length}개의 메일이 검색되었습니다
|
||||||
{searchTerm && (
|
{searchTerm && (
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
|
|
@ -521,20 +497,20 @@ export default function MailReceivePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 네이버 메일 스타일 3-column 레이아웃 */}
|
{/* 네이버 메일 스타일 3-column 레이아웃 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
{/* 왼쪽: 메일 목록 */}
|
{/* 왼쪽: 메일 목록 */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Card className="">
|
<Card className="">
|
||||||
<CardContent className="flex justify-center items-center py-16">
|
<CardContent className="flex items-center justify-center py-16">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
<Loader2 className="h-8 w-8 animate-spin text-orange-500" />
|
||||||
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
<span className="text-muted-foreground ml-3">메일을 불러오는 중...</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : filteredAndSortedMails.length === 0 ? (
|
) : filteredAndSortedMails.length === 0 ? (
|
||||||
<Card className="text-center py-16 bg-card ">
|
<Card className="bg-card py-16 text-center">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
<Mail className="mx-auto mb-4 h-16 w-16 text-gray-300" />
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
{!selectedAccountId
|
{!selectedAccountId
|
||||||
? "메일 계정을 선택하세요"
|
? "메일 계정을 선택하세요"
|
||||||
|
|
@ -543,15 +519,11 @@ export default function MailReceivePage() {
|
||||||
: "받은 메일이 없습니다"}
|
: "받은 메일이 없습니다"}
|
||||||
</p>
|
</p>
|
||||||
{selectedAccountId && (
|
{selectedAccountId && (
|
||||||
<Button
|
<Button onClick={handleTestConnection} variant="outline" disabled={testing}>
|
||||||
onClick={handleTestConnection}
|
|
||||||
variant="outline"
|
|
||||||
disabled={testing}
|
|
||||||
>
|
|
||||||
{testing ? (
|
{testing ? (
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
IMAP 연결 테스트
|
IMAP 연결 테스트
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -560,61 +532,51 @@ export default function MailReceivePage() {
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="">
|
<Card className="">
|
||||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
|
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Inbox className="w-5 h-5 text-orange-500" />
|
<Inbox className="h-5 w-5 text-orange-500" />
|
||||||
받은 메일함 ({filteredAndSortedMails.length}/{mails.length}개)
|
받은 메일함 ({filteredAndSortedMails.length}/{mails.length}개)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y max-h-[calc(100vh-300px)] overflow-y-auto">
|
<div className="max-h-[calc(100vh-300px)] divide-y overflow-y-auto">
|
||||||
{filteredAndSortedMails.map((mail) => (
|
{filteredAndSortedMails.map((mail) => (
|
||||||
<div
|
<div
|
||||||
key={mail.id}
|
key={mail.id}
|
||||||
onClick={() => handleMailClick(mail)}
|
onClick={() => handleMailClick(mail)}
|
||||||
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
|
className={`hover:bg-background cursor-pointer p-4 transition-colors ${
|
||||||
!mail.isRead ? "bg-blue-50/30" : ""
|
!mail.isRead ? "bg-blue-50/30" : ""
|
||||||
} ${selectedMailId === mail.id ? "bg-accent border-l-4 border-l-primary" : ""}`}
|
} ${selectedMailId === mail.id ? "bg-accent border-l-primary border-l-4" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* 읽음 표시 */}
|
{/* 읽음 표시 */}
|
||||||
<div className="flex-shrink-0 w-2 h-2 mt-2">
|
<div className="mt-2 h-2 w-2 flex-shrink-0">
|
||||||
{!mail.isRead && (
|
{!mail.isRead && <div className="h-2 w-2 rounded-full bg-blue-500"></div>}
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메일 내용 */}
|
{/* 메일 내용 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${
|
className={`text-sm ${
|
||||||
mail.isRead
|
mail.isRead ? "text-muted-foreground" : "text-foreground font-semibold"
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-foreground font-semibold"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{mail.from}
|
{mail.from}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{mail.hasAttachments && (
|
{mail.hasAttachments && <Paperclip className="h-4 w-4 text-gray-400" />}
|
||||||
<Paperclip className="w-4 h-4 text-gray-400" />
|
<span className="text-muted-foreground text-xs">{formatDate(mail.date)}</span>
|
||||||
)}
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(mail.date)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3
|
<h3
|
||||||
className={`text-sm mb-1 truncate ${
|
className={`mb-1 truncate text-sm ${
|
||||||
mail.isRead ? "text-foreground" : "text-foreground font-medium"
|
mail.isRead ? "text-foreground" : "text-foreground font-medium"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{mail.subject}
|
{mail.subject}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
<p className="text-muted-foreground line-clamp-2 text-xs">{mail.preview}</p>
|
||||||
{mail.preview}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -624,13 +586,8 @@ export default function MailReceivePage() {
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 p-4 border-t">
|
<div className="flex items-center justify-center gap-2 border-t p-4">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
처음
|
처음
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -661,7 +618,7 @@ export default function MailReceivePage() {
|
||||||
variant={currentPage === pageNum ? "default" : "outline"}
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentPage(pageNum)}
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
className="w-8 h-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -709,7 +666,7 @@ export default function MailReceivePage() {
|
||||||
✕
|
✕
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground space-y-1 mt-2">
|
<div className="text-muted-foreground mt-2 space-y-1 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">보낸 사람:</span>
|
<span className="font-medium">보낸 사람:</span>
|
||||||
<span>{selectedMailDetail.from}</span>
|
<span>{selectedMailDetail.from}</span>
|
||||||
|
|
@ -725,7 +682,7 @@ export default function MailReceivePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 답장/전달/삭제 버튼 */}
|
{/* 답장/전달/삭제 버튼 */}
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="mt-4 flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -737,7 +694,7 @@ export default function MailReceivePage() {
|
||||||
// 1. DOMPurify로 먼저 정제
|
// 1. DOMPurify로 먼저 정제
|
||||||
const cleanHtml = DOMPurify.sanitize(html, {
|
const cleanHtml = DOMPurify.sanitize(html, {
|
||||||
ALLOWED_TAGS: [], // 모든 태그 제거
|
ALLOWED_TAGS: [], // 모든 태그 제거
|
||||||
KEEP_CONTENT: true // 내용만 유지
|
KEEP_CONTENT: true, // 내용만 유지
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. DOM으로 텍스트만 추출
|
// 2. DOM으로 텍스트만 추출
|
||||||
|
|
@ -746,10 +703,10 @@ export default function MailReceivePage() {
|
||||||
let text = tmp.textContent || tmp.innerText || "";
|
let text = tmp.textContent || tmp.innerText || "";
|
||||||
|
|
||||||
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
|
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
|
||||||
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
|
text = text.replace(/[a-z-]+\{[^}]*\}/gi, "");
|
||||||
|
|
||||||
// 4. 연속된 공백 정리
|
// 4. 연속된 공백 정리
|
||||||
text = text.replace(/\s+/g, ' ').trim();
|
text = text.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
@ -760,8 +717,9 @@ export default function MailReceivePage() {
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
|
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
|
||||||
const bodyText = selectedMailDetail.textBody
|
const bodyText =
|
||||||
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
|
selectedMailDetail.textBody ||
|
||||||
|
(selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
|
||||||
|
|
||||||
// console.log('📧 변환된 본문:', bodyText);
|
// console.log('📧 변환된 본문:', bodyText);
|
||||||
|
|
||||||
|
|
@ -772,11 +730,11 @@ export default function MailReceivePage() {
|
||||||
originalBody: bodyText,
|
originalBody: bodyText,
|
||||||
};
|
};
|
||||||
router.push(
|
router.push(
|
||||||
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`
|
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Reply className="w-4 h-4 mr-1" />
|
<Reply className="mr-1 h-4 w-4" />
|
||||||
답장
|
답장
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -790,7 +748,7 @@ export default function MailReceivePage() {
|
||||||
// 1. DOMPurify로 먼저 정제
|
// 1. DOMPurify로 먼저 정제
|
||||||
const cleanHtml = DOMPurify.sanitize(html, {
|
const cleanHtml = DOMPurify.sanitize(html, {
|
||||||
ALLOWED_TAGS: [], // 모든 태그 제거
|
ALLOWED_TAGS: [], // 모든 태그 제거
|
||||||
KEEP_CONTENT: true // 내용만 유지
|
KEEP_CONTENT: true, // 내용만 유지
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. DOM으로 텍스트만 추출
|
// 2. DOM으로 텍스트만 추출
|
||||||
|
|
@ -799,10 +757,10 @@ export default function MailReceivePage() {
|
||||||
let text = tmp.textContent || tmp.innerText || "";
|
let text = tmp.textContent || tmp.innerText || "";
|
||||||
|
|
||||||
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
|
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
|
||||||
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
|
text = text.replace(/[a-z-]+\{[^}]*\}/gi, "");
|
||||||
|
|
||||||
// 4. 연속된 공백 정리
|
// 4. 연속된 공백 정리
|
||||||
text = text.replace(/\s+/g, ' ').trim();
|
text = text.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
@ -813,8 +771,9 @@ export default function MailReceivePage() {
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
|
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
|
||||||
const bodyText = selectedMailDetail.textBody
|
const bodyText =
|
||||||
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
|
selectedMailDetail.textBody ||
|
||||||
|
(selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
|
||||||
|
|
||||||
// console.log('📧 변환된 본문:', bodyText);
|
// console.log('📧 변환된 본문:', bodyText);
|
||||||
|
|
||||||
|
|
@ -825,37 +784,32 @@ export default function MailReceivePage() {
|
||||||
originalBody: bodyText,
|
originalBody: bodyText,
|
||||||
};
|
};
|
||||||
router.push(
|
router.push(
|
||||||
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`
|
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Forward className="w-4 h-4 mr-1" />
|
<Forward className="mr-1 h-4 w-4" />
|
||||||
전달
|
전달
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="destructive" size="sm" onClick={handleDeleteMail} disabled={deleting}>
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDeleteMail}
|
|
||||||
disabled={deleting}
|
|
||||||
>
|
|
||||||
{deleting ? (
|
{deleting ? (
|
||||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="mr-1 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
삭제
|
삭제
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-6 max-h-[calc(100vh-300px)] overflow-y-auto">
|
<CardContent className="max-h-[calc(100vh-300px)] overflow-y-auto p-6">
|
||||||
{/* 첨부파일 */}
|
{/* 첨부파일 */}
|
||||||
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
|
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
|
||||||
<div className="mb-4 p-3 bg-muted rounded-lg">
|
<div className="bg-muted mb-4 rounded-lg p-3">
|
||||||
<p className="text-sm font-medium mb-2">첨부파일 ({selectedMailDetail.attachments.length}개)</p>
|
<p className="mb-2 text-sm font-medium">첨부파일 ({selectedMailDetail.attachments.length}개)</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{selectedMailDetail.attachments.map((att, index) => (
|
{selectedMailDetail.attachments.map((att, index) => (
|
||||||
<div key={index} className="flex items-center gap-2 text-sm">
|
<div key={index} className="flex items-center gap-2 text-sm">
|
||||||
<Paperclip className="w-4 h-4" />
|
<Paperclip className="h-4 w-4" />
|
||||||
<span>{att.filename}</span>
|
<span>{att.filename}</span>
|
||||||
<span className="text-muted-foreground">({(att.size / 1024).toFixed(1)} KB)</span>
|
<span className="text-muted-foreground">({(att.size / 1024).toFixed(1)} KB)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -873,26 +827,22 @@ export default function MailReceivePage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="whitespace-pre-wrap text-sm">
|
<div className="text-sm whitespace-pre-wrap">{selectedMailDetail.textBody}</div>
|
||||||
{selectedMailDetail.textBody}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : loadingDetail ? (
|
) : loadingDetail ? (
|
||||||
<Card className="sticky top-6">
|
<Card className="sticky top-6">
|
||||||
<CardContent className="flex justify-center items-center py-16">
|
<CardContent className="flex items-center justify-center py-16">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
<Loader2 className="h-8 w-8 animate-spin text-orange-500" />
|
||||||
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
<span className="text-muted-foreground ml-3">메일을 불러오는 중...</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="sticky top-6">
|
<Card className="sticky top-6">
|
||||||
<CardContent className="flex flex-col justify-center items-center py-16 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<Mail className="w-16 h-16 mb-4 text-gray-300" />
|
<Mail className="mb-4 h-16 w-16 text-gray-300" />
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">메일을 선택하면 내용이 표시됩니다</p>
|
||||||
메일을 선택하면 내용이 표시됩니다
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
@ -900,94 +850,92 @@ export default function MailReceivePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 안내 정보 */}
|
{/* 안내 정보 */}
|
||||||
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
|
<Card className="border-green-200 bg-gradient-to-r from-green-50 to-emerald-50">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center">
|
<CardTitle className="flex items-center text-lg">
|
||||||
<CheckCircle className="w-5 h-5 mr-2 text-green-600" />
|
<CheckCircle className="mr-2 h-5 w-5 text-green-600" />
|
||||||
메일 수신 기능 완성! 🎉
|
메일 수신 기능 완성! 🎉
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-foreground mb-4">
|
<p className="text-foreground mb-4">✅ 구현 완료된 모든 기능:</p>
|
||||||
✅ 구현 완료된 모든 기능:
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-800 mb-2">📬 기본 기능</p>
|
<p className="mb-2 font-medium text-gray-800">📬 기본 기능</p>
|
||||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>IMAP 프로토콜 메일 수신</span>
|
<span>IMAP 프로토콜 메일 수신</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>메일 목록 표시</span>
|
<span>메일 목록 표시</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>읽음/안읽음 상태</span>
|
<span>읽음/안읽음 상태</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>첨부파일 유무 표시</span>
|
<span>첨부파일 유무 표시</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-800 mb-2">📄 상세보기</p>
|
<p className="mb-2 font-medium text-gray-800">📄 상세보기</p>
|
||||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>HTML 본문 렌더링</span>
|
<span>HTML 본문 렌더링</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>텍스트 본문 보기</span>
|
<span>텍스트 본문 보기</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>자동 읽음 처리</span>
|
<span>자동 읽음 처리</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>첨부파일 다운로드</span>
|
<span>첨부파일 다운로드</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-800 mb-2">🔍 고급 기능</p>
|
<p className="mb-2 font-medium text-gray-800">🔍 고급 기능</p>
|
||||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>통합 검색 (제목/발신자/내용)</span>
|
<span>통합 검색 (제목/발신자/내용)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>필터링 (읽음/첨부파일)</span>
|
<span>필터링 (읽음/첨부파일)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>정렬 (날짜/발신자)</span>
|
<span>정렬 (날짜/발신자)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>자동 새로고침 (30초)</span>
|
<span>자동 새로고침 (30초)</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-800 mb-2">🔒 보안</p>
|
<p className="mb-2 font-medium text-gray-800">🔒 보안</p>
|
||||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>XSS 방지 (DOMPurify)</span>
|
<span>XSS 방지 (DOMPurify)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>비밀번호 암호화</span>
|
<span>비밀번호 암호화</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="mr-2 text-green-500">✓</span>
|
||||||
<span>안전한 파일명 생성</span>
|
<span>안전한 파일명 생성</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,13 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
Send,
|
Send,
|
||||||
Search,
|
Search,
|
||||||
|
|
@ -169,7 +163,7 @@ export default function SentMailPage() {
|
||||||
(mail) =>
|
(mail) =>
|
||||||
mail.subject?.toLowerCase().includes(term) ||
|
mail.subject?.toLowerCase().includes(term) ||
|
||||||
mail.to?.some((email) => email.toLowerCase().includes(term)) ||
|
mail.to?.some((email) => email.toLowerCase().includes(term)) ||
|
||||||
mail.accountEmail?.toLowerCase().includes(term)
|
mail.accountEmail?.toLowerCase().includes(term),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,9 +243,7 @@ export default function SentMailPage() {
|
||||||
originalBody: selectedMailDetail.htmlContent || "",
|
originalBody: selectedMailDetail.htmlContent || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
router.push(
|
router.push(`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`);
|
||||||
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleForward = () => {
|
const handleForward = () => {
|
||||||
|
|
@ -264,9 +256,7 @@ export default function SentMailPage() {
|
||||||
originalBody: selectedMailDetail.htmlContent || "",
|
originalBody: selectedMailDetail.htmlContent || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
router.push(
|
router.push(`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`);
|
||||||
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
|
|
@ -293,28 +283,25 @@ export default function SentMailPage() {
|
||||||
|
|
||||||
if (loading && mails.length === 0) {
|
if (loading && mails.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||||
<p className="text-sm text-muted-foreground">메일을 불러오는 중...</p>
|
<p className="text-muted-foreground text-sm">메일을 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 bg-background min-h-screen">
|
<div className="bg-background min-h-screen space-y-6 p-6">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
<div className="bg-card space-y-4 rounded-lg border p-6">
|
||||||
{/* 브레드크럼브 */}
|
{/* 브레드크럼브 */}
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
<Link
|
<Link href="/admin/mail/dashboard" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
href="/admin/mail/dashboard"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
메일 관리
|
메일 관리
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-foreground font-medium">보낸메일함</span>
|
<span className="text-foreground font-medium">보낸메일함</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -323,21 +310,19 @@ export default function SentMailPage() {
|
||||||
{/* 제목 및 빠른 액션 */}
|
{/* 제목 및 빠른 액션 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
|
<h1 className="text-foreground flex items-center gap-2 text-3xl font-bold">
|
||||||
<Send className="w-8 h-8" />
|
<Send className="h-8 w-8" />
|
||||||
보낸메일함
|
보낸메일함
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="text-muted-foreground mt-2">총 {filteredCount}개의 발송 메일</p>
|
||||||
총 {filteredCount}개의 발송 메일
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={loadMails} variant="outline" size="sm" disabled={loading}>
|
<Button onClick={loadMails} variant="outline" size="sm" disabled={loading}>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => router.push("/admin/automaticMng/mail/send")} size="sm">
|
<Button onClick={() => router.push("/admin/automaticMng/mail/send")} size="sm">
|
||||||
<Mail className="w-4 h-4 mr-2" />
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
메일 작성
|
메일 작성
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -345,18 +330,16 @@ export default function SentMailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">전체 발송</p>
|
<p className="text-muted-foreground text-sm font-medium">전체 발송</p>
|
||||||
<p className="text-2xl font-bold text-foreground mt-1">
|
<p className="text-foreground mt-1 text-2xl font-bold">{stats.totalSent}</p>
|
||||||
{stats.totalSent}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-primary/10 rounded-lg">
|
<div className="bg-primary/10 rounded-lg p-3">
|
||||||
<Send className="w-6 h-6 text-primary" />
|
<Send className="text-primary h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -366,13 +349,11 @@ export default function SentMailPage() {
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">발송 성공</p>
|
<p className="text-muted-foreground text-sm font-medium">발송 성공</p>
|
||||||
<p className="text-2xl font-bold text-foreground mt-1">
|
<p className="text-foreground mt-1 text-2xl font-bold">{stats.successCount}</p>
|
||||||
{stats.successCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-green-500/10 rounded-lg">
|
<div className="rounded-lg bg-green-500/10 p-3">
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-600" />
|
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -382,13 +363,11 @@ export default function SentMailPage() {
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">발송 실패</p>
|
<p className="text-muted-foreground text-sm font-medium">발송 실패</p>
|
||||||
<p className="text-2xl font-bold text-foreground mt-1">
|
<p className="text-foreground mt-1 text-2xl font-bold">{stats.failedCount}</p>
|
||||||
{stats.failedCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-red-500/10 rounded-lg">
|
<div className="rounded-lg bg-red-500/10 p-3">
|
||||||
<XCircle className="w-6 h-6 text-red-600" />
|
<XCircle className="h-6 w-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -398,13 +377,11 @@ export default function SentMailPage() {
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">오늘 발송</p>
|
<p className="text-muted-foreground text-sm font-medium">오늘 발송</p>
|
||||||
<p className="text-2xl font-bold text-foreground mt-1">
|
<p className="text-foreground mt-1 text-2xl font-bold">{stats.todayCount}</p>
|
||||||
{stats.todayCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-blue-500/10 rounded-lg">
|
<div className="rounded-lg bg-blue-500/10 p-3">
|
||||||
<Calendar className="w-6 h-6 text-blue-600" />
|
<Calendar className="h-6 w-6 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -414,11 +391,11 @@ export default function SentMailPage() {
|
||||||
{/* 검색 및 필터 */}
|
{/* 검색 및 필터 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col gap-4 sm:flex-row">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="제목, 받는사람, 계정으로 검색..."
|
placeholder="제목, 받는사람, 계정으로 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -441,10 +418,7 @@ export default function SentMailPage() {
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* 계정 필터 */}
|
{/* 계정 필터 */}
|
||||||
<Select
|
<Select value={filterAccountId} onValueChange={(value) => setFilterAccountId(value)}>
|
||||||
value={filterAccountId}
|
|
||||||
onValueChange={(value) => setFilterAccountId(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full sm:w-[200px]">
|
<SelectTrigger className="w-full sm:w-[200px]">
|
||||||
<SelectValue placeholder="발송 계정" />
|
<SelectValue placeholder="발송 계정" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -462,70 +436,58 @@ export default function SentMailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 메일 목록 + 상세보기 */}
|
{/* 메일 목록 + 상세보기 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* 왼쪽: 메일 목록 */}
|
{/* 왼쪽: 메일 목록 */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<Card className="flex flex-col h-[calc(100vh-500px)] min-h-[400px]">
|
<Card className="flex h-[calc(100vh-500px)] min-h-[400px] flex-col">
|
||||||
<CardHeader className="flex-shrink-0">
|
<CardHeader className="flex-shrink-0">
|
||||||
<CardTitle className="text-base">메일 목록</CardTitle>
|
<CardTitle className="text-base">메일 목록</CardTitle>
|
||||||
<CardDescription>{filteredCount}개의 메일</CardDescription>
|
<CardDescription>{filteredCount}개의 메일</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 p-0 overflow-hidden">
|
<CardContent className="flex-1 overflow-hidden p-0">
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
{mails.length === 0 ? (
|
{mails.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
|
||||||
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
|
<Mail className="text-muted-foreground mb-4 h-12 w-12" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">발송된 메일이 없습니다</p>
|
||||||
발송된 메일이 없습니다
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
mails.map((mail) => (
|
mails.map((mail) => (
|
||||||
<div
|
<div
|
||||||
key={mail.id}
|
key={mail.id}
|
||||||
onClick={() => handleMailClick(mail)}
|
onClick={() => handleMailClick(mail)}
|
||||||
className={`
|
className={`hover:bg-accent cursor-pointer border-b p-4 transition-colors ${selectedMailId === mail.id ? "bg-accent" : ""} `}
|
||||||
p-4 border-b cursor-pointer transition-colors
|
|
||||||
hover:bg-accent
|
|
||||||
${selectedMailId === mail.id ? "bg-accent" : ""}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 mb-2">
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<User className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<User className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||||
<span className="text-sm font-medium truncate">
|
<span className="truncate text-sm font-medium">{mail.to?.[0] || "받는사람 없음"}</span>
|
||||||
{mail.to?.[0] || "받는사람 없음"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold truncate">
|
<p className="truncate text-sm font-semibold">{mail.subject || "(제목 없음)"}</p>
|
||||||
{mail.subject || "(제목 없음)"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
<div className="flex flex-shrink-0 flex-col items-end gap-1">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">{formatDate(mail.sentAt)}</span>
|
||||||
{formatDate(mail.sentAt)}
|
|
||||||
</span>
|
|
||||||
{mail.status === "success" ? (
|
{mail.status === "success" ? (
|
||||||
<Badge variant="default" className="text-xs">
|
<Badge variant="default" className="text-xs">
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
성공
|
성공
|
||||||
</Badge>
|
</Badge>
|
||||||
) : mail.status === "failed" ? (
|
) : mail.status === "failed" ? (
|
||||||
<Badge variant="destructive" className="text-xs">
|
<Badge variant="destructive" className="text-xs">
|
||||||
<XCircle className="w-3 h-3 mr-1" />
|
<XCircle className="mr-1 h-3 w-3" />
|
||||||
실패
|
실패
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
<Mail className="w-3 h-3" />
|
<Mail className="h-3 w-3" />
|
||||||
<span className="truncate">{mail.accountEmail}</span>
|
<span className="truncate">{mail.accountEmail}</span>
|
||||||
{mail.attachments && mail.attachments.length > 0 && (
|
{mail.attachments && mail.attachments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Paperclip className="w-3 h-3 ml-2" />
|
<Paperclip className="ml-2 h-3 w-3" />
|
||||||
<span>{mail.attachments.length}</span>
|
<span>{mail.attachments.length}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -538,13 +500,8 @@ export default function SentMailPage() {
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 p-4 border-t">
|
<div className="flex items-center justify-center gap-2 border-t p-4">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
처음
|
처음
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -575,7 +532,7 @@ export default function SentMailPage() {
|
||||||
variant={currentPage === pageNum ? "default" : "outline"}
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentPage(pageNum)}
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
className="w-8 h-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -607,39 +564,33 @@ export default function SentMailPage() {
|
||||||
{/* 오른쪽: 메일 상세보기 */}
|
{/* 오른쪽: 메일 상세보기 */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
{selectedMailDetail ? (
|
{selectedMailDetail ? (
|
||||||
<Card className="flex flex-col h-[calc(100vh-500px)] min-h-[400px]">
|
<Card className="flex h-[calc(100vh-500px)] min-h-[400px] flex-col">
|
||||||
<CardHeader className="flex-shrink-0">
|
<CardHeader className="flex-shrink-0">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle className="text-xl mb-2">
|
<CardTitle className="mb-2 text-xl">{selectedMailDetail.subject || "(제목 없음)"}</CardTitle>
|
||||||
{selectedMailDetail.subject || "(제목 없음)"}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<User className="w-4 h-4 text-muted-foreground" />
|
<User className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="font-medium">보낸사람:</span>
|
<span className="font-medium">보낸사람:</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{selectedMailDetail.accountName} ({selectedMailDetail.accountEmail})
|
{selectedMailDetail.accountName} ({selectedMailDetail.accountEmail})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
<Mail className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="font-medium">받는사람:</span>
|
<span className="font-medium">받는사람:</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">{selectedMailDetail.to?.join(", ") || "-"}</span>
|
||||||
{selectedMailDetail.to?.join(", ") || "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{selectedMailDetail.cc && selectedMailDetail.cc.length > 0 && (
|
{selectedMailDetail.cc && selectedMailDetail.cc.length > 0 && (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
<Mail className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="font-medium">참조:</span>
|
<span className="font-medium">참조:</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">{selectedMailDetail.cc.join(", ")}</span>
|
||||||
{selectedMailDetail.cc.join(", ")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
<Clock className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="font-medium">발송일시:</span>
|
<span className="font-medium">발송일시:</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{new Date(selectedMailDetail.sentAt).toLocaleString("ko-KR")}
|
{new Date(selectedMailDetail.sentAt).toLocaleString("ko-KR")}
|
||||||
|
|
@ -650,26 +601,17 @@ export default function SentMailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 답장/전달/삭제 버튼 */}
|
{/* 답장/전달/삭제 버튼 */}
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="mt-4 flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={handleReply}>
|
<Button variant="outline" size="sm" onClick={handleReply}>
|
||||||
<Reply className="w-4 h-4 mr-1" />
|
<Reply className="mr-1 h-4 w-4" />
|
||||||
답장
|
답장
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={handleForward}>
|
<Button variant="outline" size="sm" onClick={handleForward}>
|
||||||
<Forward className="w-4 h-4 mr-1" />
|
<Forward className="mr-1 h-4 w-4" />
|
||||||
전달
|
전달
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="destructive" size="sm" onClick={handleDeleteMail} disabled={deleting}>
|
||||||
variant="destructive"
|
{deleting ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Trash2 className="mr-1 h-4 w-4" />}
|
||||||
size="sm"
|
|
||||||
onClick={handleDeleteMail}
|
|
||||||
disabled={deleting}
|
|
||||||
>
|
|
||||||
{deleting ? (
|
|
||||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
|
||||||
)}
|
|
||||||
삭제
|
삭제
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -680,22 +622,17 @@ export default function SentMailPage() {
|
||||||
<CardContent className="flex-1 overflow-y-auto pt-6">
|
<CardContent className="flex-1 overflow-y-auto pt-6">
|
||||||
{/* 첨부파일 */}
|
{/* 첨부파일 */}
|
||||||
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
|
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
|
||||||
<div className="mb-6 p-4 bg-muted rounded-lg">
|
<div className="bg-muted mb-6 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<Paperclip className="w-4 h-4 text-muted-foreground" />
|
<Paperclip className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">첨부파일 ({selectedMailDetail.attachments.length})</span>
|
||||||
첨부파일 ({selectedMailDetail.attachments.length})
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedMailDetail.attachments.map((file: any, index: number) => (
|
{selectedMailDetail.attachments.map((file: any, index: number) => (
|
||||||
<div
|
<div key={index} className="bg-background flex items-center gap-2 rounded p-2 text-sm">
|
||||||
key={index}
|
<Paperclip className="text-muted-foreground h-4 w-4" />
|
||||||
className="flex items-center gap-2 text-sm bg-background p-2 rounded"
|
|
||||||
>
|
|
||||||
<Paperclip className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<span className="flex-1 truncate">{file.filename || file.name}</span>
|
<span className="flex-1 truncate">{file.filename || file.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
{file.size ? `${(file.size / 1024).toFixed(1)}KB` : ""}
|
{file.size ? `${(file.size / 1024).toFixed(1)}KB` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -713,19 +650,15 @@ export default function SentMailPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="whitespace-pre-wrap text-sm">
|
<div className="text-sm whitespace-pre-wrap">{selectedMailDetail.htmlContent || "(내용 없음)"}</div>
|
||||||
{selectedMailDetail.htmlContent || "(내용 없음)"}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="h-[calc(100vh-500px)] min-h-[400px]">
|
<Card className="h-[calc(100vh-500px)] min-h-[400px]">
|
||||||
<CardContent className="flex flex-col items-center justify-center h-full">
|
<CardContent className="flex h-full flex-col items-center justify-center">
|
||||||
<Eye className="w-16 h-16 text-muted-foreground mb-4" />
|
<Eye className="text-muted-foreground mb-4 h-16 w-16" />
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">메일을 선택하면 내용이 표시됩니다</p>
|
||||||
메일을 선택하면 내용이 표시됩니다
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,15 @@ export default function MailTemplatesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<MailTemplate | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<MailTemplate | null>(null);
|
||||||
const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create');
|
const [editorMode, setEditorMode] = useState<"create" | "edit">("create");
|
||||||
|
|
||||||
// 템플릿 목록 불러오기
|
// 템플릿 목록 불러오기
|
||||||
const loadTemplates = async () => {
|
const loadTemplates = async () => {
|
||||||
|
|
@ -43,7 +43,7 @@ export default function MailTemplatesPage() {
|
||||||
setTemplates(data);
|
setTemplates(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('템플릿 로드 실패:', error);
|
// console.error('템플릿 로드 실패:', error);
|
||||||
alert('템플릿 목록을 불러오는데 실패했습니다.');
|
alert("템플릿 목록을 불러오는데 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -58,8 +58,7 @@ export default function MailTemplatesPage() {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
template.subject.toLowerCase().includes(searchTerm.toLowerCase());
|
template.subject.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
const matchesCategory =
|
const matchesCategory = categoryFilter === "all" || template.category === categoryFilter;
|
||||||
categoryFilter === 'all' || template.category === categoryFilter;
|
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -67,13 +66,13 @@ export default function MailTemplatesPage() {
|
||||||
const categories = Array.from(new Set(templates.map((t) => t.category).filter(Boolean)));
|
const categories = Array.from(new Set(templates.map((t) => t.category).filter(Boolean)));
|
||||||
|
|
||||||
const handleOpenCreateModal = () => {
|
const handleOpenCreateModal = () => {
|
||||||
setEditorMode('create');
|
setEditorMode("create");
|
||||||
setSelectedTemplate(null);
|
setSelectedTemplate(null);
|
||||||
setIsEditorOpen(true);
|
setIsEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenEditModal = (template: MailTemplate) => {
|
const handleOpenEditModal = (template: MailTemplate) => {
|
||||||
setEditorMode('edit');
|
setEditorMode("edit");
|
||||||
setSelectedTemplate(template);
|
setSelectedTemplate(template);
|
||||||
setIsEditorOpen(true);
|
setIsEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
@ -90,9 +89,9 @@ export default function MailTemplatesPage() {
|
||||||
|
|
||||||
const handleSaveTemplate = async (data: CreateMailTemplateDto | UpdateMailTemplateDto) => {
|
const handleSaveTemplate = async (data: CreateMailTemplateDto | UpdateMailTemplateDto) => {
|
||||||
try {
|
try {
|
||||||
if (editorMode === 'create') {
|
if (editorMode === "create") {
|
||||||
await createMailTemplate(data as CreateMailTemplateDto);
|
await createMailTemplate(data as CreateMailTemplateDto);
|
||||||
} else if (editorMode === 'edit' && selectedTemplate) {
|
} else if (editorMode === "edit" && selectedTemplate) {
|
||||||
await updateMailTemplate(selectedTemplate.id, data as UpdateMailTemplateDto);
|
await updateMailTemplate(selectedTemplate.id, data as UpdateMailTemplateDto);
|
||||||
}
|
}
|
||||||
await loadTemplates();
|
await loadTemplates();
|
||||||
|
|
@ -108,10 +107,10 @@ export default function MailTemplatesPage() {
|
||||||
try {
|
try {
|
||||||
await deleteMailTemplate(selectedTemplate.id);
|
await deleteMailTemplate(selectedTemplate.id);
|
||||||
await loadTemplates();
|
await loadTemplates();
|
||||||
alert('템플릿이 삭제되었습니다.');
|
alert("템플릿이 삭제되었습니다.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('템플릿 삭제 실패:', error);
|
// console.error('템플릿 삭제 실패:', error);
|
||||||
alert('템플릿 삭제에 실패했습니다.');
|
alert("템플릿 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -124,18 +123,18 @@ export default function MailTemplatesPage() {
|
||||||
category: template.category,
|
category: template.category,
|
||||||
});
|
});
|
||||||
await loadTemplates();
|
await loadTemplates();
|
||||||
alert('템플릿이 복사되었습니다.');
|
alert("템플릿이 복사되었습니다.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('템플릿 복사 실패:', error);
|
// console.error('템플릿 복사 실패:', error);
|
||||||
alert('템플릿 복사에 실패했습니다.');
|
alert("템플릿 복사에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
<div className="bg-card space-y-4 rounded-lg border p-6">
|
||||||
{/* 브레드크럼브 */}
|
{/* 브레드크럼브 */}
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -144,7 +143,7 @@ export default function MailTemplatesPage() {
|
||||||
>
|
>
|
||||||
메일 관리
|
메일 관리
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-foreground font-medium">템플릿 관리</span>
|
<span className="text-foreground font-medium">템플릿 관리</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -153,25 +152,16 @@ export default function MailTemplatesPage() {
|
||||||
{/* 제목 + 액션 버튼들 */}
|
{/* 제목 + 액션 버튼들 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">메일 템플릿 관리</h1>
|
<h1 className="text-foreground text-3xl font-bold">메일 템플릿 관리</h1>
|
||||||
<p className="mt-2 text-muted-foreground">드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다</p>
|
<p className="text-muted-foreground mt-2">드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={loadTemplates} disabled={loading}>
|
||||||
variant="outline"
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
size="sm"
|
|
||||||
onClick={loadTemplates}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="default" onClick={handleOpenCreateModal}>
|
||||||
variant="default"
|
<Plus className="mr-2 h-4 w-4" />새 템플릿 만들기
|
||||||
onClick={handleOpenCreateModal}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
새 템플릿 만들기
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,20 +171,20 @@ export default function MailTemplatesPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex-1 relative">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
placeholder="템플릿 이름, 제목으로 검색..."
|
placeholder="템플릿 이름, 제목으로 검색..."
|
||||||
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
|
className="focus:ring-primary focus:border-primary bg-background w-full rounded-lg border py-2 pr-4 pl-10 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
|
className="focus:ring-primary focus:border-primary bg-background rounded-lg border px-4 py-2 focus:ring-2"
|
||||||
>
|
>
|
||||||
<option value="all">전체 카테고리</option>
|
<option value="all">전체 카테고리</option>
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
|
|
@ -210,32 +200,26 @@ export default function MailTemplatesPage() {
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 메인 컨텐츠 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex justify-center items-center py-16">
|
<CardContent className="flex items-center justify-center py-16">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : filteredTemplates.length === 0 ? (
|
) : filteredTemplates.length === 0 ? (
|
||||||
<Card className="text-center py-16">
|
<Card className="py-16 text-center">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<FileText className="w-16 h-16 mx-auto mb-4 text-muted-foreground" />
|
<FileText className="text-muted-foreground mx-auto mb-4 h-16 w-16" />
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
{templates.length === 0
|
{templates.length === 0 ? "아직 생성된 템플릿이 없습니다" : "검색 결과가 없습니다"}
|
||||||
? '아직 생성된 템플릿이 없습니다'
|
|
||||||
: '검색 결과가 없습니다'}
|
|
||||||
</p>
|
</p>
|
||||||
{templates.length === 0 && (
|
{templates.length === 0 && (
|
||||||
<Button
|
<Button variant="default" onClick={handleOpenCreateModal}>
|
||||||
variant="default"
|
<Plus className="mr-2 h-4 w-4" />첫 템플릿 만들기
|
||||||
onClick={handleOpenCreateModal}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
첫 템플릿 만들기
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredTemplates.map((template) => (
|
{filteredTemplates.map((template) => (
|
||||||
<MailTemplateCard
|
<MailTemplateCard
|
||||||
key={template.id}
|
key={template.id}
|
||||||
|
|
@ -252,16 +236,14 @@ export default function MailTemplatesPage() {
|
||||||
{/* 안내 정보 */}
|
{/* 안내 정보 */}
|
||||||
<Card className="bg-muted/50">
|
<Card className="bg-muted/50">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center">
|
<CardTitle className="flex items-center text-lg">
|
||||||
<FileText className="w-5 h-5 mr-2 text-foreground" />
|
<FileText className="text-foreground mr-2 h-5 w-5" />
|
||||||
템플릿 디자이너
|
템플릿 디자이너
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-foreground mb-4">
|
<p className="text-foreground mb-4">💡 드래그 앤 드롭으로 손쉽게 메일 템플릿을 만들 수 있어요!</p>
|
||||||
💡 드래그 앤 드롭으로 손쉽게 메일 템플릿을 만들 수 있어요!
|
<ul className="text-muted-foreground space-y-2 text-sm">
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-foreground mr-2">✓</span>
|
<span className="text-foreground mr-2">✓</span>
|
||||||
<span>텍스트, 버튼, 이미지, 여백 컴포넌트 지원</span>
|
<span>텍스트, 버튼, 이미지, 여백 컴포넌트 지원</span>
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,12 @@ export default function TrashPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmptyTrash = async () => {
|
const handleEmptyTrash = async () => {
|
||||||
if (!confirm(`휴지통의 모든 메일(${trashedMails.length}개)을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) return;
|
if (
|
||||||
|
!confirm(
|
||||||
|
`휴지통의 모든 메일(${trashedMails.length}개)을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -80,22 +85,22 @@ export default function TrashPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 space-y-3">
|
<div className="space-y-3 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">휴지통</h1>
|
<h1 className="text-foreground text-3xl font-bold">휴지통</h1>
|
||||||
<p className="mt-2 text-muted-foreground">삭제된 메일은 30일 후 자동으로 영구 삭제됩니다</p>
|
<p className="text-muted-foreground mt-2">삭제된 메일은 30일 후 자동으로 영구 삭제됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
{trashedMails.length > 0 && (
|
{trashedMails.length > 0 && (
|
||||||
<Button variant="destructive" onClick={handleEmptyTrash} className="h-10">
|
<Button variant="destructive" onClick={handleEmptyTrash} className="h-10">
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
휴지통 비우기
|
휴지통 비우기
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -104,7 +109,7 @@ export default function TrashPage() {
|
||||||
{trashedMails.length === 0 ? (
|
{trashedMails.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
|
<Mail className="text-muted-foreground mb-4 h-12 w-12" />
|
||||||
<p className="text-muted-foreground">휴지통이 비어 있습니다</p>
|
<p className="text-muted-foreground">휴지통이 비어 있습니다</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -117,18 +122,14 @@ export default function TrashPage() {
|
||||||
: 30;
|
: 30;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={mail.id} className="hover:shadow-md transition-shadow">
|
<Card key={mail.id} className="transition-shadow hover:shadow-md">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle className="text-lg truncate">
|
<CardTitle className="truncate text-lg">{mail.subject || "(제목 없음)"}</CardTitle>
|
||||||
{mail.subject || "(제목 없음)"}
|
<CardDescription className="mt-1">받는 사람: {mail.to.join(", ") || "(없음)"}</CardDescription>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1">
|
|
||||||
받는 사람: {mail.to.join(", ") || "(없음)"}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="ml-4 flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -137,10 +138,10 @@ export default function TrashPage() {
|
||||||
className="h-8"
|
className="h-8"
|
||||||
>
|
>
|
||||||
{restoring === mail.id ? (
|
{restoring === mail.id ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RotateCcw className="w-4 h-4 mr-1" />
|
<RotateCcw className="mr-1 h-4 w-4" />
|
||||||
복구
|
복구
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -153,10 +154,10 @@ export default function TrashPage() {
|
||||||
className="h-8"
|
className="h-8"
|
||||||
>
|
>
|
||||||
{deleting === mail.id ? (
|
{deleting === mail.id ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="mr-1 h-4 w-4" />
|
||||||
영구 삭제
|
영구 삭제
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -166,16 +167,14 @@ export default function TrashPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">계정: {mail.accountName || mail.accountEmail}</span>
|
||||||
계정: {mail.accountName || mail.accountEmail}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{format(new Date(mail.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
|
{format(new Date(mail.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{daysLeft <= 7 && (
|
{daysLeft <= 7 && (
|
||||||
<div className="flex items-center gap-2 mt-2 text-xs text-amber-600">
|
<div className="mt-2 flex items-center gap-2 text-xs text-amber-600">
|
||||||
<AlertCircle className="w-3 h-3" />
|
<AlertCircle className="h-3 w-3" />
|
||||||
<span>{daysLeft}일 후 자동 삭제됩니다</span>
|
<span>{daysLeft}일 후 자동 삭제됩니다</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -188,5 +187,3 @@ export default function TrashPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -38,7 +25,7 @@ import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Database,
|
Database,
|
||||||
Globe
|
Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||||
|
|
@ -95,20 +82,21 @@ export default function BatchManagementPage() {
|
||||||
|
|
||||||
// 검색어 필터
|
// 검색어 필터
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
filtered = filtered.filter(job =>
|
filtered = filtered.filter(
|
||||||
|
(job) =>
|
||||||
job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
job.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
job.description?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 필터
|
// 상태 필터
|
||||||
if (statusFilter !== "all") {
|
if (statusFilter !== "all") {
|
||||||
filtered = filtered.filter(job => job.is_active === statusFilter);
|
filtered = filtered.filter((job) => job.is_active === statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 타입 필터
|
// 타입 필터
|
||||||
if (typeFilter !== "all") {
|
if (typeFilter !== "all") {
|
||||||
filtered = filtered.filter(job => job.job_type === typeFilter);
|
filtered = filtered.filter((job) => job.job_type === typeFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilteredJobs(filtered);
|
setFilteredJobs(filtered);
|
||||||
|
|
@ -118,19 +106,19 @@ export default function BatchManagementPage() {
|
||||||
setIsBatchTypeModalOpen(true);
|
setIsBatchTypeModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
|
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => {
|
||||||
console.log("배치 타입 선택:", type);
|
console.log("배치 타입 선택:", type);
|
||||||
setIsBatchTypeModalOpen(false);
|
setIsBatchTypeModalOpen(false);
|
||||||
|
|
||||||
if (type === 'db-to-db') {
|
if (type === "db-to-db") {
|
||||||
// 기존 배치 생성 모달 열기
|
// 기존 배치 생성 모달 열기
|
||||||
console.log("DB → DB 배치 모달 열기");
|
console.log("DB → DB 배치 모달 열기");
|
||||||
setSelectedJob(null);
|
setSelectedJob(null);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
} else if (type === 'restapi-to-db') {
|
} else if (type === "restapi-to-db") {
|
||||||
// 새로운 REST API 배치 페이지로 이동
|
// 새로운 REST API 배치 페이지로 이동
|
||||||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
console.log("REST API → DB 페이지로 이동:", "/admin/batch-management-new");
|
||||||
router.push('/admin/batch-management-new');
|
router.push("/admin/batch-management-new");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -177,7 +165,7 @@ export default function BatchManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeBadge = (type: string) => {
|
const getTypeBadge = (type: string) => {
|
||||||
const option = jobTypes.find(opt => opt.value === type);
|
const option = jobTypes.find((opt) => opt.value === type);
|
||||||
const colors = {
|
const colors = {
|
||||||
collection: "bg-blue-100 text-blue-800",
|
collection: "bg-blue-100 text-blue-800",
|
||||||
sync: "bg-purple-100 text-purple-800",
|
sync: "bg-purple-100 text-purple-800",
|
||||||
|
|
@ -211,24 +199,21 @@ export default function BatchManagementPage() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">배치 관리</h1>
|
<h1 className="text-2xl font-bold">배치 관리</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">스케줄된 배치 작업을 관리하고 실행 상태를 모니터링합니다.</p>
|
||||||
스케줄된 배치 작업을 관리하고 실행 상태를 모니터링합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" onClick={() => window.open('/admin/monitoring', '_blank')}>
|
<Button variant="outline" onClick={() => window.open("/admin/monitoring", "_blank")}>
|
||||||
<BarChart3 className="h-4 w-4 mr-2" />
|
<BarChart3 className="mr-2 h-4 w-4" />
|
||||||
모니터링
|
모니터링
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate}>
|
<Button onClick={handleCreate}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="mr-2 h-4 w-4" />새 배치 작업
|
||||||
새 배치 작업
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">총 작업</CardTitle>
|
<CardTitle className="text-sm font-medium">총 작업</CardTitle>
|
||||||
|
|
@ -236,9 +221,7 @@ export default function BatchManagementPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{jobs.length}</div>
|
<div className="text-2xl font-bold">{jobs.length}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">활성: {jobs.filter((j) => j.is_active === "Y").length}개</p>
|
||||||
활성: {jobs.filter(j => j.is_active === 'Y').length}개
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -248,10 +231,8 @@ export default function BatchManagementPage() {
|
||||||
<div className="text-2xl">▶️</div>
|
<div className="text-2xl">▶️</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">{jobs.reduce((sum, job) => sum + job.execution_count, 0)}</div>
|
||||||
{jobs.reduce((sum, job) => sum + job.execution_count, 0)}
|
<p className="text-muted-foreground text-xs">누적 실행 횟수</p>
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -264,7 +245,7 @@ export default function BatchManagementPage() {
|
||||||
<div className="text-2xl font-bold text-green-600">
|
<div className="text-2xl font-bold text-green-600">
|
||||||
{jobs.reduce((sum, job) => sum + job.success_count, 0)}
|
{jobs.reduce((sum, job) => sum + job.success_count, 0)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
<p className="text-muted-foreground text-xs">총 성공 횟수</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -277,7 +258,7 @@ export default function BatchManagementPage() {
|
||||||
<div className="text-2xl font-bold text-red-600">
|
<div className="text-2xl font-bold text-red-600">
|
||||||
{jobs.reduce((sum, job) => sum + job.failure_count, 0)}
|
{jobs.reduce((sum, job) => sum + job.failure_count, 0)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
<p className="text-muted-foreground text-xs">총 실패 횟수</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -288,10 +269,10 @@ export default function BatchManagementPage() {
|
||||||
<CardTitle>필터 및 검색</CardTitle>
|
<CardTitle>필터 및 검색</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="작업명, 설명으로 검색..."
|
placeholder="작업명, 설명으로 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -327,7 +308,7 @@ export default function BatchManagementPage() {
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Button variant="outline" onClick={loadJobs} disabled={isLoading}>
|
<Button variant="outline" onClick={loadJobs} disabled={isLoading}>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -341,12 +322,12 @@ export default function BatchManagementPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8">
|
<div className="py-8 text-center">
|
||||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
<RefreshCw className="mx-auto mb-2 h-8 w-8 animate-spin" />
|
||||||
<p>배치 작업을 불러오는 중...</p>
|
<p>배치 작업을 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredJobs.length === 0 ? (
|
) : filteredJobs.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-muted-foreground py-8 text-center">
|
||||||
{jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."}
|
{jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -369,22 +350,12 @@ export default function BatchManagementPage() {
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{job.job_name}</div>
|
<div className="font-medium">{job.job_name}</div>
|
||||||
{job.description && (
|
{job.description && <div className="text-muted-foreground text-sm">{job.description}</div>}
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{job.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>{getTypeBadge(job.job_type)}</TableCell>
|
||||||
{getTypeBadge(job.job_type)}
|
<TableCell className="font-mono text-sm">{job.schedule_cron || "-"}</TableCell>
|
||||||
</TableCell>
|
<TableCell>{getStatusBadge(job.is_active)}</TableCell>
|
||||||
<TableCell className="font-mono text-sm">
|
|
||||||
{job.schedule_cron || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{getStatusBadge(job.is_active)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div>총 {job.execution_count}회</div>
|
<div>총 {job.execution_count}회</div>
|
||||||
|
|
@ -395,18 +366,21 @@ export default function BatchManagementPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`text-sm font-medium ${
|
<div
|
||||||
getSuccessRate(job) >= 90 ? 'text-green-600' :
|
className={`text-sm font-medium ${
|
||||||
getSuccessRate(job) >= 70 ? 'text-yellow-600' : 'text-red-600'
|
getSuccessRate(job) >= 90
|
||||||
}`}>
|
? "text-green-600"
|
||||||
|
: getSuccessRate(job) >= 70
|
||||||
|
? "text-yellow-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{getSuccessRate(job)}%
|
{getSuccessRate(job)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{job.last_executed_at
|
{job.last_executed_at ? new Date(job.last_executed_at).toLocaleString() : "-"}
|
||||||
? new Date(job.last_executed_at).toLocaleString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -417,18 +391,15 @@ export default function BatchManagementPage() {
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => handleEdit(job)}>
|
<DropdownMenuItem onClick={() => handleEdit(job)}>
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
수정
|
수정
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => handleExecute(job)} disabled={job.is_active !== "Y"}>
|
||||||
onClick={() => handleExecute(job)}
|
<Play className="mr-2 h-4 w-4" />
|
||||||
disabled={job.is_active !== "Y"}
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4 mr-2" />
|
|
||||||
실행
|
실행
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleDelete(job)}>
|
<DropdownMenuItem onClick={() => handleDelete(job)}>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
삭제
|
삭제
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
@ -444,51 +415,48 @@ export default function BatchManagementPage() {
|
||||||
|
|
||||||
{/* 배치 타입 선택 모달 */}
|
{/* 배치 타입 선택 모달 */}
|
||||||
{isBatchTypeModalOpen && (
|
{isBatchTypeModalOpen && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
|
||||||
<Card className="w-full max-w-2xl mx-4">
|
<Card className="mx-4 w-full max-w-2xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{/* DB → DB */}
|
{/* DB → DB */}
|
||||||
<div
|
<div
|
||||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
|
className="cursor-pointer rounded-lg border p-6 transition-all hover:border-blue-500 hover:bg-blue-50"
|
||||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
onClick={() => handleBatchTypeSelect("db-to-db")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center mb-4">
|
<div className="mb-4 flex items-center justify-center">
|
||||||
<Database className="w-8 h-8 text-blue-600 mr-2" />
|
<Database className="mr-2 h-8 w-8 text-blue-600" />
|
||||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
<ArrowRight className="mr-2 h-6 w-6 text-gray-400" />
|
||||||
<Database className="w-8 h-8 text-blue-600" />
|
<Database className="h-8 w-8 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="font-medium text-lg mb-2">DB → DB</div>
|
<div className="mb-2 text-lg font-medium">DB → DB</div>
|
||||||
<div className="text-sm text-gray-500">데이터베이스 간 데이터 동기화</div>
|
<div className="text-sm text-gray-500">데이터베이스 간 데이터 동기화</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* REST API → DB */}
|
{/* REST API → DB */}
|
||||||
<div
|
<div
|
||||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
|
className="cursor-pointer rounded-lg border p-6 transition-all hover:border-green-500 hover:bg-green-50"
|
||||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
onClick={() => handleBatchTypeSelect("restapi-to-db")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center mb-4">
|
<div className="mb-4 flex items-center justify-center">
|
||||||
<Globe className="w-8 h-8 text-green-600 mr-2" />
|
<Globe className="mr-2 h-8 w-8 text-green-600" />
|
||||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
<ArrowRight className="mr-2 h-6 w-6 text-gray-400" />
|
||||||
<Database className="w-8 h-8 text-green-600" />
|
<Database className="h-8 w-8 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="font-medium text-lg mb-2">REST API → DB</div>
|
<div className="mb-2 text-lg font-medium">REST API → DB</div>
|
||||||
<div className="text-sm text-gray-500">REST API에서 데이터베이스로 데이터 수집</div>
|
<div className="text-sm text-gray-500">REST API에서 데이터베이스로 데이터 수집</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center pt-4">
|
<div className="flex justify-center pt-4">
|
||||||
<Button
|
<Button variant="outline" onClick={() => setIsBatchTypeModalOpen(false)}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
|
||||||
>
|
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,12 @@ export default function CascadingManagementPage() {
|
||||||
// URL 쿼리 파라미터에서 탭 설정
|
// URL 쿼리 파라미터에서 탭 설정
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tab = searchParams.get("tab");
|
const tab = searchParams.get("tab");
|
||||||
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(tab)) {
|
if (
|
||||||
|
tab &&
|
||||||
|
["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(
|
||||||
|
tab,
|
||||||
|
)
|
||||||
|
) {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
@ -36,12 +41,12 @@ export default function CascadingManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">연쇄 드롭다운 통합 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">연쇄 드롭다운 통합 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
연쇄 드롭다운, 자동 입력, 다단계 계층, 조건부 필터 등을 관리합니다.
|
연쇄 드롭다운, 자동 입력, 다단계 계층, 조건부 필터 등을 관리합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -112,4 +117,3 @@ export default function CascadingManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,35 +14,11 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Plus, Pencil, Trash2, Search, Save, RefreshCw, Check, ChevronsUpDown, X } from "lucide-react";
|
import { Plus, Pencil, Trash2, Search, Save, RefreshCw, Check, ChevronsUpDown, X } from "lucide-react";
|
||||||
import {
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
Command,
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
categoryValueCascadingApi,
|
categoryValueCascadingApi,
|
||||||
|
|
@ -173,7 +149,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
|
|
||||||
// category 타입 컬럼만 필터링
|
// category 타입 컬럼만 필터링
|
||||||
const categoryColumns = columnsArray.filter(
|
const categoryColumns = columnsArray.filter(
|
||||||
(col: any) => col.input_type === "category" || col.inputType === "category"
|
(col: any) => col.input_type === "category" || col.inputType === "category",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 인터페이스에 맞게 변환
|
// 인터페이스에 맞게 변환
|
||||||
|
|
@ -300,7 +276,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
optionsMap[parentCode] = [];
|
optionsMap[parentCode] = [];
|
||||||
}
|
}
|
||||||
// 중복 체크
|
// 중복 체크
|
||||||
if (!optionsMap[parentCode].some(opt => opt.code === mapping.child_value_code)) {
|
if (!optionsMap[parentCode].some((opt) => opt.code === mapping.child_value_code)) {
|
||||||
optionsMap[parentCode].push({
|
optionsMap[parentCode].push({
|
||||||
code: mapping.child_value_code,
|
code: mapping.child_value_code,
|
||||||
label: mapping.child_value_label || mapping.child_value_code,
|
label: mapping.child_value_label || mapping.child_value_code,
|
||||||
|
|
@ -449,10 +425,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await categoryValueCascadingApi.saveMappings(
|
const response = await categoryValueCascadingApi.saveMappings(selectedGroup.group_id, mappingInputs);
|
||||||
selectedGroup.group_id,
|
|
||||||
mappingInputs
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setIsMappingModalOpen(false);
|
setIsMappingModalOpen(false);
|
||||||
|
|
@ -486,21 +459,21 @@ export default function CategoryValueCascadingTab() {
|
||||||
{/* 설명 */}
|
{/* 설명 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-xl font-semibold">카테고리 값 연쇄관계</h2>
|
<h2 className="text-xl font-semibold">카테고리 값 연쇄관계</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
카테고리 값들 간의 부모-자식 연쇄관계를 정의합니다. 예: 검사유형 선택 시 해당 유형에 맞는 적용대상만 표시
|
카테고리 값들 간의 부모-자식 연쇄관계를 정의합니다. 예: 검사유형 선택 시 해당 유형에 맞는 적용대상만 표시
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-semibold text-destructive">오류</p>
|
<p className="text-destructive text-sm font-semibold">오류</p>
|
||||||
<button onClick={() => setError(null)} className="text-destructive hover:text-destructive/80">
|
<button onClick={() => setError(null)} className="text-destructive hover:text-destructive/80">
|
||||||
x
|
x
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-destructive/80">{error}</p>
|
<p className="text-destructive/80 mt-1 text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -508,7 +481,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
<div className="relative w-full sm:w-[300px]">
|
<div className="relative w-full sm:w-[300px]">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="관계코드, 관계명, 테이블명 검색..."
|
placeholder="관계코드, 관계명, 테이블명 검색..."
|
||||||
value={searchText}
|
value={searchText}
|
||||||
|
|
@ -519,8 +492,8 @@ export default function CategoryValueCascadingTab() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">
|
||||||
총 <span className="font-semibold text-foreground">{filteredGroups.length}</span> 건
|
총 <span className="text-foreground font-semibold">{filteredGroups.length}</span> 건
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => loadGroups()} variant="outline" className="h-10 gap-2 text-sm font-medium">
|
<Button onClick={() => loadGroups()} variant="outline" className="h-10 gap-2 text-sm font-medium">
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
|
@ -534,16 +507,16 @@ export default function CategoryValueCascadingTab() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="rounded-lg border bg-card shadow-sm">
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
<TableHead className="h-12 text-sm font-semibold">관계코드</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">관계코드</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">관계명</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">관계명</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">부모 (테이블.컬럼)</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">부모 (테이블.컬럼)</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">자식 (테이블.컬럼)</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">자식 (테이블.컬럼)</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">사용</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">사용</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold text-center">관리</TableHead>
|
<TableHead className="h-12 text-center text-sm font-semibold">관리</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -551,35 +524,35 @@ export default function CategoryValueCascadingTab() {
|
||||||
Array.from({ length: 5 }).map((_, idx) => (
|
Array.from({ length: 5 }).map((_, idx) => (
|
||||||
<TableRow key={idx} className="border-b">
|
<TableRow key={idx} className="border-b">
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
<div className="bg-muted h-4 w-32 animate-pulse rounded" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
|
<div className="bg-muted h-4 w-40 animate-pulse rounded" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
|
<div className="bg-muted h-4 w-40 animate-pulse rounded" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-6 w-11 animate-pulse rounded-full bg-muted" />
|
<div className="bg-muted h-6 w-11 animate-pulse rounded-full" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
<div className="bg-muted h-8 w-24 animate-pulse rounded" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : filteredGroups.length === 0 ? (
|
) : filteredGroups.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="h-32 text-center text-sm text-muted-foreground">
|
<TableCell colSpan={6} className="text-muted-foreground h-32 text-center text-sm">
|
||||||
등록된 카테고리 값 연쇄관계가 없습니다.
|
등록된 카테고리 값 연쇄관계가 없습니다.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredGroups.map((group) => (
|
filteredGroups.map((group) => (
|
||||||
<TableRow key={group.group_id} className="border-b transition-colors hover:bg-muted/50">
|
<TableRow key={group.group_id} className="hover:bg-muted/50 border-b transition-colors">
|
||||||
<TableCell className="h-16 text-sm font-mono">{group.relation_code}</TableCell>
|
<TableCell className="h-16 font-mono text-sm">{group.relation_code}</TableCell>
|
||||||
<TableCell className="h-16 text-sm font-medium">{group.relation_name}</TableCell>
|
<TableCell className="h-16 text-sm font-medium">{group.relation_name}</TableCell>
|
||||||
<TableCell className="h-16 text-sm">
|
<TableCell className="h-16 text-sm">
|
||||||
<span className="text-muted-foreground">{group.parent_table_name}.</span>
|
<span className="text-muted-foreground">{group.parent_table_name}.</span>
|
||||||
|
|
@ -606,19 +579,14 @@ export default function CategoryValueCascadingTab() {
|
||||||
>
|
>
|
||||||
값 매핑
|
값 매핑
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => openEditModal(group)} className="h-8 w-8">
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => openEditModal(group)}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleDelete(group)}
|
onClick={() => handleDelete(group)}
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive h-8 w-8"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -688,7 +656,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="py-2 text-center text-xs text-muted-foreground">
|
<CommandEmpty className="text-muted-foreground py-2 text-center text-xs">
|
||||||
{tables.length === 0 ? "테이블을 불러오는 중..." : "테이블을 찾을 수 없습니다."}
|
{tables.length === 0 ? "테이블을 불러오는 중..." : "테이블을 찾을 수 없습니다."}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|
@ -697,7 +665,12 @@ export default function CategoryValueCascadingTab() {
|
||||||
key={table.tableName}
|
key={table.tableName}
|
||||||
value={`${table.displayName || ""} ${table.tableName}`}
|
value={`${table.displayName || ""} ${table.tableName}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setFormData({ ...formData, parentTableName: table.tableName, parentColumnName: "", childTableName: table.tableName });
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
parentTableName: table.tableName,
|
||||||
|
parentColumnName: "",
|
||||||
|
childTableName: table.tableName,
|
||||||
|
});
|
||||||
loadColumns(table.tableName, "parent");
|
loadColumns(table.tableName, "parent");
|
||||||
setParentTableOpen(false);
|
setParentTableOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|
@ -706,13 +679,13 @@ export default function CategoryValueCascadingTab() {
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
formData.parentTableName === table.tableName ? "opacity-100" : "opacity-0"
|
formData.parentTableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||||
{table.displayName && table.displayName !== table.tableName && (
|
{table.displayName && table.displayName !== table.tableName && (
|
||||||
<span className="text-[10px] text-muted-foreground">{table.tableName}</span>
|
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|
@ -735,7 +708,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{parentColumns.length === 0 ? (
|
{parentColumns.length === 0 ? (
|
||||||
<div className="px-2 py-1 text-xs text-muted-foreground">
|
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||||||
{formData.parentTableName ? "카테고리 컬럼이 없습니다" : "테이블을 먼저 선택하세요"}
|
{formData.parentTableName ? "카테고리 컬럼이 없습니다" : "테이블을 먼저 선택하세요"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -754,7 +727,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
{/* 자식 옵션 라벨 설정 */}
|
{/* 자식 옵션 라벨 설정 */}
|
||||||
<div className="space-y-2 rounded-md border p-3">
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
<h4 className="text-sm font-medium">하위 옵션 설정</h4>
|
<h4 className="text-sm font-medium">하위 옵션 설정</h4>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
부모 카테고리 값별로 표시할 하위 옵션들의 그룹명을 입력합니다.
|
부모 카테고리 값별로 표시할 하위 옵션들의 그룹명을 입력합니다.
|
||||||
<br />
|
<br />
|
||||||
실제 하위 옵션은 등록 후 "값 매핑" 버튼에서 직접 입력합니다.
|
실제 하위 옵션은 등록 후 "값 매핑" 버튼에서 직접 입력합니다.
|
||||||
|
|
@ -763,7 +736,13 @@ export default function CategoryValueCascadingTab() {
|
||||||
<Label className="text-xs">하위 옵션 라벨 *</Label>
|
<Label className="text-xs">하위 옵션 라벨 *</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.childColumnName}
|
value={formData.childColumnName}
|
||||||
onChange={(e) => setFormData({ ...formData, childColumnName: e.target.value, childTableName: formData.parentTableName })}
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
childColumnName: e.target.value,
|
||||||
|
childTableName: formData.parentTableName,
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="예: 적용대상"
|
placeholder="예: 적용대상"
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
@ -800,7 +779,13 @@ export default function CategoryValueCascadingTab() {
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
disabled={!formData.relationCode || !formData.relationName || !formData.parentTableName || !formData.parentColumnName || !formData.childColumnName}
|
disabled={
|
||||||
|
!formData.relationCode ||
|
||||||
|
!formData.relationName ||
|
||||||
|
!formData.parentTableName ||
|
||||||
|
!formData.parentColumnName ||
|
||||||
|
!formData.childColumnName
|
||||||
|
}
|
||||||
>
|
>
|
||||||
등록
|
등록
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -813,9 +798,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">카테고리 값 연쇄관계 수정</DialogTitle>
|
<DialogTitle className="text-base sm:text-lg">카테고리 값 연쇄관계 수정</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">연쇄관계 설정을 수정합니다.</DialogDescription>
|
||||||
연쇄관계 설정을 수정합니다.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -823,11 +806,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm">관계코드</Label>
|
<Label className="text-xs sm:text-sm">관계코드</Label>
|
||||||
<Input
|
<Input value={formData.relationCode} disabled className="bg-muted h-8 text-xs sm:h-10 sm:text-sm" />
|
||||||
value={formData.relationCode}
|
|
||||||
disabled
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm bg-muted"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm">관계명 *</Label>
|
<Label className="text-xs sm:text-sm">관계명 *</Label>
|
||||||
|
|
@ -849,8 +828,8 @@ export default function CategoryValueCascadingTab() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 부모/자식 설정 - 수정 불가 표시 */}
|
{/* 부모/자식 설정 - 수정 불가 표시 */}
|
||||||
<div className="space-y-2 rounded-md border p-3 bg-muted/30">
|
<div className="bg-muted/30 space-y-2 rounded-md border p-3">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">부모/자식 설정 (수정 불가)</h4>
|
<h4 className="text-muted-foreground text-sm font-medium">부모/자식 설정 (수정 불가)</h4>
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">부모: </span>
|
<span className="text-muted-foreground">부모: </span>
|
||||||
|
|
@ -890,10 +869,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleUpdate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
onClick={handleUpdate}
|
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
||||||
>
|
|
||||||
저장
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
@ -902,11 +878,9 @@ export default function CategoryValueCascadingTab() {
|
||||||
|
|
||||||
{/* 값 매핑 모달 */}
|
{/* 값 매핑 모달 */}
|
||||||
<Dialog open={isMappingModalOpen} onOpenChange={setIsMappingModalOpen}>
|
<Dialog open={isMappingModalOpen} onOpenChange={setIsMappingModalOpen}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden flex flex-col">
|
<DialogContent className="flex max-h-[90vh] max-w-[95vw] flex-col overflow-hidden sm:max-w-[800px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">
|
<DialogTitle className="text-base sm:text-lg">하위 옵션 설정 - {selectedGroup?.relation_name}</DialogTitle>
|
||||||
하위 옵션 설정 - {selectedGroup?.relation_name}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
각 부모 카테고리 값에 대해 하위 옵션을 직접 입력합니다.
|
각 부모 카테고리 값에 대해 하위 옵션을 직접 입력합니다.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
@ -914,7 +888,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{parentValues.length === 0 ? (
|
{parentValues.length === 0 ? (
|
||||||
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex h-32 items-center justify-center text-sm">
|
||||||
부모 카테고리 값이 등록되지 않았습니다.
|
부모 카테고리 값이 등록되지 않았습니다.
|
||||||
<br />
|
<br />
|
||||||
먼저 카테고리 관리에서 "{selectedGroup?.parent_column_name}" 컬럼의 값을 등록하세요.
|
먼저 카테고리 관리에서 "{selectedGroup?.parent_column_name}" 컬럼의 값을 등록하세요.
|
||||||
|
|
@ -925,7 +899,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
<div key={parent.value} className="space-y-3 rounded-md border p-4">
|
<div key={parent.value} className="space-y-3 rounded-md border p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-sm font-semibold">{parent.label}</h4>
|
<h4 className="text-sm font-semibold">{parent.label}</h4>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
{(childOptionsMap[parent.value] || []).length}개 옵션
|
{(childOptionsMap[parent.value] || []).length}개 옵션
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -934,9 +908,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={newOptionInputs[parent.value] || ""}
|
value={newOptionInputs[parent.value] || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setNewOptionInputs((prev) => ({ ...prev, [parent.value]: e.target.value }))}
|
||||||
setNewOptionInputs((prev) => ({ ...prev, [parent.value]: e.target.value }))
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// 한글 IME 조합 중에는 무시 (마지막 글자 중복 방지)
|
// 한글 IME 조합 중에는 무시 (마지막 글자 중복 방지)
|
||||||
if (e.nativeEvent.isComposing) return;
|
if (e.nativeEvent.isComposing) return;
|
||||||
|
|
@ -948,11 +920,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
placeholder="하위 옵션 입력 후 Enter 또는 추가 버튼"
|
placeholder="하위 옵션 입력 후 Enter 또는 추가 버튼"
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button size="sm" onClick={() => addChildOption(parent.value)} className="h-8 sm:h-10">
|
||||||
size="sm"
|
|
||||||
onClick={() => addChildOption(parent.value)}
|
|
||||||
className="h-8 sm:h-10"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -960,22 +928,19 @@ export default function CategoryValueCascadingTab() {
|
||||||
{/* 등록된 하위 옵션 목록 */}
|
{/* 등록된 하위 옵션 목록 */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(childOptionsMap[parent.value] || []).map((option) => (
|
{(childOptionsMap[parent.value] || []).map((option) => (
|
||||||
<div
|
<div key={option.code} className="bg-muted flex items-center gap-1 rounded-md px-2 py-1">
|
||||||
key={option.code}
|
|
||||||
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1"
|
|
||||||
>
|
|
||||||
<span className="text-xs">{option.label}</span>
|
<span className="text-xs">{option.label}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeChildOption(parent.value, option.code)}
|
onClick={() => removeChildOption(parent.value, option.code)}
|
||||||
className="rounded-full hover:bg-muted-foreground/20 p-0.5"
|
className="hover:bg-muted-foreground/20 rounded-full p-0.5"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3 text-muted-foreground" />
|
<X className="text-muted-foreground h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(childOptionsMap[parent.value] || []).length === 0 && (
|
{(childOptionsMap[parent.value] || []).length === 0 && (
|
||||||
<span className="text-xs text-muted-foreground">등록된 하위 옵션이 없습니다</span>
|
<span className="text-muted-foreground text-xs">등록된 하위 옵션이 없습니다</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -984,7 +949,7 @@ export default function CategoryValueCascadingTab() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0 border-t pt-4">
|
<DialogFooter className="gap-2 border-t pt-4 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsMappingModalOpen(false)}
|
onClick={() => setIsMappingModalOpen(false)}
|
||||||
|
|
@ -1006,4 +971,3 @@ export default function CategoryValueCascadingTab() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
|
|
@ -34,21 +28,9 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
import { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { cascadingConditionApi, CascadingCondition, CONDITION_OPERATORS } from "@/lib/api/cascadingCondition";
|
||||||
import {
|
|
||||||
cascadingConditionApi,
|
|
||||||
CascadingCondition,
|
|
||||||
CONDITION_OPERATORS,
|
|
||||||
} from "@/lib/api/cascadingCondition";
|
|
||||||
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
||||||
|
|
||||||
export default function ConditionTab() {
|
export default function ConditionTab() {
|
||||||
|
|
@ -115,7 +97,7 @@ export default function ConditionTab() {
|
||||||
(c) =>
|
(c) =>
|
||||||
c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) ||
|
c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
c.conditionField?.toLowerCase().includes(searchText.toLowerCase())
|
c.conditionField?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 모달 열기 (생성)
|
// 모달 열기 (생성)
|
||||||
|
|
@ -171,7 +153,7 @@ export default function ConditionTab() {
|
||||||
toast.error(response.error || "삭제에 실패했습니다.");
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast("조건 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
setDeletingConditionId(null);
|
setDeletingConditionId(null);
|
||||||
|
|
@ -207,7 +189,7 @@ export default function ConditionTab() {
|
||||||
toast.error(response.error || "저장에 실패했습니다.");
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast("조건 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -223,7 +205,7 @@ export default function ConditionTab() {
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="조건명, 관계 코드, 조건 필드로 검색..."
|
placeholder="조건명, 관계 코드, 조건 필드로 검색..."
|
||||||
value={searchText}
|
value={searchText}
|
||||||
|
|
@ -253,8 +235,7 @@ export default function ConditionTab() {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleOpenCreate}>
|
<Button onClick={handleOpenCreate}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />새 규칙 추가
|
||||||
새 규칙 추가
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -324,11 +305,7 @@ export default function ConditionTab() {
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}>
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(condition.conditionId!)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleDeleteConfirm(condition.conditionId!)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -345,9 +322,7 @@ export default function ConditionTab() {
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle>
|
<DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>특정 필드 값에 따라 드롭다운 옵션을 필터링하는 규칙을 설정합니다.</DialogDescription>
|
||||||
특정 필드 값에 따라 드롭다운 옵션을 필터링하는 규칙을 설정합니다.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -20,22 +14,12 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react";
|
import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import {
|
import { hierarchyColumnApi, HierarchyColumnGroup, CreateHierarchyGroupRequest } from "@/lib/api/hierarchyColumn";
|
||||||
hierarchyColumnApi,
|
|
||||||
HierarchyColumnGroup,
|
|
||||||
CreateHierarchyGroupRequest,
|
|
||||||
} from "@/lib/api/hierarchyColumn";
|
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
import apiClient from "@/lib/api/client";
|
import apiClient from "@/lib/api/client";
|
||||||
|
|
||||||
|
|
@ -130,7 +114,7 @@ export default function HierarchyColumnTab() {
|
||||||
response.data.map((cat: any) => ({
|
response.data.map((cat: any) => ({
|
||||||
categoryCode: cat.categoryCode || cat.category_code,
|
categoryCode: cat.categoryCode || cat.category_code,
|
||||||
categoryName: cat.categoryName || cat.category_name,
|
categoryName: cat.categoryName || cat.category_name,
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -328,9 +312,7 @@ export default function HierarchyColumnTab() {
|
||||||
const handleMappingChange = (depth: number, field: string, value: any) => {
|
const handleMappingChange = (depth: number, field: string, value: any) => {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
mappings: prev.mappings.map((m) =>
|
mappings: prev.mappings.map((m) => (m.depth === depth ? { ...m, [field]: value } : m)),
|
||||||
m.depth === depth ? { ...m, [field]: value } : m
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -340,7 +322,7 @@ export default function HierarchyColumnTab() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">계층구조 컬럼 그룹</h2>
|
<h2 className="text-xl font-semibold">계층구조 컬럼 그룹</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
공통코드 계층구조를 테이블 컬럼에 매핑하여 대분류/중분류/소분류를 각각 별도 컬럼에 저장합니다.
|
공통코드 계층구조를 테이블 컬럼에 매핑하여 대분류/중분류/소분류를 각각 별도 컬럼에 저장합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -350,8 +332,7 @@ export default function HierarchyColumnTab() {
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={openCreateModal}>
|
<Button size="sm" onClick={openCreateModal}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />새 그룹 추가
|
||||||
새 그룹 추가
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -360,23 +341,22 @@ export default function HierarchyColumnTab() {
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<span className="ml-2 text-muted-foreground">로딩 중...</span>
|
<span className="text-muted-foreground ml-2">로딩 중...</span>
|
||||||
</div>
|
</div>
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Layers className="h-12 w-12 text-muted-foreground" />
|
<Layers className="text-muted-foreground h-12 w-12" />
|
||||||
<p className="mt-4 text-muted-foreground">계층구조 컬럼 그룹이 없습니다.</p>
|
<p className="text-muted-foreground mt-4">계층구조 컬럼 그룹이 없습니다.</p>
|
||||||
<Button className="mt-4" onClick={openCreateModal}>
|
<Button className="mt-4" onClick={openCreateModal}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />첫 번째 그룹 만들기
|
||||||
첫 번째 그룹 만들기
|
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<Card key={group.group_id} className="hover:shadow-md transition-shadow">
|
<Card key={group.group_id} className="transition-shadow hover:shadow-md">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -387,7 +367,12 @@ export default function HierarchyColumnTab() {
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEditModal(group)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEditModal(group)}>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => openDeleteDialog(group)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive h-8 w-8"
|
||||||
|
onClick={() => openDeleteDialog(group)}
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -395,7 +380,7 @@ export default function HierarchyColumnTab() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Database className="h-4 w-4 text-muted-foreground" />
|
<Database className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="font-medium">{group.table_name}</span>
|
<span className="font-medium">{group.table_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -409,7 +394,7 @@ export default function HierarchyColumnTab() {
|
||||||
<Badge variant="outline" className="w-14 justify-center">
|
<Badge variant="outline" className="w-14 justify-center">
|
||||||
{mapping.level_label}
|
{mapping.level_label}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-mono text-muted-foreground">{mapping.column_name}</span>
|
<span className="text-muted-foreground font-mono">{mapping.column_name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -425,9 +410,7 @@ export default function HierarchyColumnTab() {
|
||||||
<DialogContent className="max-w-[600px]">
|
<DialogContent className="max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"}</DialogTitle>
|
<DialogTitle>{isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>공통코드 계층구조를 테이블 컬럼에 매핑합니다.</DialogDescription>
|
||||||
공통코드 계층구조를 테이블 컬럼에 매핑합니다.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
|
|
@ -474,7 +457,9 @@ export default function HierarchyColumnTab() {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{loadingCategories ? (
|
{loadingCategories ? (
|
||||||
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
<SelectItem value="_loading" disabled>
|
||||||
|
로딩 중...
|
||||||
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
categories.map((cat) => (
|
categories.map((cat) => (
|
||||||
<SelectItem key={cat.categoryCode} value={cat.categoryCode}>
|
<SelectItem key={cat.categoryCode} value={cat.categoryCode}>
|
||||||
|
|
@ -497,7 +482,9 @@ export default function HierarchyColumnTab() {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{loadingTables ? (
|
{loadingTables ? (
|
||||||
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
<SelectItem value="_loading" disabled>
|
||||||
|
로딩 중...
|
||||||
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
tables.map((table) => (
|
tables.map((table) => (
|
||||||
<SelectItem key={table.tableName} value={table.tableName}>
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
|
@ -530,18 +517,14 @@ export default function HierarchyColumnTab() {
|
||||||
{/* 컬럼 매핑 */}
|
{/* 컬럼 매핑 */}
|
||||||
<div className="space-y-3 border-t pt-4">
|
<div className="space-y-3 border-t pt-4">
|
||||||
<Label className="text-base font-medium">컬럼 매핑</Label>
|
<Label className="text-base font-medium">컬럼 매핑</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">각 계층 레벨에 저장할 컬럼을 선택합니다.</p>
|
||||||
각 계층 레벨에 저장할 컬럼을 선택합니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{formData.mappings
|
{formData.mappings
|
||||||
.filter((m) => m.depth <= formData.maxDepth)
|
.filter((m) => m.depth <= formData.maxDepth)
|
||||||
.map((mapping) => (
|
.map((mapping) => (
|
||||||
<div key={mapping.depth} className="grid grid-cols-4 gap-2 items-center">
|
<div key={mapping.depth} className="grid grid-cols-4 items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={mapping.depth === 1 ? "default" : "outline"}>
|
<Badge variant={mapping.depth === 1 ? "default" : "outline"}>{mapping.depth}단계</Badge>
|
||||||
{mapping.depth}단계
|
|
||||||
</Badge>
|
|
||||||
<Input
|
<Input
|
||||||
value={mapping.levelLabel}
|
value={mapping.levelLabel}
|
||||||
onChange={(e) => handleMappingChange(mapping.depth, "levelLabel", e.target.value)}
|
onChange={(e) => handleMappingChange(mapping.depth, "levelLabel", e.target.value)}
|
||||||
|
|
@ -551,7 +534,9 @@ export default function HierarchyColumnTab() {
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={mapping.columnName || "_none"}
|
value={mapping.columnName || "_none"}
|
||||||
onValueChange={(value) => handleMappingChange(mapping.depth, "columnName", value === "_none" ? "" : value)}
|
onValueChange={(value) =>
|
||||||
|
handleMappingChange(mapping.depth, "columnName", value === "_none" ? "" : value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="컬럼 선택" />
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
|
@ -559,7 +544,9 @@ export default function HierarchyColumnTab() {
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="_none">컬럼 선택</SelectItem>
|
<SelectItem value="_none">컬럼 선택</SelectItem>
|
||||||
{loadingColumns ? (
|
{loadingColumns ? (
|
||||||
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
<SelectItem value="_loading" disabled>
|
||||||
|
로딩 중...
|
||||||
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
columns.map((col) => (
|
columns.map((col) => (
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
|
@ -582,7 +569,7 @@ export default function HierarchyColumnTab() {
|
||||||
onChange={(e) => handleMappingChange(mapping.depth, "isRequired", e.target.checked)}
|
onChange={(e) => handleMappingChange(mapping.depth, "isRequired", e.target.checked)}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">필수</span>
|
<span className="text-muted-foreground text-xs">필수</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -593,9 +580,7 @@ export default function HierarchyColumnTab() {
|
||||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave}>
|
<Button onClick={handleSave}>{isEditing ? "수정" : "생성"}</Button>
|
||||||
{isEditing ? "수정" : "생성"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
@ -607,8 +592,7 @@ export default function HierarchyColumnTab() {
|
||||||
<DialogTitle>계층구조 그룹 삭제</DialogTitle>
|
<DialogTitle>계층구조 그룹 삭제</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
"{selectedGroup?.group_name}" 그룹을 삭제하시겠습니까?
|
"{selectedGroup?.group_name}" 그룹을 삭제하시겠습니까?
|
||||||
<br />
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
이 작업은 되돌릴 수 없습니다.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
@ -624,4 +608,3 @@ export default function HierarchyColumnTab() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,9 +221,9 @@ export default function LayoutManagementPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">레이아웃 관리</h1>
|
<h1 className="text-3xl font-bold text-gray-900">레이아웃 관리</h1>
|
||||||
<p className="mt-2 text-gray-600">화면 레이아웃을 생성하고 관리합니다</p>
|
<p className="mt-2 text-gray-600">화면 레이아웃을 생성하고 관리합니다</p>
|
||||||
|
|
|
||||||
|
|
@ -876,12 +876,12 @@ export default function MenuPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">메뉴 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">메뉴 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">시스템 메뉴를 관리하고 화면을 할당합니다</p>
|
<p className="text-muted-foreground text-sm">시스템 메뉴를 관리하고 화면을 할당합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 메인 컨텐츠 */}
|
||||||
|
|
@ -895,7 +895,7 @@ export default function MenuPage() {
|
||||||
{/* 메뉴 타입 선택 카드들 */}
|
{/* 메뉴 타입 선택 카드들 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md ${
|
className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all hover:shadow-md ${
|
||||||
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-border"
|
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-border"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleMenuTypeChange("admin")}
|
onClick={() => handleMenuTypeChange("admin")}
|
||||||
|
|
@ -903,7 +903,7 @@ export default function MenuPage() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.admin")}</h4>
|
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.admin")}</h4>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
{getUITextSync("menu.management.admin.description")}
|
{getUITextSync("menu.management.admin.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -914,7 +914,7 @@ export default function MenuPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md ${
|
className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all hover:shadow-md ${
|
||||||
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-border"
|
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-border"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleMenuTypeChange("user")}
|
onClick={() => handleMenuTypeChange("user")}
|
||||||
|
|
@ -922,7 +922,7 @@ export default function MenuPage() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.user")}</h4>
|
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.user")}</h4>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
{getUITextSync("menu.management.user.description")}
|
{getUITextSync("menu.management.user.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -953,7 +953,7 @@ export default function MenuPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
|
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
|
||||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
|
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
|
||||||
{selectedCompany === "all"
|
{selectedCompany === "all"
|
||||||
|
|
@ -974,7 +974,7 @@ export default function MenuPage() {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isCompanyDropdownOpen && (
|
{isCompanyDropdownOpen && (
|
||||||
<div className="absolute top-full left-0 z-[100] mt-1 w-full min-w-[200px] rounded-md border bg-popover text-popover-foreground shadow-lg">
|
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-[100] mt-1 w-full min-w-[200px] rounded-md border shadow-lg">
|
||||||
<div className="border-b p-2">
|
<div className="border-b p-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={getUITextSync("filter.company.search")}
|
placeholder={getUITextSync("filter.company.search")}
|
||||||
|
|
@ -987,7 +987,7 @@ export default function MenuPage() {
|
||||||
|
|
||||||
<div className="max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCompany("all");
|
setSelectedCompany("all");
|
||||||
setIsCompanyDropdownOpen(false);
|
setIsCompanyDropdownOpen(false);
|
||||||
|
|
@ -997,7 +997,7 @@ export default function MenuPage() {
|
||||||
{getUITextSync("filter.company.all")}
|
{getUITextSync("filter.company.all")}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCompany("*");
|
setSelectedCompany("*");
|
||||||
setIsCompanyDropdownOpen(false);
|
setIsCompanyDropdownOpen(false);
|
||||||
|
|
@ -1017,7 +1017,7 @@ export default function MenuPage() {
|
||||||
.map((company, index) => (
|
.map((company, index) => (
|
||||||
<div
|
<div
|
||||||
key={company.code || `company-${index}`}
|
key={company.code || `company-${index}`}
|
||||||
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCompany(company.code);
|
setSelectedCompany(company.code);
|
||||||
setIsCompanyDropdownOpen(false);
|
setIsCompanyDropdownOpen(false);
|
||||||
|
|
@ -1058,7 +1058,11 @@ export default function MenuPage() {
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 최상위 메뉴 추가 */}
|
{/* 최상위 메뉴 추가 */}
|
||||||
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="h-10 gap-2 text-sm font-medium">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAddTopLevelMenu()}
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
{getUITextSync("button.add.top.level")}
|
{getUITextSync("button.add.top.level")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,7 @@ import React, { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -58,13 +51,13 @@ export default function MonitoringPage() {
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case "completed":
|
||||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||||
case 'failed':
|
case "failed":
|
||||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||||
case 'running':
|
case "running":
|
||||||
return <Play className="h-4 w-4 text-blue-500" />;
|
return <Play className="h-4 w-4 text-blue-500" />;
|
||||||
case 'pending':
|
case "pending":
|
||||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||||
default:
|
default:
|
||||||
return <Clock className="h-4 w-4 text-gray-500" />;
|
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||||
|
|
@ -110,9 +103,9 @@ export default function MonitoringPage() {
|
||||||
|
|
||||||
if (!monitoring) {
|
if (!monitoring) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
<RefreshCw className="mx-auto mb-2 h-8 w-8 animate-spin" />
|
||||||
<p>모니터링 데이터를 불러오는 중...</p>
|
<p>모니터링 데이터를 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -121,13 +114,11 @@ export default function MonitoringPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">모니터링</h1>
|
<h1 className="text-2xl font-bold">모니터링</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">배치 작업 실행 상태를 실시간으로 모니터링합니다.</p>
|
||||||
배치 작업 실행 상태를 실시간으로 모니터링합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모니터링 대시보드 */}
|
{/* 모니터링 대시보드 */}
|
||||||
|
|
@ -142,23 +133,18 @@ export default function MonitoringPage() {
|
||||||
onClick={toggleAutoRefresh}
|
onClick={toggleAutoRefresh}
|
||||||
className={autoRefresh ? "bg-accent text-primary" : ""}
|
className={autoRefresh ? "bg-accent text-primary" : ""}
|
||||||
>
|
>
|
||||||
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
{autoRefresh ? <Pause className="mr-1 h-4 w-4" /> : <Play className="mr-1 h-4 w-4" />}
|
||||||
자동 새로고침
|
자동 새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
|
||||||
variant="outline"
|
<RefreshCw className={`mr-1 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">총 작업 수</CardTitle>
|
<CardTitle className="text-sm font-medium">총 작업 수</CardTitle>
|
||||||
|
|
@ -166,9 +152,7 @@ export default function MonitoringPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{monitoring.total_jobs}</div>
|
<div className="text-2xl font-bold">{monitoring.total_jobs}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">활성: {monitoring.active_jobs}개</p>
|
||||||
활성: {monitoring.active_jobs}개
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -178,10 +162,8 @@ export default function MonitoringPage() {
|
||||||
<div className="text-2xl">🔄</div>
|
<div className="text-2xl">🔄</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-primary">{monitoring.running_jobs}</div>
|
<div className="text-primary text-2xl font-bold">{monitoring.running_jobs}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">현재 실행 중인 작업</p>
|
||||||
현재 실행 중인 작업
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -192,9 +174,7 @@ export default function MonitoringPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
|
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">성공률: {getSuccessRate()}%</p>
|
||||||
성공률: {getSuccessRate()}%
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -204,10 +184,8 @@ export default function MonitoringPage() {
|
||||||
<div className="text-2xl">❌</div>
|
<div className="text-2xl">❌</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-destructive">{monitoring.failed_jobs_today}</div>
|
<div className="text-destructive text-2xl font-bold">{monitoring.failed_jobs_today}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">주의가 필요한 작업</p>
|
||||||
주의가 필요한 작업
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -224,9 +202,7 @@ export default function MonitoringPage() {
|
||||||
<span>실패: {monitoring.failed_jobs_today}건</span>
|
<span>실패: {monitoring.failed_jobs_today}건</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={getSuccessRate()} className="h-2" />
|
<Progress value={getSuccessRate()} className="h-2" />
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-center text-sm">{getSuccessRate()}% 성공률</div>
|
||||||
{getSuccessRate()}% 성공률
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -238,9 +214,7 @@ export default function MonitoringPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{monitoring.recent_executions.length === 0 ? (
|
{monitoring.recent_executions.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-muted-foreground py-8 text-center">최근 실행 이력이 없습니다.</div>
|
||||||
최근 실행 이력이 없습니다.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|
@ -264,25 +238,17 @@ export default function MonitoringPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono">#{execution.job_id}</TableCell>
|
<TableCell className="font-mono">#{execution.job_id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{execution.started_at
|
{execution.started_at ? new Date(execution.started_at).toLocaleString() : "-"}
|
||||||
? new Date(execution.started_at).toLocaleString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{execution.completed_at
|
{execution.completed_at ? new Date(execution.completed_at).toLocaleString() : "-"}
|
||||||
? new Date(execution.completed_at).toLocaleString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{execution.execution_time_ms
|
{execution.execution_time_ms ? formatDuration(execution.execution_time_ms) : "-"}
|
||||||
? formatDuration(execution.execution_time_ms)
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-xs">
|
<TableCell className="max-w-xs">
|
||||||
{execution.error_message ? (
|
{execution.error_message ? (
|
||||||
<span className="text-destructive text-sm truncate block">
|
<span className="text-destructive block truncate text-sm">{execution.error_message}</span>
|
||||||
{execution.error_message}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
"-"
|
"-"
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
||||||
|
|
||||||
{/* 주요 관리 기능 */}
|
{/* 주요 관리 기능 */}
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,18 @@ import { DashboardTopMenu } from "@/components/admin/dashboard/DashboardTopMenu"
|
||||||
import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar";
|
import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar";
|
||||||
import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal";
|
import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal";
|
||||||
import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types";
|
import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types";
|
||||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "@/components/admin/dashboard/gridUtils";
|
import {
|
||||||
|
GRID_CONFIG,
|
||||||
|
snapToGrid,
|
||||||
|
snapSizeToGrid,
|
||||||
|
calculateCellSize,
|
||||||
|
calculateBoxSize,
|
||||||
|
} from "@/components/admin/dashboard/gridUtils";
|
||||||
import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector";
|
import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector";
|
||||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts";
|
||||||
import {
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -659,9 +659,7 @@ export default function DashboardDesignerPage({ params }: { params: Promise<{ id
|
||||||
<CheckCircle2 className="text-success h-6 w-6" />
|
<CheckCircle2 className="text-success h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
||||||
<DialogDescription className="text-center">
|
<DialogDescription className="text-center">대시보드가 성공적으로 저장되었습니다.</DialogDescription>
|
||||||
대시보드가 성공적으로 저장되었습니다.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-center pt-4">
|
<div className="flex justify-center pt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,11 @@ export default function DashboardListPage() {
|
||||||
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
|
<Button
|
||||||
<Plus className="h-4 w-4" />
|
onClick={() => router.push("/admin/screenMng/dashboardList/new")}
|
||||||
새 대시보드 생성
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import { Plus, RefreshCw, Search, Smartphone, Eye, Settings, LayoutGrid, GitBranch } from "lucide-react";
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
Smartphone,
|
|
||||||
Eye,
|
|
||||||
Settings,
|
|
||||||
LayoutGrid,
|
|
||||||
GitBranch,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { PopDesigner } from "@/components/pop/designer";
|
import { PopDesigner } from "@/components/pop/designer";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
@ -28,7 +18,6 @@ import {
|
||||||
PopScreenPreview,
|
PopScreenPreview,
|
||||||
PopScreenFlowView,
|
PopScreenFlowView,
|
||||||
PopScreenSettingModal,
|
PopScreenSettingModal,
|
||||||
PopDeployModal,
|
|
||||||
} from "@/components/pop/management";
|
} from "@/components/pop/management";
|
||||||
import { PopScreenGroup } from "@/lib/api/popScreenGroup";
|
import { PopScreenGroup } from "@/lib/api/popScreenGroup";
|
||||||
|
|
||||||
|
|
@ -64,10 +53,6 @@ export default function PopScreenManagementPage() {
|
||||||
// UI 상태
|
// UI 상태
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
||||||
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false);
|
|
||||||
const [deployGroupScreens, setDeployGroupScreens] = useState<ScreenDefinition[]>([]);
|
|
||||||
const [deployGroupName, setDeployGroupName] = useState("");
|
|
||||||
const [deployGroupInfo, setDeployGroupInfo] = useState<any>(undefined);
|
|
||||||
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
|
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
|
||||||
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
|
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
|
||||||
|
|
||||||
|
|
@ -199,7 +184,7 @@ export default function PopScreenManagementPage() {
|
||||||
|
|
||||||
if (isDesignMode && selectedScreen) {
|
if (isDesignMode && selectedScreen) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-background">
|
<div className="bg-background fixed inset-0 z-50">
|
||||||
<PopDesigner
|
<PopDesigner
|
||||||
selectedScreen={selectedScreen}
|
selectedScreen={selectedScreen}
|
||||||
onBackToList={() => goToStep("list")}
|
onBackToList={() => goToStep("list")}
|
||||||
|
|
@ -208,13 +193,6 @@ export default function PopScreenManagementPage() {
|
||||||
...selectedScreen,
|
...selectedScreen,
|
||||||
...updatedFields,
|
...updatedFields,
|
||||||
});
|
});
|
||||||
setScreens((prev) =>
|
|
||||||
prev.map((s) =>
|
|
||||||
s.screenId === selectedScreen.screenId
|
|
||||||
? { ...s, ...updatedFields }
|
|
||||||
: s
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -226,9 +204,9 @@ export default function PopScreenManagementPage() {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
<div className="bg-background flex h-screen flex-col overflow-hidden">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="shrink-0 border-b bg-background px-6 py-4">
|
<div className="bg-background shrink-0 border-b px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -238,7 +216,7 @@ export default function PopScreenManagementPage() {
|
||||||
모바일/태블릿
|
모바일/태블릿
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
POP 화면을 카테고리별로 관리하고 모바일/태블릿에 최적화된 화면을 설계합니다
|
POP 화면을 카테고리별로 관리하고 모바일/태블릿에 최적화된 화면을 설계합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -248,24 +226,8 @@ export default function PopScreenManagementPage() {
|
||||||
<Button variant="outline" size="icon" onClick={loadScreens}>
|
<Button variant="outline" size="icon" onClick={loadScreens}>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{selectedScreen && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setDeployGroupScreens([]);
|
|
||||||
setDeployGroupName("");
|
|
||||||
setDeployGroupInfo(undefined);
|
|
||||||
setIsDeployModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />새 POP 화면
|
||||||
새 POP 화면
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -274,38 +236,37 @@ export default function PopScreenManagementPage() {
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 */}
|
||||||
{popScreenCount === 0 ? (
|
{popScreenCount === 0 ? (
|
||||||
// POP 화면이 없을 때 빈 상태 표시
|
// POP 화면이 없을 때 빈 상태 표시
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
|
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
||||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
<div className="bg-muted mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
<Smartphone className="h-8 w-8 text-muted-foreground" />
|
<Smartphone className="text-muted-foreground h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">POP 화면이 없습니다</h3>
|
<h3 className="mb-2 text-lg font-semibold">POP 화면이 없습니다</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
<p className="text-muted-foreground mb-6 max-w-md text-sm">
|
||||||
아직 생성된 POP 화면이 없습니다.
|
아직 생성된 POP 화면이 없습니다.
|
||||||
<br />
|
<br />
|
||||||
"새 POP 화면" 버튼을 클릭하여 모바일/태블릿용 화면을 만들어보세요.
|
"새 POP 화면" 버튼을 클릭하여 모바일/태블릿용 화면을 만들어보세요.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />새 POP 화면 만들기
|
||||||
새 POP 화면 만들기
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-hidden flex">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 왼쪽: 카테고리 트리 + 화면 목록 */}
|
{/* 왼쪽: 카테고리 트리 + 화면 목록 */}
|
||||||
<div className="w-[320px] min-w-[280px] max-w-[400px] flex flex-col border-r bg-background overflow-hidden">
|
<div className="bg-background flex w-[320px] max-w-[400px] min-w-[280px] flex-col border-r">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="shrink-0 p-3 border-b">
|
<div className="shrink-0 border-b p-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="POP 화면 검색..."
|
placeholder="POP 화면 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-9 h-9"
|
className="h-9 pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="mt-2 flex items-center justify-between">
|
||||||
<span className="text-xs text-muted-foreground">POP 화면</span>
|
<span className="text-muted-foreground text-xs">POP 화면</span>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{popScreenCount}개
|
{popScreenCount}개
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -318,40 +279,22 @@ export default function PopScreenManagementPage() {
|
||||||
selectedScreen={selectedScreen}
|
selectedScreen={selectedScreen}
|
||||||
onScreenSelect={handleScreenSelect}
|
onScreenSelect={handleScreenSelect}
|
||||||
onScreenDesign={handleDesignScreen}
|
onScreenDesign={handleDesignScreen}
|
||||||
onScreenSettings={(screen) => {
|
|
||||||
setSelectedScreen(screen);
|
|
||||||
setIsSettingModalOpen(true);
|
|
||||||
}}
|
|
||||||
onScreenCopy={(screen) => {
|
|
||||||
setSelectedScreen(screen);
|
|
||||||
setDeployGroupScreens([]);
|
|
||||||
setDeployGroupName("");
|
|
||||||
setDeployGroupInfo(undefined);
|
|
||||||
setIsDeployModalOpen(true);
|
|
||||||
}}
|
|
||||||
onGroupCopy={(groupScreensList, groupName, gInfo) => {
|
|
||||||
setSelectedScreen(null);
|
|
||||||
setDeployGroupScreens(groupScreensList);
|
|
||||||
setDeployGroupName(groupName);
|
|
||||||
setDeployGroupInfo(gInfo);
|
|
||||||
setIsDeployModalOpen(true);
|
|
||||||
}}
|
|
||||||
onGroupSelect={handleGroupSelect}
|
onGroupSelect={handleGroupSelect}
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽: 미리보기 / 화면 흐름 */}
|
{/* 오른쪽: 미리보기 / 화면 흐름 */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* 오른쪽 패널 헤더 */}
|
{/* 오른쪽 패널 헤더 */}
|
||||||
<div className="shrink-0 px-4 py-2 border-b bg-background flex items-center justify-between">
|
<div className="bg-background flex shrink-0 items-center justify-between border-b px-4 py-2">
|
||||||
<Tabs value={rightPanelView} onValueChange={(v) => setRightPanelView(v as RightPanelView)}>
|
<Tabs value={rightPanelView} onValueChange={(v) => setRightPanelView(v as RightPanelView)}>
|
||||||
<TabsList className="h-8">
|
<TabsList className="h-8">
|
||||||
<TabsTrigger value="preview" className="h-7 px-3 text-xs gap-1.5">
|
<TabsTrigger value="preview" className="h-7 gap-1.5 px-3 text-xs">
|
||||||
<LayoutGrid className="h-3.5 w-3.5" />
|
<LayoutGrid className="h-3.5 w-3.5" />
|
||||||
미리보기
|
미리보기
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="flow" className="h-7 px-3 text-xs gap-1.5">
|
<TabsTrigger value="flow" className="h-7 gap-1.5 px-3 text-xs">
|
||||||
<GitBranch className="h-3.5 w-3.5" />
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
화면 흐름
|
화면 흐름
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -366,16 +309,10 @@ export default function PopScreenManagementPage() {
|
||||||
className="h-7 px-2 text-xs"
|
className="h-7 px-2 text-xs"
|
||||||
onClick={() => handlePreviewScreen(selectedScreen)}
|
onClick={() => handlePreviewScreen(selectedScreen)}
|
||||||
>
|
>
|
||||||
<Eye className="h-3.5 w-3.5 mr-1" />
|
<Eye className="mr-1 h-3.5 w-3.5" />새 탭
|
||||||
새 탭
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleOpenSettings}>
|
||||||
variant="ghost"
|
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
onClick={handleOpenSettings}
|
|
||||||
>
|
|
||||||
<Settings className="h-3.5 w-3.5 mr-1" />
|
|
||||||
설정
|
설정
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -429,18 +366,6 @@ export default function PopScreenManagementPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* POP 화면 배포 모달 */}
|
|
||||||
<PopDeployModal
|
|
||||||
open={isDeployModalOpen}
|
|
||||||
onOpenChange={setIsDeployModalOpen}
|
|
||||||
screen={selectedScreen}
|
|
||||||
groupScreens={deployGroupScreens.length > 0 ? deployGroupScreens : undefined}
|
|
||||||
groupName={deployGroupName || undefined}
|
|
||||||
groupInfo={deployGroupInfo}
|
|
||||||
allScreens={screens}
|
|
||||||
onDeployed={loadScreens}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
{/* Scroll to Top 버튼 */}
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,87 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { DndProvider } from "react-dnd";
|
import { DndProvider } from "react-dnd";
|
||||||
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
|
import { useTabId } from "@/contexts/TabIdContext";
|
||||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||||
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
|
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
|
||||||
import { PageListPanel } from "@/components/report/designer/PageListPanel";
|
import { PageListPanel } from "@/components/report/designer/PageListPanel";
|
||||||
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
|
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
|
||||||
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
|
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
|
||||||
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
|
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
|
||||||
import { ReportDesignerProvider } from "@/contexts/ReportDesignerContext";
|
import { ReportDesignerProvider, useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
|
import { ComponentSettingsModal } from "@/components/report/designer/modals/ComponentSettingsModal";
|
||||||
import { reportApi } from "@/lib/api/reportApi";
|
import { reportApi } from "@/lib/api/reportApi";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function ReportDesignerPage() {
|
const BREAKPOINT_COLLAPSE_LEFT = 1200;
|
||||||
const params = useParams();
|
const BREAKPOINT_COLLAPSE_ALL = 900;
|
||||||
const router = useRouter();
|
|
||||||
const reportId = params.reportId as string;
|
function DesignerLayout() {
|
||||||
|
const {
|
||||||
|
setIsPageListCollapsed,
|
||||||
|
setIsLeftPanelCollapsed,
|
||||||
|
setIsRightPanelCollapsed,
|
||||||
|
} = useReportDesigner();
|
||||||
|
|
||||||
|
const handleResize = useCallback(() => {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
if (w < BREAKPOINT_COLLAPSE_ALL) {
|
||||||
|
setIsPageListCollapsed(true);
|
||||||
|
setIsLeftPanelCollapsed(true);
|
||||||
|
setIsRightPanelCollapsed(true);
|
||||||
|
} else if (w < BREAKPOINT_COLLAPSE_LEFT) {
|
||||||
|
setIsPageListCollapsed(true);
|
||||||
|
setIsLeftPanelCollapsed(false);
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}
|
||||||
|
}, [setIsPageListCollapsed, setIsLeftPanelCollapsed, setIsRightPanelCollapsed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, [handleResize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen min-w-[768px] flex-col overflow-hidden bg-gray-50">
|
||||||
|
<ReportDesignerToolbar />
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<PageListPanel />
|
||||||
|
<ReportDesignerLeftPanel />
|
||||||
|
<ReportDesignerCanvas />
|
||||||
|
<ReportDesignerRightPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ComponentSettingsModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportDesignerPageProps {
|
||||||
|
adminParams?: { reportId?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportDesignerPage({ adminParams }: ReportDesignerPageProps) {
|
||||||
|
const routeParams = useParams();
|
||||||
|
const reportId = adminParams?.reportId || (routeParams.reportId as string);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const closeTab = useTabStore((s) => s.closeTab);
|
||||||
|
const currentTabId = useTabId();
|
||||||
|
|
||||||
|
const closeDesignerTab = useCallback(() => {
|
||||||
|
if (currentTabId) {
|
||||||
|
closeTab(currentTabId);
|
||||||
|
}
|
||||||
|
}, [currentTabId, closeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadReport = async () => {
|
const loadReport = async () => {
|
||||||
// 'new'는 새 리포트 생성 모드
|
|
||||||
if (reportId === "new") {
|
if (reportId === "new") {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -37,7 +95,7 @@ export default function ReportDesignerPage() {
|
||||||
description: "리포트를 찾을 수 없습니다.",
|
description: "리포트를 찾을 수 없습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
router.push("/admin/screenMng/reportList");
|
closeDesignerTab();
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -45,7 +103,7 @@ export default function ReportDesignerPage() {
|
||||||
description: error.message || "리포트를 불러오는데 실패했습니다.",
|
description: error.message || "리포트를 불러오는데 실패했습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
router.push("/admin/screenMng/reportList");
|
closeDesignerTab();
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +112,7 @@ export default function ReportDesignerPage() {
|
||||||
if (reportId) {
|
if (reportId) {
|
||||||
loadReport();
|
loadReport();
|
||||||
}
|
}
|
||||||
}, [reportId, router, toast]);
|
}, [reportId, closeDesignerTab, toast]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -65,28 +123,12 @@ export default function ReportDesignerPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div id="report-designer-dnd-root">
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<ReportDesignerProvider reportId={reportId}>
|
<ReportDesignerProvider reportId={reportId}>
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-gray-50">
|
<DesignerLayout />
|
||||||
{/* 상단 툴바 */}
|
|
||||||
<ReportDesignerToolbar />
|
|
||||||
|
|
||||||
{/* 메인 영역 */}
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
|
||||||
{/* 페이지 목록 패널 */}
|
|
||||||
<PageListPanel />
|
|
||||||
|
|
||||||
{/* 좌측 패널 (템플릿, 컴포넌트) */}
|
|
||||||
<ReportDesignerLeftPanel />
|
|
||||||
|
|
||||||
{/* 중앙 캔버스 */}
|
|
||||||
<ReportDesignerCanvas />
|
|
||||||
|
|
||||||
{/* 우측 패널 (속성) */}
|
|
||||||
<ReportDesignerRightPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ReportDesignerProvider>
|
</ReportDesignerProvider>
|
||||||
</DndProvider>
|
</DndProvider>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,528 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { ReportListTable } from "@/components/report/ReportListTable";
|
import { ReportListTable } from "@/components/report/ReportListTable";
|
||||||
import { Plus, Search, RotateCcw } from "lucide-react";
|
import { ReportCreateModal } from "@/components/report/ReportCreateModal";
|
||||||
|
import { ReportCopyModal } from "@/components/report/ReportCopyModal";
|
||||||
|
import { ReportListPreviewModal } from "@/components/report/ReportListPreviewModal";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Check,
|
||||||
|
Tag,
|
||||||
|
CalendarDays,
|
||||||
|
User,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import { useReportList } from "@/hooks/useReportList";
|
import { useReportList } from "@/hooks/useReportList";
|
||||||
|
import { ReportMaster } from "@/types/report";
|
||||||
|
import { PieChart, Pie, Cell, Tooltip, BarChart, Bar, XAxis, LabelList } from "recharts";
|
||||||
|
import { REPORT_TYPE_COLORS, getTypeColorIndex, getTypeLabel, getTypeIcon } from "@/lib/reportTypeColors";
|
||||||
|
import { format, subDays, subMonths, startOfDay } from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
|
||||||
|
const SEARCH_FIELD_OPTIONS = [
|
||||||
|
{ value: "report_type" as const, label: "카테고리", icon: Tag },
|
||||||
|
{ value: "report_name" as const, label: "리포트명", icon: FileText },
|
||||||
|
{ value: "updated_at" as const, label: "기간 검색", icon: CalendarDays },
|
||||||
|
{ value: "created_by" as const, label: "작성자", icon: User },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ReportManagementPage() {
|
export default function ReportManagementPage() {
|
||||||
const router = useRouter();
|
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "list">("list");
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [copyTarget, setCopyTarget] = useState<ReportMaster | null>(null);
|
||||||
|
const [viewTarget, setViewTarget] = useState<ReportMaster | null>(null);
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const [datePopoverOpen, setDatePopoverOpen] = useState(false);
|
||||||
|
const [tempStartDate, setTempStartDate] = useState<Date | undefined>(undefined);
|
||||||
|
const [tempEndDate, setTempEndDate] = useState<Date | undefined>(undefined);
|
||||||
|
const filterRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList();
|
const {
|
||||||
|
reports,
|
||||||
|
total,
|
||||||
|
typeSummary,
|
||||||
|
recentActivity,
|
||||||
|
recentTotal,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
isLoading,
|
||||||
|
searchField,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
refetch,
|
||||||
|
setPage,
|
||||||
|
setLimit,
|
||||||
|
handleSearch,
|
||||||
|
handleSearchFieldChange,
|
||||||
|
handleDateRangeChange,
|
||||||
|
} = useReportList();
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
useEffect(() => {
|
||||||
handleSearch(searchText);
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (filterRef.current && !filterRef.current.contains(e.target as Node)) {
|
||||||
|
setFilterOpen(false);
|
||||||
|
setDatePopoverOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
if (filterOpen) document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [filterOpen]);
|
||||||
|
|
||||||
const handleReset = () => {
|
const isDateFilterActive = searchField === "updated_at" && startDate && endDate;
|
||||||
setSearchText("");
|
|
||||||
handleSearch("");
|
const handleDatePreset = useCallback((days: number) => {
|
||||||
|
const end = new Date();
|
||||||
|
const start = days === 0 ? startOfDay(end) : subDays(end, days);
|
||||||
|
setTempStartDate(start);
|
||||||
|
setTempEndDate(end);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMonthPreset = useCallback((months: number) => {
|
||||||
|
const end = new Date();
|
||||||
|
const start = subMonths(end, months);
|
||||||
|
setTempStartDate(start);
|
||||||
|
setTempEndDate(end);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleApplyDateFilter = useCallback(() => {
|
||||||
|
if (!tempStartDate || !tempEndDate) return;
|
||||||
|
handleSearchFieldChange("updated_at");
|
||||||
|
handleDateRangeChange(format(tempStartDate, "yyyy-MM-dd"), format(tempEndDate, "yyyy-MM-dd"));
|
||||||
|
setDatePopoverOpen(false);
|
||||||
|
setFilterOpen(false);
|
||||||
|
}, [tempStartDate, tempEndDate, handleSearchFieldChange, handleDateRangeChange]);
|
||||||
|
|
||||||
|
const handleClearDateFilter = useCallback(() => {
|
||||||
|
setTempStartDate(undefined);
|
||||||
|
setTempEndDate(undefined);
|
||||||
|
handleSearchFieldChange("report_name");
|
||||||
|
handleDateRangeChange("", "");
|
||||||
|
}, [handleSearchFieldChange, handleDateRangeChange]);
|
||||||
|
|
||||||
|
const typeData = useMemo(() => typeSummary.map(({ type, count }) => ({ type, value: count })), [typeSummary]);
|
||||||
|
|
||||||
|
const authorStats = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
reports.forEach((r) => {
|
||||||
|
const author = r.created_by || "미지정";
|
||||||
|
map.set(author, (map.get(author) || 0) + 1);
|
||||||
|
});
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 3)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, "ko"));
|
||||||
|
}, [reports]);
|
||||||
|
|
||||||
|
const authorCount = useMemo(() => new Set(reports.map((r) => r.created_by).filter(Boolean)).size, [reports]);
|
||||||
|
|
||||||
|
const handleSearchClick = () => handleSearch(searchText);
|
||||||
|
|
||||||
|
const handleViewModeChange = (mode: "grid" | "list") => {
|
||||||
|
setViewMode(mode);
|
||||||
|
setLimit(mode === "grid" ? 9 : 8);
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
const handleCreateNew = () => {
|
||||||
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입
|
setIsCreateOpen(true);
|
||||||
router.push("/admin/screenMng/reportList/designer/new");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentFieldLabel = SEARCH_FIELD_OPTIONS.find((o) => o.value === searchField)?.label ?? "리포트명";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="report-page-content flex h-[calc(100vh-56px)] flex-col bg-gray-50">
|
||||||
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
<div className="shrink-0 border-b bg-white">
|
||||||
{/* 페이지 제목 */}
|
<div className="mx-6 py-2.5">
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">리포트 관리</h1>
|
<h1 className="text-xl font-bold text-gray-900">리포트 관리</h1>
|
||||||
<p className="mt-2 text-gray-600">리포트를 생성하고 관리합니다</p>
|
<span className="text-sm text-gray-400">리포트를 생성하고 관리합니다</span>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCreateNew} className="gap-2">
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="relative w-full sm:w-[480px]">
|
||||||
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder={`${currentFieldLabel}(으)로 검색...`}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearchClick()}
|
||||||
|
className="h-9 pl-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSearchClick}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-9 shrink-0"
|
||||||
|
title="검색"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div ref={filterRef} className="relative">
|
||||||
|
<Button
|
||||||
|
onClick={() => setFilterOpen(!filterOpen)}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className={`h-9 w-9 shrink-0 ${filterOpen || isDateFilterActive ? "border-blue-400 bg-blue-50 text-blue-600" : ""}`}
|
||||||
|
title="검색 필터"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{filterOpen && !datePopoverOpen && (
|
||||||
|
<div className="absolute top-full right-0 z-50 mt-1.5 w-52 rounded-lg border border-gray-200 bg-white py-1.5 shadow-lg">
|
||||||
|
<div className="px-3.5 py-2 text-sm font-semibold text-gray-400">검색 기준</div>
|
||||||
|
{SEARCH_FIELD_OPTIONS.map((opt) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
const isDateOption = opt.value === "updated_at";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
if (isDateOption) {
|
||||||
|
setDatePopoverOpen(true);
|
||||||
|
} else {
|
||||||
|
handleSearchFieldChange(opt.value);
|
||||||
|
handleDateRangeChange("", "");
|
||||||
|
setFilterOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-base transition-colors hover:bg-gray-50 ${
|
||||||
|
searchField === opt.value || (isDateOption && searchField === "updated_at")
|
||||||
|
? "font-medium text-blue-600"
|
||||||
|
: "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="flex-1 text-left">{opt.label}</span>
|
||||||
|
{isDateOption && <span className="text-xs text-gray-400">▸</span>}
|
||||||
|
{!isDateOption && searchField === opt.value && (
|
||||||
|
<Check className="h-4.5 w-4.5 shrink-0 text-blue-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterOpen && datePopoverOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full right-0 z-50 mt-1.5 rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDatePopoverOpen(false)}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
← 뒤로
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold text-gray-700">기간 검색</span>
|
||||||
|
<div className="w-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{[
|
||||||
|
{ label: "오늘", action: () => handleDatePreset(0) },
|
||||||
|
{ label: "1주일", action: () => handleDatePreset(7) },
|
||||||
|
{ label: "1개월", action: () => handleMonthPreset(1) },
|
||||||
|
{ label: "3개월", action: () => handleMonthPreset(3) },
|
||||||
|
].map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
onClick={preset.action}
|
||||||
|
className="flex-1 rounded-md border border-gray-200 px-2 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-500">시작일</label>
|
||||||
|
<div className="flex h-9 w-full items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700">
|
||||||
|
<CalendarDays className="mr-2 h-3.5 w-3.5 text-gray-400" />
|
||||||
|
{tempStartDate ? (
|
||||||
|
format(tempStartDate, "yyyy-MM-dd")
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">선택</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={tempStartDate}
|
||||||
|
onSelect={setTempStartDate}
|
||||||
|
locale={ko}
|
||||||
|
className="mt-1.5 rounded-md border border-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center pt-6">
|
||||||
|
<span className="text-sm text-gray-400">~</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-500">종료일</label>
|
||||||
|
<div className="flex h-9 w-full items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700">
|
||||||
|
<CalendarDays className="mr-2 h-3.5 w-3.5 text-gray-400" />
|
||||||
|
{tempEndDate ? (
|
||||||
|
format(tempEndDate, "yyyy-MM-dd")
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">선택</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={tempEndDate}
|
||||||
|
onSelect={setTempEndDate}
|
||||||
|
locale={ko}
|
||||||
|
className="mt-1.5 rounded-md border border-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleApplyDateFilter}
|
||||||
|
disabled={!tempStartDate || !tempEndDate}
|
||||||
|
className="h-9 w-full bg-blue-600 text-sm text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDateFilterActive && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5">
|
||||||
|
<CalendarDays className="h-4 w-4 text-blue-600" />
|
||||||
|
<span className="text-sm font-medium text-blue-700">
|
||||||
|
{startDate} ~ {endDate}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClearDateFilter}
|
||||||
|
className="ml-1 rounded p-0.5 text-blue-400 transition-colors hover:bg-blue-100 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center overflow-hidden rounded-md border border-gray-200">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleViewModeChange("list")}
|
||||||
|
className={`h-9 w-9 rounded-none ${viewMode === "list" ? "bg-gray-100" : ""}`}
|
||||||
|
title="리스트 보기"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleViewModeChange("grid")}
|
||||||
|
className={`h-9 w-9 rounded-none border-l ${viewMode === "grid" ? "bg-gray-100" : ""}`}
|
||||||
|
title="그리드 보기"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateNew}
|
||||||
|
className="ml-auto h-9 gap-1.5 bg-blue-600 text-sm text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />새 리포트
|
<Plus className="h-4 w-4" />새 리포트
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 영역 */}
|
|
||||||
<Card className="shadow-sm">
|
|
||||||
<CardHeader className="bg-gray-50/50">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Search className="h-5 w-5" />
|
|
||||||
검색
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="리포트명으로 검색..."
|
|
||||||
value={searchText}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleSearchClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button onClick={handleSearchClick} className="gap-2">
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
검색
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleReset} variant="outline" className="gap-2">
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 리포트 목록 */}
|
<div className="flex min-h-0 flex-1 flex-col gap-5 px-8 py-5">
|
||||||
<Card className="shadow-sm">
|
<div className="grid shrink-0 auto-rows-fr grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<CardHeader className="bg-gray-50/50">
|
<div className="flex items-center justify-center gap-5 overflow-hidden rounded-xl border border-gray-100 bg-white px-6 py-8 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||||
<CardTitle className="flex items-center justify-between">
|
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-blue-50">
|
||||||
<span className="flex items-center gap-2">
|
<FileText className="h-8 w-8 text-blue-600" />
|
||||||
📋 리포트 목록
|
</div>
|
||||||
<span className="text-muted-foreground text-sm font-normal">(총 {total}건)</span>
|
<div>
|
||||||
|
<span className="text-lg font-bold whitespace-nowrap text-gray-500">전체 리포트</span>
|
||||||
|
<p className="mt-1 text-5xl font-bold tracking-tight whitespace-nowrap text-gray-900 tabular-nums">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
<span className="ml-1.5 text-xl font-bold text-gray-400">건</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex items-center justify-center gap-8 rounded-xl border border-gray-100 bg-white px-6 py-8 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-purple-50">
|
||||||
|
<Users className="h-8 w-8 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-lg font-bold whitespace-nowrap text-gray-500">작성자</span>
|
||||||
|
<p className="mt-1 text-5xl font-bold tracking-tight whitespace-nowrap text-gray-900 tabular-nums">
|
||||||
|
{authorCount.toLocaleString()}
|
||||||
|
<span className="ml-1.5 text-xl font-bold text-gray-400">명</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{authorStats.length > 0 && (
|
||||||
|
<div className="h-[80px] w-[100px] shrink-0" style={{ overflow: "visible" }}>
|
||||||
|
<BarChart width={100} height={80} data={authorStats} margin={{ top: 12, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<Bar
|
||||||
|
dataKey="count" fill="#a78bfa" radius={[3, 3, 0, 0]} maxBarSize={18}
|
||||||
|
isAnimationActive={true} animationDuration={1200} animationEasing="ease-out"
|
||||||
|
>
|
||||||
|
<LabelList dataKey="count" position="top" style={{ fontSize: "10px", fontWeight: 700, fill: "#6d28d9" }} />
|
||||||
|
</Bar>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${value}건`, "리포트"]}
|
||||||
|
labelFormatter={(_label: string, payload: any[]) => payload?.[0]?.payload?.name || _label}
|
||||||
|
contentStyle={{ fontSize: "11px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }}
|
||||||
|
wrapperStyle={{ zIndex: 50, pointerEvents: "none" }}
|
||||||
|
cursor={false}
|
||||||
|
allowEscapeViewBox={{ x: true, y: true }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-gray-100 bg-white px-6 py-6 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||||
|
<span className="text-lg font-bold whitespace-nowrap text-gray-500">
|
||||||
|
최근 30일 활동{" "}
|
||||||
|
<span className="text-base font-semibold text-gray-400 tabular-nums">
|
||||||
|
({recentTotal.toLocaleString()}건)
|
||||||
</span>
|
</span>
|
||||||
</CardTitle>
|
</span>
|
||||||
</CardHeader>
|
<div className="mt-2 flex w-full flex-1 items-center justify-center">
|
||||||
<CardContent className="p-0">
|
<div className="shrink-0" style={{ overflow: "visible" }}>
|
||||||
|
<BarChart
|
||||||
|
width={220} height={100} data={recentActivity}
|
||||||
|
barCategoryGap="12%" margin={{ top: 16, right: 2, bottom: 0, left: 2 }}
|
||||||
|
>
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 12, fill: "#374151", fontWeight: 700 }} axisLine={false} tickLine={false} />
|
||||||
|
<Bar
|
||||||
|
dataKey="count" fill="#60a5fa" radius={[4, 4, 0, 0]} maxBarSize={32}
|
||||||
|
isAnimationActive={true} animationDuration={1200} animationEasing="ease-out"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${value}건`, "수정"]}
|
||||||
|
contentStyle={{ fontSize: "12px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }}
|
||||||
|
cursor={false}
|
||||||
|
allowEscapeViewBox={{ x: true, y: true }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-gray-100 bg-white px-6 py-6 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||||
|
<span className="text-lg font-bold whitespace-nowrap text-gray-500">카테고리별 분포</span>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
{typeData.length === 0 ? (
|
||||||
|
<p className="text-lg font-bold text-gray-400">데이터 없음</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2.5">
|
||||||
|
<div className="shrink-0" style={{ overflow: "visible", position: "relative" }}>
|
||||||
|
<PieChart width={90} height={90} style={{ overflow: "visible" }}>
|
||||||
|
<Pie
|
||||||
|
data={typeData} cx="50%" cy="50%" innerRadius={20} outerRadius={40}
|
||||||
|
dataKey="value" nameKey="type" startAngle={90} endAngle={-270}
|
||||||
|
strokeWidth={2} stroke="#fff"
|
||||||
|
isAnimationActive={true} animationDuration={1200} animationEasing="ease-out"
|
||||||
|
>
|
||||||
|
{typeData.map((entry) => (
|
||||||
|
<Cell key={entry.type} fill={REPORT_TYPE_COLORS[getTypeColorIndex(entry.type) % REPORT_TYPE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number, name: string) => [`${value}건`, getTypeLabel(name)]}
|
||||||
|
contentStyle={{ fontSize: "12px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }}
|
||||||
|
wrapperStyle={{ zIndex: 20, pointerEvents: "none" }}
|
||||||
|
allowEscapeViewBox={{ x: true, y: true }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
||||||
|
{typeData.slice(0, 4).map((entry) => {
|
||||||
|
const TypeIcon = getTypeIcon(entry.type);
|
||||||
|
return (
|
||||||
|
<div key={entry.type} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="flex h-4 w-4 shrink-0 items-center justify-center rounded"
|
||||||
|
style={{ backgroundColor: REPORT_TYPE_COLORS[getTypeColorIndex(entry.type) % REPORT_TYPE_COLORS.length] }}
|
||||||
|
>
|
||||||
|
<TypeIcon className="h-2.5 w-2.5 text-white" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium whitespace-nowrap text-gray-600">{getTypeLabel(entry.type)}</span>
|
||||||
|
<span className="text-sm font-bold whitespace-nowrap text-gray-900">{entry.value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{typeData.length > 4 && <span className="text-xs text-gray-400">외 {typeData.length - 4}개</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50/50 px-5 py-3">
|
||||||
|
<span className="flex items-center gap-2.5 text-base font-semibold text-gray-900">
|
||||||
|
리포트 목록
|
||||||
|
<span className="text-sm font-normal text-gray-400">(총 {total}건)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
<ReportListTable
|
<ReportListTable
|
||||||
reports={reports}
|
reports={reports}
|
||||||
total={total}
|
total={total}
|
||||||
page={page}
|
page={page}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
viewMode={viewMode}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onRefresh={refetch}
|
onRefresh={refetch}
|
||||||
|
onViewClick={setViewTarget}
|
||||||
|
onCopyClick={setCopyTarget}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReportCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onSuccess={refetch} />
|
||||||
|
|
||||||
|
<ReportListPreviewModal report={viewTarget} onClose={() => setViewTarget(null)} />
|
||||||
|
|
||||||
|
{copyTarget && (
|
||||||
|
<ReportCopyModal
|
||||||
|
report={copyTarget}
|
||||||
|
onClose={() => setCopyTarget(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setCopyTarget(null);
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, Printer, Download } from "lucide-react";
|
||||||
|
|
||||||
|
interface DocumentLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
title: string;
|
||||||
|
docNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentLayout({ children, title, docNumber }: DocumentLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FC]">
|
||||||
|
{/* Navigation Bar */}
|
||||||
|
<div className="bg-[#1E3A5F] border-b-4 border-[#0F172A] px-6 py-3 print:hidden">
|
||||||
|
<div className="max-w-[842px] mx-auto flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/admin/screenMng/reportList/samples"
|
||||||
|
className="flex items-center gap-2 text-white hover:text-[#EFF6FF] transition-colors border-2 border-white px-3 py-1"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
<span className="text-sm">돌아가기</span>
|
||||||
|
</Link>
|
||||||
|
<div className="h-6 w-px bg-[#64748B]" />
|
||||||
|
<h1 className="text-lg text-white">{title}</h1>
|
||||||
|
{docNumber && (
|
||||||
|
<span className="text-xs text-[#94A3B8] border border-[#475569] px-2 py-0.5">{docNumber}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white text-[#1E3A5F] border-2 border-white hover:bg-[#EFF6FF] transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4" />
|
||||||
|
인쇄
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 border-2 border-white text-white hover:bg-[#2563EB] hover:border-[#2563EB] transition-colors text-sm">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Container */}
|
||||||
|
<div className="py-8 px-4">
|
||||||
|
<div className="max-w-[842px] mx-auto bg-white border-4 border-[#1E3A5F] print:border-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
type StatusType = "합격" | "불합격" | "보류" | "발주완료" | "검토중" | "취소" | "완료" | "승인대기";
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: StatusType;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_MAP: Record<StatusType, string> = {
|
||||||
|
합격: "bg-white text-[#16A34A] border-[#16A34A]",
|
||||||
|
완료: "bg-white text-[#16A34A] border-[#16A34A]",
|
||||||
|
발주완료: "bg-white text-[#16A34A] border-[#16A34A]",
|
||||||
|
불합격: "bg-[#DC2626] text-white border-[#DC2626]",
|
||||||
|
취소: "bg-[#DC2626] text-white border-[#DC2626]",
|
||||||
|
보류: "bg-[#D97706] text-white border-[#D97706]",
|
||||||
|
검토중: "bg-[#D97706] text-white border-[#D97706]",
|
||||||
|
승인대기: "bg-[#2563EB] text-white border-[#2563EB]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE_MAP = {
|
||||||
|
sm: "px-2 py-0.5 text-xs",
|
||||||
|
md: "px-3 py-1 text-sm",
|
||||||
|
lg: "px-8 py-3 text-2xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center justify-center border-2 ${COLOR_MAP[status]} ${SIZE_MAP[size]}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import DocumentLayout from "../components/DocumentLayout";
|
||||||
|
import StatusBadge from "../components/StatusBadge";
|
||||||
|
|
||||||
|
const INSPECTION_ITEMS = [
|
||||||
|
{
|
||||||
|
no: 1,
|
||||||
|
item: "외관상태",
|
||||||
|
subItem: "ee",
|
||||||
|
method: "육안 및 뒤틀림이 없을 것",
|
||||||
|
standard: "A",
|
||||||
|
measured: ["A", "A", "A", "A", "A", "A", "A", "A"],
|
||||||
|
result: "합격" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: 2,
|
||||||
|
item: "표면 및 표시",
|
||||||
|
subItem: "ff",
|
||||||
|
method: "100표에서 1시간 방치",
|
||||||
|
standard: "O",
|
||||||
|
measured: ["O", "O", "O", "O", "O", "O", "O", "O"],
|
||||||
|
result: "합격" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: 3,
|
||||||
|
item: "치수 yy",
|
||||||
|
subItem: "yy",
|
||||||
|
method: "길이",
|
||||||
|
standard: "453.9±0.9",
|
||||||
|
measured: ["453.6", "453.6", "454.4", "453.5", "453.1", "454.1", "454.3", "454.7"],
|
||||||
|
result: "합격" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: 4,
|
||||||
|
item: "치수 hhh",
|
||||||
|
subItem: "hhh",
|
||||||
|
method: "폭",
|
||||||
|
standard: "177.3±0.5",
|
||||||
|
measured: ["177.4", "177.1", "177.5", "177.6", "177.3", "176.9", "177.7", "176.8"],
|
||||||
|
result: "합격" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: 5,
|
||||||
|
item: "외관상태",
|
||||||
|
subItem: "",
|
||||||
|
method: "ff",
|
||||||
|
standard: "A",
|
||||||
|
measured: ["A", "A", "A", "A", "A", "A", "A", "A"],
|
||||||
|
result: "합격" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 정보 카드 (CardRenderer 구조를 참고한 정적 구현) ────────────────────────
|
||||||
|
|
||||||
|
function InfoCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<div className="bg-[#EFF6FF] border-b-2 border-[#1E3A5F] px-4 py-2">
|
||||||
|
<h3 className="text-sm text-[#0F172A]">▣ {title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-2 text-xs">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[100px,1fr] border-b border-[#E2E8F0] pb-1">
|
||||||
|
<span className="text-[#64748B]">{label}</span>
|
||||||
|
<span className={highlight ? "text-[#2563EB]" : "text-[#0F172A]"}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 결재란 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ApprovalSection({ columns }: { columns: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<div className="grid text-center text-xs" style={{ gridTemplateColumns: `repeat(${columns.length}, 96px)` }}>
|
||||||
|
{columns.map((col, i) => (
|
||||||
|
<div key={i} className={`p-3 ${i < columns.length - 1 ? "border-r-2 border-[#1E3A5F]" : ""}`}>
|
||||||
|
<div className="text-[#64748B] mb-6 pb-2 border-b border-[#E2E8F0]">{col}</div>
|
||||||
|
<div className="h-12" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InspectionReportPage() {
|
||||||
|
return (
|
||||||
|
<DocumentLayout title="검사 보고서" docNumber="IR-2026-00123">
|
||||||
|
<div className="p-10">
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div className="border-4 border-[#1E3A5F] mb-6">
|
||||||
|
<div className="bg-[#1E3A5F] text-white px-6 py-4 text-center">
|
||||||
|
<h1 className="text-3xl tracking-widest">검 사 보 고 서</h1>
|
||||||
|
<p className="text-xs mt-1 tracking-wider">INSPECTION REPORT</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white px-6 py-3 flex justify-between items-center border-t-2 border-[#1E3A5F]">
|
||||||
|
<div className="text-sm text-[#64748B]">문서번호: IR-2026-00123</div>
|
||||||
|
<StatusBadge status="합격" size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 기본 정보 (2열 카드) ── */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<InfoCard title="검사 대상">
|
||||||
|
<InfoRow label="발행번호" value="HC2014 - 005" />
|
||||||
|
<InfoRow label="협력업체" value="매직볼드" />
|
||||||
|
<InfoRow label="규격명" value="SATA-234" highlight />
|
||||||
|
<InfoRow label="수주계측기" value="버니어캘리퍼스 (Serial No.) #05233911" />
|
||||||
|
<InfoRow label="검사전환일" value="저울 (Serial No.) #258-98-22" />
|
||||||
|
</InfoCard>
|
||||||
|
|
||||||
|
<InfoCard title="검사 정보">
|
||||||
|
<InfoRow label="생산일자" value="2014-03-10" highlight />
|
||||||
|
<InfoRow label="검사수량" value="565" />
|
||||||
|
<InfoRow label="검사레벨" value="일반검사1" />
|
||||||
|
<InfoRow label="AQL" value="1.5" />
|
||||||
|
<InfoRow label="검사일자" value="2014-03-10" highlight />
|
||||||
|
<InfoRow label="시료수량" value="8" />
|
||||||
|
<InfoRow label="검사자" value="김수로" />
|
||||||
|
</InfoCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 검사 항목 테이블 ── */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="bg-[#EFF6FF] border-2 border-[#1E3A5F] border-b-0 px-4 py-2">
|
||||||
|
<h3 className="text-sm text-[#0F172A]">▣ 검사/시험 측정값</h3>
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#1E3A5F] text-white">
|
||||||
|
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}>검사항목</th>
|
||||||
|
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}>
|
||||||
|
시험 및 검사대응<br />(검사기준)
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-center border-r-2 border-white" colSpan={8}>
|
||||||
|
검사/시험 측정값
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}>X̄</th>
|
||||||
|
<th className="px-3 py-2 text-center" rowSpan={2}>합격 판정</th>
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-[#1E3A5F] text-white border-t-2 border-white">
|
||||||
|
{["X1", "X2", "X3", "X4", "X5", "X6", "X7", "X8"].map((x) => (
|
||||||
|
<th key={x} className="px-2 py-2 text-center border-r-2 border-white">{x}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{INSPECTION_ITEMS.map((item, idx) => (
|
||||||
|
<tr key={item.no} className={`border-t border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||||
|
<td className="px-3 py-2 border-r border-[#E2E8F0]">
|
||||||
|
<div>{item.item}</div>
|
||||||
|
{item.subItem && <div className="text-[#64748B]">{item.subItem}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 border-r border-[#E2E8F0]">
|
||||||
|
<div>{item.method}</div>
|
||||||
|
{(item.method === "길이" || item.method === "폭") && (
|
||||||
|
<div className="text-[#64748B] mt-1">{item.standard}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{item.measured.map((val, i) => (
|
||||||
|
<td key={i} className="px-2 py-2 text-center border-r border-[#E2E8F0]">{val}</td>
|
||||||
|
))}
|
||||||
|
<td className="px-3 py-2 text-center border-r border-[#E2E8F0]">8</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<StatusBadge status={item.result} size="sm" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="mt-3 flex items-center gap-6 text-xs text-[#64748B]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-1 border-2 border-[#D97706] bg-yellow-100 text-[#0F172A]">비 고</span>
|
||||||
|
<span>[범례] A : Accept, R : Reject, H : Hold</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>중량판정</span>
|
||||||
|
<span className="px-2 py-1 bg-[#1E3A5F] text-white border-2 border-[#1E3A5F]">■ 합 격</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 결재란 ── */}
|
||||||
|
<ApprovalSection columns={["작성", "검토", "승인"]} />
|
||||||
|
|
||||||
|
{/* ── 푸터 ── */}
|
||||||
|
<div className="text-xs text-[#64748B] flex justify-between items-center pt-4 border-t-2 border-[#1E3A5F]">
|
||||||
|
<div>양식번호 : QF-805-2 (Rev.0)</div>
|
||||||
|
<div>A4(210mm×297mm)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, ClipboardCheck, FileText, ShoppingCart } from "lucide-react";
|
||||||
|
|
||||||
|
const SAMPLES = [
|
||||||
|
{
|
||||||
|
title: "검사 보고서",
|
||||||
|
titleEng: "Inspection Report",
|
||||||
|
description: "품질 검사 결과를 기록하고 관리하는 문서입니다. 검사 항목, 측정값, 합격/불합격 판정을 포함합니다.",
|
||||||
|
path: "/admin/screenMng/reportList/samples/inspection",
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
docNo: "IR-2026-XXXX",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "견적서",
|
||||||
|
titleEng: "Quotation",
|
||||||
|
description: "고객에게 제공하는 견적 문서입니다. 품목별 단가, 수량, 공급가액, 세액을 포함합니다.",
|
||||||
|
path: "/admin/screenMng/reportList/samples/quotation",
|
||||||
|
icon: FileText,
|
||||||
|
docNo: "QT-2026-XXXX",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "발주서",
|
||||||
|
titleEng: "Purchase Order",
|
||||||
|
description: "공급업체에 발주하는 공식 문서입니다. 발주처 정보, 발주 내역, 납기일 등을 포함합니다.",
|
||||||
|
path: "/admin/screenMng/reportList/samples/purchase-order",
|
||||||
|
icon: ShoppingCart,
|
||||||
|
docNo: "PO-2026-XXXX",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SamplesPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FC]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-[#1E3A5F] border-b-4 border-[#0F172A] px-6 py-3">
|
||||||
|
<div className="max-w-5xl mx-auto flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/admin/screenMng/reportList"
|
||||||
|
className="flex items-center gap-2 text-white hover:text-[#EFF6FF] transition-colors border-2 border-white px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
리포트 목록
|
||||||
|
</Link>
|
||||||
|
<div className="h-6 w-px bg-[#64748B]" />
|
||||||
|
<h1 className="text-white text-lg">리포트 디자인 샘플</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-10 px-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
{/* Title Section */}
|
||||||
|
<div className="bg-white border-4 border-[#1E3A5F] p-8 mb-8 text-center">
|
||||||
|
<h2 className="text-3xl text-[#0F172A] border-b-4 border-[#2563EB] pb-4 mb-4">
|
||||||
|
WACE PLM — 문서 양식 샘플
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#64748B] text-sm">
|
||||||
|
리포트 디자이너에서 활용 가능한 표준 문서 양식 샘플입니다.
|
||||||
|
<br />
|
||||||
|
카드(정보패널), 테이블, 결재란 등 기본 컴포넌트로 구성되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{SAMPLES.map((sample) => (
|
||||||
|
<Link
|
||||||
|
key={sample.path}
|
||||||
|
href={sample.path}
|
||||||
|
className="bg-white border-2 border-[#1E3A5F] hover:bg-[#EFF6FF] transition-colors group block"
|
||||||
|
>
|
||||||
|
<div className="border-b-2 border-[#1E3A5F] bg-[#EFF6FF] p-5 text-center group-hover:bg-[#DBEAFE] transition-colors">
|
||||||
|
<sample.icon className="w-10 h-10 mx-auto text-[#2563EB] mb-2" />
|
||||||
|
<p className="text-xs text-[#64748B]">{sample.docNo}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-xl text-[#0F172A] text-center border-b border-[#E2E8F0] pb-2 mb-3">
|
||||||
|
{sample.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[#64748B] text-center mb-1">{sample.titleEng}</p>
|
||||||
|
<p className="text-[#64748B] text-sm leading-relaxed text-center mt-3">
|
||||||
|
{sample.description}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<span className="inline-block border-2 border-[#2563EB] px-4 py-2 text-sm text-[#2563EB] hover:bg-[#2563EB] hover:text-white transition-colors">
|
||||||
|
샘플 보기 →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 text-center bg-white border-2 border-[#1E3A5F] p-4">
|
||||||
|
<p className="text-[#64748B] text-xs">A4 인쇄 최적화 · WACE PLM 리포트 디자이너 v2.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import DocumentLayout from "../components/DocumentLayout";
|
||||||
|
import StatusBadge from "../components/StatusBadge";
|
||||||
|
|
||||||
|
const ITEMS = [
|
||||||
|
{ no: 1, code: "P-001", name: "원자재 A", spec: "KS-100", unit: "KG", qty: 500, price: 5000 },
|
||||||
|
{ no: 2, code: "P-002", name: "부품 B", spec: "ISO-200", unit: "EA", qty: 1000, price: 3000 },
|
||||||
|
{ no: 3, code: "P-003", name: "자재 C", spec: "JIS-300", unit: "M", qty: 200, price: 8000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_ROWS = 10;
|
||||||
|
|
||||||
|
// ── 발주처 정보 테이블 행 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
highlight,
|
||||||
|
colSpan,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
highlight?: boolean;
|
||||||
|
colSpan?: number;
|
||||||
|
}) {
|
||||||
|
const labelBg = highlight ? "bg-yellow-100" : "bg-[#EFF6FF]";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className={`py-2 px-3 ${labelBg} border-r-2 border-[#1E3A5F] text-[#0F172A] w-28`}>{label}</td>
|
||||||
|
<td className={`py-2 px-3 text-[#64748B]`} colSpan={colSpan ?? 1}>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PurchaseOrderPage() {
|
||||||
|
const totalAmount = ITEMS.reduce((sum, item) => sum + item.qty * item.price, 0);
|
||||||
|
const tax = Math.round(totalAmount * 0.1);
|
||||||
|
const grandTotal = totalAmount + tax;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentLayout title="발주서" docNumber="PO-2026-00789">
|
||||||
|
<div className="p-10">
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div className="border-4 border-[#1E3A5F] mb-6">
|
||||||
|
<div className="flex items-center justify-between bg-[#1E3A5F] text-white px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl tracking-[0.5em]">발 주 서</h1>
|
||||||
|
<p className="text-xs mt-1 tracking-wider">PURCHASE ORDER</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<StatusBadge status="발주완료" size="md" />
|
||||||
|
{/* 결재란 인라인 */}
|
||||||
|
<div className="border-2 border-white">
|
||||||
|
<div className="grid grid-cols-4 text-xs">
|
||||||
|
{["담당", "부서장", "임원", "사장"].map((col, i) => (
|
||||||
|
<div key={i} className={`px-3 py-2 text-center ${i < 3 ? "border-r-2 border-white" : ""}`}>
|
||||||
|
{col}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 문서 번호 ── */}
|
||||||
|
<div className="mb-6 text-right">
|
||||||
|
<div className="inline-block border-2 border-[#1E3A5F] px-6 py-2 bg-[#F8F9FC]">
|
||||||
|
<div className="text-sm text-[#64748B]">발주번호: PO-2026-00789</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 발주처 정보 카드 ── */}
|
||||||
|
<div className="mb-6 border-2 border-[#1E3A5F]">
|
||||||
|
<div className="bg-[#EFF6FF] border-b-2 border-[#1E3A5F] px-4 py-2 text-sm text-[#0F172A]">
|
||||||
|
▣ 발주처 정보
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<InfoRow label="수 신 처" />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0] w-1/3" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] w-20 text-[#0F172A]">TEL</td>
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] w-20 text-[#0F172A]">담당</td>
|
||||||
|
<td className="py-2 px-3" />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F]" />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
|
||||||
|
<td className="py-2 px-3" colSpan={3} />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<InfoRow label="발 신 처" />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">TEL</td>
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">담당</td>
|
||||||
|
<td className="py-2 px-3" />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b-2 border-[#1E3A5F]">
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F]" />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
|
||||||
|
<td className="py-2 px-3" colSpan={3} />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<InfoRow label="납품일정" highlight />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">TEL</td>
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">현장담당</td>
|
||||||
|
<td className="py-2 px-3" />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F]" />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
|
||||||
|
<td className="py-2 px-3" colSpan={3} />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b-2 border-[#1E3A5F]">
|
||||||
|
<InfoRow label="납 기 일" highlight />
|
||||||
|
<td className="py-2 px-3 text-[#64748B]" colSpan={3}>20___년 ___월 ___일</td>
|
||||||
|
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A] w-20">인도조건</td>
|
||||||
|
<td className="py-2 px-3" />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<InfoRow label="대금결제조건" highlight colSpan={5} />
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<InfoRow label="검 수 방 법" highlight colSpan={5} />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 발주 내역 테이블 ── */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="bg-[#EFF6FF] border-2 border-[#1E3A5F] border-b-0 px-4 py-2 text-sm text-[#0F172A]">
|
||||||
|
▣ 발주 내역
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#1E3A5F] text-white">
|
||||||
|
{["NO", "품 명", "규격", "단위", "수량", "단가", "금액", "비고"].map((h, i) => (
|
||||||
|
<th key={i} className={`px-3 py-3 text-center ${i < 7 ? "border-r-2 border-white" : ""} ${i === 0 ? "w-12" : ""} ${i === 3 || i === 4 ? "w-16" : ""} ${i === 7 ? "w-20" : ""}`}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ITEMS.map((item, idx) => (
|
||||||
|
<tr key={item.no} className={`border-b border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||||
|
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.no}</td>
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.name}</td>
|
||||||
|
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.spec}</td>
|
||||||
|
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.unit}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.qty.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.price.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{(item.qty * item.price).toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-center" />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
|
||||||
|
<tr key={`e${idx}`} className={`border-b border-[#E2E8F0] ${(ITEMS.length + idx) % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||||
|
<td className="px-3 py-3 text-center border-r border-[#E2E8F0] text-[#CBD5E1]">{ITEMS.length + idx + 1}</td>
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0] h-8" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3" />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 금액 요약 ── */}
|
||||||
|
<div className="border-2 border-[#1E3A5F] mb-6">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-center w-32 text-[#0F172A]">공급가액</td>
|
||||||
|
<td className="px-4 py-3 text-right border-r-2 border-[#1E3A5F] text-[#0F172A]">₩ {totalAmount.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-center w-32 text-[#0F172A]">부가세액</td>
|
||||||
|
<td className="px-4 py-3 text-right border-r-2 border-[#1E3A5F] text-[#0F172A]">₩ {tax.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 bg-[#1E3A5F] text-white text-center w-32">합계금액</td>
|
||||||
|
<td className="px-4 py-3 text-right bg-[#1E3A5F] text-white">₩ {grandTotal.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 안내문 ── */}
|
||||||
|
<div className="border-2 border-[#1E3A5F] p-4 text-center mb-6 bg-[#F8F9FC]">
|
||||||
|
<p className="text-sm text-[#0F172A]">상기 자재를 발주하오니 납기를 준수하여 인도 바랍니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 푸터 ── */}
|
||||||
|
<div className="text-xs text-[#64748B] border-t-2 border-[#1E3A5F] pt-3 flex justify-between">
|
||||||
|
<div>양식번호: PO-001 (Rev.2)</div>
|
||||||
|
<div>문의: TEL 000-0000-0000 / FAX 000-0000-0000</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import DocumentLayout from "../components/DocumentLayout";
|
||||||
|
|
||||||
|
const ITEMS = [
|
||||||
|
{ no: 1, name: "프리미엄 제품 A", spec: "Model-X1000", qty: 50, unit: "EA", price: 150000 },
|
||||||
|
{ no: 2, name: "스탠다드 제품 B", spec: "Model-S500", qty: 100, unit: "EA", price: 80000 },
|
||||||
|
{ no: 3, name: "베이직 제품 C", spec: "Model-B200", qty: 200, unit: "EA", price: 45000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_ROWS = 5;
|
||||||
|
|
||||||
|
function ApprovalSection({ columns }: { columns: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<div className="grid text-center text-xs" style={{ gridTemplateColumns: `repeat(${columns.length}, 80px)` }}>
|
||||||
|
{columns.map((col, i) => (
|
||||||
|
<div key={i} className={`p-3 ${i < columns.length - 1 ? "border-r-2 border-[#1E3A5F]" : ""}`}>
|
||||||
|
<div className="text-[#64748B] mb-6 pb-2 border-b border-[#E2E8F0]">{col}</div>
|
||||||
|
<div className="h-12" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuotationPage() {
|
||||||
|
const supplyAmount = ITEMS.reduce((sum, item) => sum + item.qty * item.price, 0);
|
||||||
|
const tax = Math.round(supplyAmount * 0.1);
|
||||||
|
const total = supplyAmount + tax;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentLayout title="견적서" docNumber="QT-2026-01234">
|
||||||
|
<div className="p-10">
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div className="border-4 border-[#1E3A5F] mb-6">
|
||||||
|
<div className="bg-[#1E3A5F] text-white px-6 py-4 text-center">
|
||||||
|
<h1 className="text-4xl tracking-[0.5em]">견 적 서</h1>
|
||||||
|
<p className="text-xs mt-2 tracking-wider">QUOTATION</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 문서 번호 ── */}
|
||||||
|
<div className="mb-6 text-right">
|
||||||
|
<div className="inline-block border-2 border-[#1E3A5F] px-6 py-2 bg-[#F8F9FC]">
|
||||||
|
<div className="text-sm text-[#64748B]">문서번호: QT-2026-01234</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 날짜 / 수신 ── */}
|
||||||
|
<div className="mb-6 text-right">
|
||||||
|
<div className="inline-block text-sm">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<span className="border-b-2 border-[#2563EB] px-8 pb-1">2026</span>
|
||||||
|
<span className="text-[#64748B]">년</span>
|
||||||
|
<span className="border-b-2 border-[#2563EB] px-6 pb-1">03</span>
|
||||||
|
<span className="text-[#64748B]">월</span>
|
||||||
|
<span className="border-b-2 border-[#2563EB] px-6 pb-1">09</span>
|
||||||
|
<span className="text-[#64748B]">일</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b-2 border-[#1E3A5F] pb-2 text-lg">
|
||||||
|
<span className="mr-8 text-[#0F172A]">(주) ○○○○</span>
|
||||||
|
<span className="text-[#0F172A]">귀하</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 견적명 / 공급자 (2열 카드) ── */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center border-b-2 border-[#1E3A5F]">
|
||||||
|
견 적 명
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white h-16" />
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center border-b-2 border-[#1E3A5F]">
|
||||||
|
공 급 자
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-white text-xs space-y-1">
|
||||||
|
<div className="grid grid-cols-2 gap-2 border-b border-[#E2E8F0] pb-1">
|
||||||
|
<span className="text-[#64748B]">등록번호</span>
|
||||||
|
<span className="text-[#64748B]">상호(법인명) / 성명</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 border-b border-[#E2E8F0] pb-1">
|
||||||
|
<span className="text-[#64748B]">업태 / 업종</span>
|
||||||
|
<span className="text-[#64748B]">주소</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-[#E2E8F0] pb-1 text-[#64748B]">
|
||||||
|
전화번호 팩스
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 합계금액 ── */}
|
||||||
|
<div className="border-2 border-[#1E3A5F] mb-6">
|
||||||
|
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center">합 계 금 액</div>
|
||||||
|
<div className="p-4 bg-white text-center text-2xl border-t-2 border-[#1E3A5F] text-[#2563EB]">
|
||||||
|
₩ {total.toLocaleString()} 원정
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 품목 테이블 ── */}
|
||||||
|
<div className="mb-6 border-2 border-[#1E3A5F]">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#1E3A5F] text-white">
|
||||||
|
{["품번", "품명", "규격", "수량", "단가", "공급가액", "세액", "비고"].map((h, i) => (
|
||||||
|
<th key={i} className={`px-3 py-3 text-center ${i < 7 ? "border-r-2 border-white" : ""}`}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ITEMS.map((item, idx) => {
|
||||||
|
const amount = item.qty * item.price;
|
||||||
|
const itemTax = Math.round(amount * 0.1);
|
||||||
|
return (
|
||||||
|
<tr key={item.no} className={`border-t-2 border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||||
|
<td className="px-2 py-3 text-center border-r border-[#E2E8F0]">{item.no}</td>
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.name}</td>
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.spec}</td>
|
||||||
|
<td className="px-2 py-3 text-right border-r border-[#E2E8F0]">{item.qty.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.price.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{amount.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{itemTax.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-center" />
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
|
||||||
|
<tr key={`e${idx}`} className={`border-t border-[#E2E8F0] ${(ITEMS.length + idx) % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||||
|
<td className="px-2 py-3 text-center border-r border-[#E2E8F0] text-[#CBD5E1]">{ITEMS.length + idx + 1}</td>
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0] h-10" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-2 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3" />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{/* 합계 행 */}
|
||||||
|
<tr className="border-t-2 border-[#1E3A5F] bg-[#EFF6FF]">
|
||||||
|
<td colSpan={3} className="px-4 py-3 text-center border-r-2 border-[#1E3A5F] text-[#0F172A]">합 계</td>
|
||||||
|
<td className="px-2 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0] text-[#2563EB]">{supplyAmount.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0] text-[#2563EB]">{tax.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3" />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 금액 요약 (우측 정렬) ── */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<div className="border-2 border-[#1E3A5F] min-w-[300px]">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b-2 border-[#E2E8F0]">
|
||||||
|
<td className="px-4 py-2 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">공급가액</td>
|
||||||
|
<td className="px-4 py-2 text-right text-[#0F172A]">₩ {supplyAmount.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b-2 border-[#1E3A5F]">
|
||||||
|
<td className="px-4 py-2 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">부가세 (10%)</td>
|
||||||
|
<td className="px-4 py-2 text-right text-[#0F172A]">₩ {tax.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-[#1E3A5F] text-white">
|
||||||
|
<td className="px-4 py-2 border-r-2 border-white">합계금액</td>
|
||||||
|
<td className="px-4 py-2 text-right">₩ {total.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 안내문 ── */}
|
||||||
|
<div className="border-2 border-[#1E3A5F] p-4 mb-6 bg-[#F8F9FC]">
|
||||||
|
<p className="text-sm text-[#0F172A] mb-1">위와 같이 견적합니다.</p>
|
||||||
|
<p className="text-sm text-[#0F172A] mb-1">상기 견적서의 품목과 금액을 확인해 주시기 바랍니다.</p>
|
||||||
|
<p className="text-sm text-[#0F172A]">감사합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 결재란 ── */}
|
||||||
|
<ApprovalSection columns={["담당", "검토", "승인", "대표"]} />
|
||||||
|
|
||||||
|
{/* ── 푸터 ── */}
|
||||||
|
<div className="text-xs text-[#64748B] border-t-2 border-[#1E3A5F] pt-3">
|
||||||
|
<div className="flex justify-between mb-1">
|
||||||
|
<div>본 견적서의 유효기간은 견적일로부터 7일입니다.</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>결제계좌: (예금주: )</div>
|
||||||
|
<div>문의: TEL 000-0000-0000 / FAX 000-0000-0000</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -115,17 +115,19 @@ export default function ScreenManagementPage() {
|
||||||
// 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시
|
// 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시
|
||||||
// 단일 키워드면 해당 키워드로 화면 필터링
|
// 단일 키워드면 해당 키워드로 화면 필터링
|
||||||
const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
||||||
const filteredScreens = searchKeywords.length > 1
|
const filteredScreens =
|
||||||
|
searchKeywords.length > 1
|
||||||
? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨)
|
? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨)
|
||||||
: screens.filter((screen) =>
|
: screens.filter(
|
||||||
|
(screen) =>
|
||||||
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
|
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용
|
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-background">
|
<div className="bg-background fixed inset-0 z-50">
|
||||||
<ScreenDesigner
|
<ScreenDesigner
|
||||||
selectedScreen={selectedScreen}
|
selectedScreen={selectedScreen}
|
||||||
onBackToList={() => goToStep("list")}
|
onBackToList={() => goToStep("list")}
|
||||||
|
|
@ -150,28 +152,24 @@ export default function ScreenManagementPage() {
|
||||||
// V2 컴포넌트 테스트 모드
|
// V2 컴포넌트 테스트 모드
|
||||||
if (currentStep === "v2-test") {
|
if (currentStep === "v2-test") {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-background">
|
<div className="bg-background fixed inset-0 z-50">
|
||||||
<V2ComponentsDemo onBack={() => goToStep("list")} />
|
<V2ComponentsDemo onBack={() => goToStep("list")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
<div className="bg-background flex h-screen flex-col overflow-hidden">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex-shrink-0 border-b bg-background px-6 py-4">
|
<div className="bg-background flex-shrink-0 border-b px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">화면 관리</h1>
|
<h1 className="text-2xl font-bold tracking-tight">화면 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">화면을 그룹별로 관리하고 데이터 관계를 확인합니다</p>
|
<p className="text-muted-foreground text-sm">화면을 그룹별로 관리하고 데이터 관계를 확인합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* V2 컴포넌트 테스트 버튼 */}
|
{/* V2 컴포넌트 테스트 버튼 */}
|
||||||
<Button
|
<Button variant="outline" onClick={() => goToNextStep("v2-test")} className="gap-2">
|
||||||
variant="outline"
|
|
||||||
onClick={() => goToNextStep("v2-test")}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<TestTube2 className="h-4 w-4" />
|
<TestTube2 className="h-4 w-4" />
|
||||||
V2 테스트
|
V2 테스트
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -192,8 +190,7 @@ export default function ScreenManagementPage() {
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />새 화면
|
||||||
새 화면
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -201,18 +198,18 @@ export default function ScreenManagementPage() {
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 */}
|
||||||
{viewMode === "tree" ? (
|
{viewMode === "tree" ? (
|
||||||
<div className="flex-1 overflow-hidden flex">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 왼쪽: 트리 구조 */}
|
{/* 왼쪽: 트리 구조 */}
|
||||||
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
|
<div className="bg-background flex w-[350px] max-w-[450px] min-w-[280px] flex-col border-r">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="flex-shrink-0 p-3 border-b">
|
<div className="flex-shrink-0 border-b p-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="화면 검색..."
|
placeholder="화면 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-9 h-9"
|
className="h-9 pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ export default function EditWebTypePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<div className="mb-6 flex items-center gap-4">
|
||||||
<Link href={`/admin/standards/${webType}`}>
|
<Link href={`/admin/standards/${webType}`}>
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ export default function WebTypeDetailPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ export default function NewWebTypePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<div className="mb-6 flex items-center gap-4">
|
||||||
<Link href="/admin/standards">
|
<Link href="/admin/standards">
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ export default function WebTypesManagePage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-96 items-center justify-center">
|
<div className="flex h-96 items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2 text-lg text-destructive">웹타입 목록을 불러오는데 실패했습니다.</div>
|
<div className="text-destructive mb-2 text-lg">웹타입 목록을 불러오는데 실패했습니다.</div>
|
||||||
<Button onClick={() => refetch()} variant="outline">
|
<Button onClick={() => refetch()} variant="outline">
|
||||||
다시 시도
|
다시 시도
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -128,13 +128,13 @@ export default function WebTypesManagePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-background rounded-lg shadow-sm border p-6">
|
<div className="bg-background flex items-center justify-between rounded-lg border p-6 shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">웹타입 관리</h1>
|
<h1 className="text-foreground text-3xl font-bold">웹타입 관리</h1>
|
||||||
<p className="mt-2 text-muted-foreground">화면관리에서 사용할 웹타입들을 관리합니다</p>
|
<p className="text-muted-foreground mt-2">화면관리에서 사용할 웹타입들을 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/standards/new">
|
<Link href="/admin/standards/new">
|
||||||
<Button className="shadow-sm">
|
<Button className="shadow-sm">
|
||||||
|
|
@ -147,15 +147,15 @@ export default function WebTypesManagePage() {
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardHeader className="bg-muted/50">
|
<CardHeader className="bg-muted/50">
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Filter className="h-5 w-5 text-muted-foreground" />
|
<Filter className="text-muted-foreground h-5 w-5" />
|
||||||
필터 및 검색
|
필터 및 검색
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="웹타입명, 설명 검색..."
|
placeholder="웹타입명, 설명 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -202,7 +202,9 @@ export default function WebTypesManagePage() {
|
||||||
|
|
||||||
{/* 결과 통계 */}
|
{/* 결과 통계 */}
|
||||||
<div className="bg-background rounded-lg border px-4 py-3">
|
<div className="bg-background rounded-lg border px-4 py-3">
|
||||||
<p className="text-foreground text-sm font-medium">총 {filteredAndSortedWebTypes.length}개의 웹타입이 있습니다.</p>
|
<p className="text-foreground text-sm font-medium">
|
||||||
|
총 {filteredAndSortedWebTypes.length}개의 웹타입이 있습니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 웹타입 목록 테이블 */}
|
{/* 웹타입 목록 테이블 */}
|
||||||
|
|
@ -210,28 +212,40 @@ export default function WebTypesManagePage() {
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-background">
|
<TableRow className="bg-background">
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("sort_order")}>
|
<TableHead
|
||||||
|
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
|
||||||
|
onClick={() => handleSort("sort_order")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
순서
|
순서
|
||||||
{sortField === "sort_order" &&
|
{sortField === "sort_order" &&
|
||||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("web_type")}>
|
<TableHead
|
||||||
|
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
|
||||||
|
onClick={() => handleSort("web_type")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
웹타입 코드
|
웹타입 코드
|
||||||
{sortField === "web_type" &&
|
{sortField === "web_type" &&
|
||||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("type_name")}>
|
<TableHead
|
||||||
|
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
|
||||||
|
onClick={() => handleSort("type_name")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
웹타입명
|
웹타입명
|
||||||
{sortField === "type_name" &&
|
{sortField === "type_name" &&
|
||||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("category")}>
|
<TableHead
|
||||||
|
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
|
||||||
|
onClick={() => handleSort("category")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
카테고리
|
카테고리
|
||||||
{sortField === "category" &&
|
{sortField === "category" &&
|
||||||
|
|
@ -239,35 +253,47 @@ export default function WebTypesManagePage() {
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">설명</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">설명</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("component_name")}>
|
<TableHead
|
||||||
|
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
|
||||||
|
onClick={() => handleSort("component_name")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
연결된 컴포넌트
|
연결된 컴포넌트
|
||||||
{sortField === "component_name" &&
|
{sortField === "component_name" &&
|
||||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("config_panel")}>
|
<TableHead
|
||||||
|
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
|
||||||
|
onClick={() => handleSort("config_panel")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
설정 패널
|
설정 패널
|
||||||
{sortField === "config_panel" &&
|
{sortField === "config_panel" &&
|
||||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("is_active")}>
|
<TableHead
|
||||||
|
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
|
||||||
|
onClick={() => handleSort("is_active")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
상태
|
상태
|
||||||
{sortField === "is_active" &&
|
{sortField === "is_active" &&
|
||||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("updated_date")}>
|
<TableHead
|
||||||
|
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
|
||||||
|
onClick={() => handleSort("updated_date")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
최종 수정일
|
최종 수정일
|
||||||
{sortField === "updated_date" &&
|
{sortField === "updated_date" &&
|
||||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold text-center">작업</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-center text-sm font-semibold">작업</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -279,10 +305,10 @@ export default function WebTypesManagePage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredAndSortedWebTypes.map((webType) => (
|
filteredAndSortedWebTypes.map((webType) => (
|
||||||
<TableRow key={webType.web_type} className="bg-background transition-colors hover:bg-muted/50">
|
<TableRow key={webType.web_type} className="bg-background hover:bg-muted/50 transition-colors">
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{webType.sort_order || 0}</TableCell>
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{webType.sort_order || 0}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{webType.web_type}</TableCell>
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{webType.web_type}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 font-medium text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm font-medium">
|
||||||
{webType.type_name}
|
{webType.type_name}
|
||||||
{webType.type_name_eng && (
|
{webType.type_name_eng && (
|
||||||
<div className="text-muted-foreground text-xs">{webType.type_name_eng}</div>
|
<div className="text-muted-foreground text-xs">{webType.type_name_eng}</div>
|
||||||
|
|
@ -291,7 +317,9 @@ export default function WebTypesManagePage() {
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<Badge variant="secondary">{webType.category}</Badge>
|
<Badge variant="secondary">{webType.category}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 max-w-xs truncate text-sm">{webType.description || "-"}</TableCell>
|
<TableCell className="h-16 max-w-xs truncate px-6 py-3 text-sm">
|
||||||
|
{webType.description || "-"}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
{webType.component_name || "TextWidget"}
|
{webType.component_name || "TextWidget"}
|
||||||
|
|
@ -307,7 +335,7 @@ export default function WebTypesManagePage() {
|
||||||
{webType.is_active === "Y" ? "활성화" : "비활성화"}
|
{webType.is_active === "Y" ? "활성화" : "비활성화"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-muted-foreground text-sm">
|
<TableCell className="text-muted-foreground h-16 px-6 py-3 text-sm">
|
||||||
{webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"}
|
{webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
|
|
@ -325,7 +353,7 @@ export default function WebTypesManagePage() {
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="text-destructive h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|
@ -358,7 +386,7 @@ export default function WebTypesManagePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deleteError && (
|
{deleteError && (
|
||||||
<div className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 p-4">
|
<div className="border-destructive/30 bg-destructive/10 mt-4 rounded-md border p-4">
|
||||||
<p className="text-destructive">
|
<p className="text-destructive">
|
||||||
삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
|
삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -5,37 +5,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import { Plus, Search, MoreHorizontal, Edit, Trash2, Play, History, RefreshCw } from "lucide-react";
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
MoreHorizontal,
|
|
||||||
Edit,
|
|
||||||
Trash2,
|
|
||||||
Play,
|
|
||||||
History,
|
|
||||||
RefreshCw
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
|
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
|
||||||
|
|
@ -81,21 +59,22 @@ export default function CollectionManagementPage() {
|
||||||
|
|
||||||
// 검색어 필터
|
// 검색어 필터
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
filtered = filtered.filter(config =>
|
filtered = filtered.filter(
|
||||||
|
(config) =>
|
||||||
config.config_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
config.config_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
config.source_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
config.source_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
config.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
config.description?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 필터
|
// 상태 필터
|
||||||
if (statusFilter !== "all") {
|
if (statusFilter !== "all") {
|
||||||
filtered = filtered.filter(config => config.is_active === statusFilter);
|
filtered = filtered.filter((config) => config.is_active === statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 타입 필터
|
// 타입 필터
|
||||||
if (typeFilter !== "all") {
|
if (typeFilter !== "all") {
|
||||||
filtered = filtered.filter(config => config.collection_type === typeFilter);
|
filtered = filtered.filter((config) => config.collection_type === typeFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilteredConfigs(filtered);
|
setFilteredConfigs(filtered);
|
||||||
|
|
@ -149,7 +128,7 @@ export default function CollectionManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeBadge = (type: string) => {
|
const getTypeBadge = (type: string) => {
|
||||||
const option = collectionTypeOptions.find(opt => opt.value === type);
|
const option = collectionTypeOptions.find((opt) => opt.value === type);
|
||||||
const colors = {
|
const colors = {
|
||||||
full: "bg-blue-100 text-blue-800",
|
full: "bg-blue-100 text-blue-800",
|
||||||
incremental: "bg-purple-100 text-purple-800",
|
incremental: "bg-purple-100 text-purple-800",
|
||||||
|
|
@ -164,18 +143,15 @@ export default function CollectionManagementPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">수집 관리</h1>
|
<h1 className="text-2xl font-bold">수집 관리</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">외부 데이터베이스에서 데이터를 수집하는 설정을 관리합니다.</p>
|
||||||
외부 데이터베이스에서 데이터를 수집하는 설정을 관리합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCreate}>
|
<Button onClick={handleCreate}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="mr-2 h-4 w-4" />새 수집 설정
|
||||||
새 수집 설정
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -185,10 +161,10 @@ export default function CollectionManagementPage() {
|
||||||
<CardTitle>필터 및 검색</CardTitle>
|
<CardTitle>필터 및 검색</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="설정명, 테이블명, 설명으로 검색..."
|
placeholder="설정명, 테이블명, 설명으로 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -224,7 +200,7 @@ export default function CollectionManagementPage() {
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Button variant="outline" onClick={loadConfigs} disabled={isLoading}>
|
<Button variant="outline" onClick={loadConfigs} disabled={isLoading}>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -238,12 +214,12 @@ export default function CollectionManagementPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8">
|
<div className="py-8 text-center">
|
||||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
<RefreshCw className="mx-auto mb-2 h-8 w-8 animate-spin" />
|
||||||
<p>수집 설정을 불러오는 중...</p>
|
<p>수집 설정을 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredConfigs.length === 0 ? (
|
) : filteredConfigs.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-muted-foreground py-8 text-center">
|
||||||
{configs.length === 0 ? "수집 설정이 없습니다." : "검색 결과가 없습니다."}
|
{configs.length === 0 ? "수집 설정이 없습니다." : "검색 결과가 없습니다."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -262,36 +238,22 @@ export default function CollectionManagementPage() {
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredConfigs.map((config) => (
|
{filteredConfigs.map((config) => (
|
||||||
<TableRow key={config.id} className="bg-background transition-colors hover:bg-muted/50">
|
<TableRow key={config.id} className="bg-background hover:bg-muted/50 transition-colors">
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{config.config_name}</div>
|
<div className="font-medium">{config.config_name}</div>
|
||||||
{config.description && (
|
{config.description && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">{config.description}</div>
|
||||||
{config.description}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="h-16 px-6 py-3 text-sm">{getTypeBadge(config.collection_type)}</TableCell>
|
||||||
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{config.source_table}</TableCell>
|
||||||
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{config.target_table || "-"}</TableCell>
|
||||||
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{config.schedule_cron || "-"}</TableCell>
|
||||||
|
<TableCell className="h-16 px-6 py-3 text-sm">{getStatusBadge(config.is_active)}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
{getTypeBadge(config.collection_type)}
|
{config.last_collected_at ? new Date(config.last_collected_at).toLocaleString() : "-"}
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
|
||||||
{config.source_table}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
|
||||||
{config.target_table || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
|
||||||
{config.schedule_cron || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
|
||||||
{getStatusBadge(config.is_active)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
|
||||||
{config.last_collected_at
|
|
||||||
? new Date(config.last_collected_at).toLocaleString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -302,18 +264,15 @@ export default function CollectionManagementPage() {
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => handleEdit(config)}>
|
<DropdownMenuItem onClick={() => handleEdit(config)}>
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
수정
|
수정
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => handleExecute(config)} disabled={config.is_active !== "Y"}>
|
||||||
onClick={() => handleExecute(config)}
|
<Play className="mr-2 h-4 w-4" />
|
||||||
disabled={config.is_active !== "Y"}
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4 mr-2" />
|
|
||||||
실행
|
실행
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleDelete(config)}>
|
<DropdownMenuItem onClick={() => handleDelete(config)}>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
삭제
|
삭제
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ export default function CommonCodeManagementPage() {
|
||||||
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
|
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">공통코드 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">공통코드 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">시스템에서 사용하는 공통코드를 관리합니다</p>
|
<p className="text-muted-foreground text-sm">시스템에서 사용하는 공통코드를 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 콘텐츠 - 좌우 레이아웃 */}
|
{/* 메인 콘텐츠 - 좌우 레이아웃 */}
|
||||||
|
|
@ -33,7 +33,7 @@ export default function CommonCodeManagementPage() {
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
코드 상세 정보
|
코드 상세 정보
|
||||||
{selectedCategoryCode && (
|
{selectedCategoryCode && (
|
||||||
<span className="ml-2 text-sm font-normal text-muted-foreground">({selectedCategoryCode})</span>
|
<span className="text-muted-foreground ml-2 text-sm font-normal">({selectedCategoryCode})</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<CodeDetailPanel categoryCode={selectedCategoryCode} />
|
<CodeDetailPanel categoryCode={selectedCategoryCode} />
|
||||||
|
|
|
||||||
|
|
@ -684,7 +684,9 @@ export default function I18nPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="text-sm text-muted-foreground">총 {languages.length}개의 언어가 등록되어 있습니다.</div>
|
<div className="text-muted-foreground text-sm">
|
||||||
|
총 {languages.length}개의 언어가 등록되어 있습니다.
|
||||||
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{selectedLanguages.size > 0 && (
|
{selectedLanguages.size > 0 && (
|
||||||
<Button variant="destructive" onClick={handleDeleteLanguages}>
|
<Button variant="destructive" onClick={handleDeleteLanguages}>
|
||||||
|
|
@ -747,11 +749,7 @@ export default function I18nPage() {
|
||||||
<Button size="sm" variant="outline" onClick={handleAddKey}>
|
<Button size="sm" variant="outline" onClick={handleAddKey}>
|
||||||
수동 추가
|
수동 추가
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" onClick={() => setIsGenerateModalOpen(true)} disabled={!selectedCategory}>
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsGenerateModalOpen(true)}
|
|
||||||
disabled={!selectedCategory}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
자동 생성
|
자동 생성
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -762,7 +760,9 @@ export default function I18nPage() {
|
||||||
{/* 검색 필터 영역 */}
|
{/* 검색 필터 영역 */}
|
||||||
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="company" className="text-xs">회사</Label>
|
<Label htmlFor="company" className="text-xs">
|
||||||
|
회사
|
||||||
|
</Label>
|
||||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="전체 회사" />
|
<SelectValue placeholder="전체 회사" />
|
||||||
|
|
@ -779,7 +779,9 @@ export default function I18nPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="search" className="text-xs">검색</Label>
|
<Label htmlFor="search" className="text-xs">
|
||||||
|
검색
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="키명, 설명으로 검색..."
|
placeholder="키명, 설명으로 검색..."
|
||||||
value={searchText}
|
value={searchText}
|
||||||
|
|
@ -789,7 +791,7 @@ export default function I18nPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<div className="text-xs text-muted-foreground">결과: {getFilteredLangKeys().length}건</div>
|
<div className="text-muted-foreground text-xs">결과: {getFilteredLangKeys().length}건</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -900,4 +902,3 @@ export default function I18nPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,9 +146,9 @@ export default function TemplatesManagePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">템플릿 관리</h1>
|
<h1 className="text-3xl font-bold text-gray-900">템플릿 관리</h1>
|
||||||
<p className="mt-2 text-gray-600">화면 디자이너에서 사용할 템플릿을 관리합니다</p>
|
<p className="mt-2 text-gray-600">화면 디자이너에서 사용할 템플릿을 관리합니다</p>
|
||||||
|
|
@ -240,7 +240,7 @@ export default function TemplatesManagePage() {
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 px-6 py-3 w-[60px]">
|
<TableHead className="h-12 w-[60px] px-6 py-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -280,7 +280,7 @@ export default function TemplatesManagePage() {
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">공개 여부</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">공개 여부</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">활성화</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">활성화</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">수정일</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">수정일</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 w-[200px] text-sm font-semibold">작업</TableHead>
|
<TableHead className="h-12 w-[200px] px-6 py-3 text-sm font-semibold">작업</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -299,10 +299,13 @@ export default function TemplatesManagePage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredAndSortedTemplates.map((template) => (
|
filteredAndSortedTemplates.map((template) => (
|
||||||
<TableRow key={template.template_code} className="bg-background transition-colors hover:bg-muted/50">
|
<TableRow
|
||||||
|
key={template.template_code}
|
||||||
|
className="bg-background hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{template.sort_order || 0}</TableCell>
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{template.sort_order || 0}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{template.template_code}</TableCell>
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{template.template_code}</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 font-medium text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm font-medium">
|
||||||
{template.template_name}
|
{template.template_name}
|
||||||
{template.template_name_eng && (
|
{template.template_name_eng && (
|
||||||
<div className="text-muted-foreground text-xs">{template.template_name_eng}</div>
|
<div className="text-muted-foreground text-xs">{template.template_name_eng}</div>
|
||||||
|
|
@ -311,12 +314,16 @@ export default function TemplatesManagePage() {
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<Badge variant="secondary">{template.category}</Badge>
|
<Badge variant="secondary">{template.category}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 max-w-xs truncate text-sm">{template.description || "-"}</TableCell>
|
<TableCell className="h-16 max-w-xs truncate px-6 py-3 text-sm">
|
||||||
|
{template.description || "-"}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<div className="flex items-center justify-center">{renderIcon(template.icon_name)}</div>
|
<div className="flex items-center justify-center">{renderIcon(template.icon_name)}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-xs">
|
<TableCell className="h-16 px-6 py-3 font-mono text-xs">
|
||||||
{template.default_size ? `${template.default_size.width}×${template.default_size.height}` : "-"}
|
{template.default_size
|
||||||
|
? `${template.default_size.width}×${template.default_size.height}`
|
||||||
|
: "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<Badge variant={template.is_public === "Y" ? "default" : "secondary"}>
|
<Badge variant={template.is_public === "Y" ? "default" : "secondary"}>
|
||||||
|
|
@ -328,7 +335,7 @@ export default function TemplatesManagePage() {
|
||||||
{template.is_active === "Y" ? "활성화" : "비활성화"}
|
{template.is_active === "Y" ? "활성화" : "비활성화"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-muted-foreground text-sm">
|
<TableCell className="text-muted-foreground h-16 px-6 py-3 text-sm">
|
||||||
{template.updated_date ? new Date(template.updated_date).toLocaleDateString("ko-KR") : "-"}
|
{template.updated_date ? new Date(template.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,12 @@ export default function CompanyPage() {
|
||||||
} = useCompanyManagement();
|
} = useCompanyManagement();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">회사 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">회사 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">시스템에서 사용하는 회사 정보를 관리합니다</p>
|
<p className="text-muted-foreground text-sm">시스템에서 사용하는 회사 정보를 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 디스크 사용량 요약 */}
|
{/* 디스크 사용량 요약 */}
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,12 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/userMng/rolesList")} className="h-10 w-10">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push("/admin/userMng/rolesList")}
|
||||||
|
className="h-10 w-10"
|
||||||
|
>
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
|
||||||
|
|
@ -155,11 +155,13 @@ export default function RolesPage() {
|
||||||
// 관리자가 아니면 접근 제한
|
// 관리자가 아니면 접근 제한
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||||
|
|
@ -180,12 +182,14 @@ export default function RolesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
|
|
@ -361,4 +365,3 @@ export default function RolesPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,12 +109,12 @@ export default function UserMngPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">사용자 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">사용자 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">시스템 사용자 계정 및 권한을 관리합니다</p>
|
<p className="text-muted-foreground text-sm">시스템 사용자 계정 및 권한을 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,11 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
// 로딩 상태
|
// 로딩 상태
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-background">
|
<div className="bg-background flex h-screen items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-ring border-t-transparent" />
|
<div className="border-ring mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
|
||||||
<div className="text-lg font-medium text-foreground">대시보드 로딩 중...</div>
|
<div className="text-foreground text-lg font-medium">대시보드 로딩 중...</div>
|
||||||
<div className="mt-1 text-sm text-muted-foreground">잠시만 기다려주세요</div>
|
<div className="text-muted-foreground mt-1 text-sm">잠시만 기다려주세요</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -91,12 +91,15 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
// 에러 상태
|
// 에러 상태
|
||||||
if (error || !dashboard) {
|
if (error || !dashboard) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-background">
|
<div className="bg-background flex h-screen items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-4 text-6xl">😞</div>
|
<div className="mb-4 text-6xl">😞</div>
|
||||||
<div className="mb-2 text-xl font-medium text-foreground">{error || "대시보드를 찾을 수 없습니다"}</div>
|
<div className="text-foreground mb-2 text-xl font-medium">{error || "대시보드를 찾을 수 없습니다"}</div>
|
||||||
<div className="mb-4 text-sm text-muted-foreground">대시보드 ID: {resolvedParams.dashboardId}</div>
|
<div className="text-muted-foreground mb-4 text-sm">대시보드 ID: {resolvedParams.dashboardId}</div>
|
||||||
<button onClick={loadDashboard} className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90">
|
<button
|
||||||
|
onClick={loadDashboard}
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg px-4 py-2"
|
||||||
|
>
|
||||||
다시 시도
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -119,19 +119,19 @@ export default function DashboardListPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="bg-background min-h-screen">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="border-b border-border bg-card">
|
<div className="border-border bg-card border-b">
|
||||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">📊 대시보드</h1>
|
<h1 className="text-foreground text-3xl font-bold">📊 대시보드</h1>
|
||||||
<p className="mt-1 text-muted-foreground">데이터를 시각화하고 인사이트를 얻어보세요</p>
|
<p className="text-muted-foreground mt-1">데이터를 시각화하고 인사이트를 얻어보세요</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin/screenMng/dashboardList"
|
href="/admin/screenMng/dashboardList"
|
||||||
className="rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg px-6 py-3 font-medium"
|
||||||
>
|
>
|
||||||
➕ 새 대시보드 만들기
|
➕ 새 대시보드 만들기
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -145,9 +145,9 @@ export default function DashboardListPage() {
|
||||||
placeholder="대시보드 검색..."
|
placeholder="대시보드 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full rounded-lg border border-input bg-background py-2 pr-4 pl-10 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="border-input bg-background text-foreground focus-visible:ring-ring w-full rounded-lg border py-2 pr-4 pl-10 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-2.5 left-3 text-muted-foreground">🔍</div>
|
<div className="text-muted-foreground absolute top-2.5 left-3">🔍</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -159,15 +159,15 @@ export default function DashboardListPage() {
|
||||||
// 로딩 상태
|
// 로딩 상태
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
<div key={i} className="rounded-lg border border-border bg-card p-6 shadow-sm">
|
<div key={i} className="border-border bg-card rounded-lg border p-6 shadow-sm">
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="mb-3 h-4 w-3/4 rounded bg-muted"></div>
|
<div className="bg-muted mb-3 h-4 w-3/4 rounded"></div>
|
||||||
<div className="mb-2 h-3 w-full rounded bg-muted"></div>
|
<div className="bg-muted mb-2 h-3 w-full rounded"></div>
|
||||||
<div className="mb-4 h-3 w-2/3 rounded bg-muted"></div>
|
<div className="bg-muted mb-4 h-3 w-2/3 rounded"></div>
|
||||||
<div className="mb-4 h-32 rounded bg-muted"></div>
|
<div className="bg-muted mb-4 h-32 rounded"></div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="h-3 w-1/4 rounded bg-muted"></div>
|
<div className="bg-muted h-3 w-1/4 rounded"></div>
|
||||||
<div className="h-3 w-1/4 rounded bg-muted"></div>
|
<div className="bg-muted h-3 w-1/4 rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -177,16 +177,16 @@ export default function DashboardListPage() {
|
||||||
// 빈 상태
|
// 빈 상태
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<div className="mb-4 text-6xl">📊</div>
|
<div className="mb-4 text-6xl">📊</div>
|
||||||
<h3 className="mb-2 text-xl font-medium text-foreground">
|
<h3 className="text-foreground mb-2 text-xl font-medium">
|
||||||
{searchTerm ? "검색 결과가 없습니다" : "아직 대시보드가 없습니다"}
|
{searchTerm ? "검색 결과가 없습니다" : "아직 대시보드가 없습니다"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-6 text-muted-foreground">
|
<p className="text-muted-foreground mb-6">
|
||||||
{searchTerm ? "다른 검색어로 시도해보세요" : "첫 번째 대시보드를 만들어보세요"}
|
{searchTerm ? "다른 검색어로 시도해보세요" : "첫 번째 대시보드를 만들어보세요"}
|
||||||
</p>
|
</p>
|
||||||
{!searchTerm && (
|
{!searchTerm && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin/screenMng/dashboardList"
|
href="/admin/screenMng/dashboardList"
|
||||||
className="inline-flex items-center rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center rounded-lg px-6 py-3 font-medium"
|
||||||
>
|
>
|
||||||
➕ 대시보드 만들기
|
➕ 대시보드 만들기
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -214,30 +214,32 @@ interface DashboardCardProps {
|
||||||
*/
|
*/
|
||||||
function DashboardCard({ dashboard }: DashboardCardProps) {
|
function DashboardCard({ dashboard }: DashboardCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-card shadow-sm transition-shadow hover:shadow-md">
|
<div className="border-border bg-card rounded-lg border shadow-sm transition-shadow hover:shadow-md">
|
||||||
{/* 썸네일 영역 */}
|
{/* 썸네일 영역 */}
|
||||||
<div className="flex h-48 items-center justify-center rounded-t-lg bg-gradient-to-br from-primary/10 to-primary/20">
|
<div className="from-primary/10 to-primary/20 flex h-48 items-center justify-center rounded-t-lg bg-gradient-to-br">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2 text-4xl">📊</div>
|
<div className="mb-2 text-4xl">📊</div>
|
||||||
<div className="text-sm text-muted-foreground">{dashboard.elementsCount}개 요소</div>
|
<div className="text-muted-foreground text-sm">{dashboard.elementsCount}개 요소</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카드 내용 */}
|
{/* 카드 내용 */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-3 flex items-start justify-between">
|
<div className="mb-3 flex items-start justify-between">
|
||||||
<h3 className="line-clamp-1 text-lg font-semibold text-foreground">{dashboard.title}</h3>
|
<h3 className="text-foreground line-clamp-1 text-lg font-semibold">{dashboard.title}</h3>
|
||||||
{dashboard.isPublic ? (
|
{dashboard.isPublic ? (
|
||||||
<span className="rounded-full bg-success/10 px-2 py-1 text-xs text-success">공개</span>
|
<span className="bg-success/10 text-success rounded-full px-2 py-1 text-xs">공개</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">비공개</span>
|
<span className="bg-muted text-muted-foreground rounded-full px-2 py-1 text-xs">비공개</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dashboard.description && <p className="mb-4 line-clamp-2 text-sm text-muted-foreground">{dashboard.description}</p>}
|
{dashboard.description && (
|
||||||
|
<p className="text-muted-foreground mb-4 line-clamp-2 text-sm">{dashboard.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 메타 정보 */}
|
{/* 메타 정보 */}
|
||||||
<div className="mb-4 text-xs text-muted-foreground">
|
<div className="text-muted-foreground mb-4 text-xs">
|
||||||
<div>생성: {new Date(dashboard.createdAt).toLocaleDateString()}</div>
|
<div>생성: {new Date(dashboard.createdAt).toLocaleDateString()}</div>
|
||||||
<div>수정: {new Date(dashboard.updatedAt).toLocaleDateString()}</div>
|
<div>수정: {new Date(dashboard.updatedAt).toLocaleDateString()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -246,13 +248,13 @@ function DashboardCard({ dashboard }: DashboardCardProps) {
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/${dashboard.id}`}
|
href={`/dashboard/${dashboard.id}`}
|
||||||
className="flex-1 rounded-lg bg-primary px-4 py-2 text-center text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90 flex-1 rounded-lg px-4 py-2 text-center text-sm font-medium"
|
||||||
>
|
>
|
||||||
보기
|
보기
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/screenMng/dashboardList?load=${dashboard.id}`}
|
href={`/admin/screenMng/dashboardList?load=${dashboard.id}`}
|
||||||
className="rounded-lg border border-input bg-background px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground"
|
className="border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground rounded-lg border px-4 py-2 text-sm"
|
||||||
>
|
>
|
||||||
편집
|
편집
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -261,7 +263,7 @@ function DashboardCard({ dashboard }: DashboardCardProps) {
|
||||||
// 복사 기능 구현
|
// 복사 기능 구현
|
||||||
console.log("Dashboard copy:", dashboard.id);
|
console.log("Dashboard copy:", dashboard.id);
|
||||||
}}
|
}}
|
||||||
className="rounded-lg border border-input bg-background px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground"
|
className="border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground rounded-lg border px-4 py-2 text-sm"
|
||||||
title="복사"
|
title="복사"
|
||||||
>
|
>
|
||||||
📋
|
📋
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ export default function MultiLangPage() {
|
||||||
const filteredLangKeys = getFilteredLangKeys();
|
const filteredLangKeys = getFilteredLangKeys();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold">다국어 관리</h1>
|
<h1 className="text-3xl font-bold">다국어 관리</h1>
|
||||||
<Button>새 키 추가</Button>
|
<Button>새 키 추가</Button>
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@ export default function MainHomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4">
|
<div className="space-y-6 p-4">
|
||||||
{/* 대시보드 컨텐츠 */}
|
{/* 대시보드 컨텐츠 */}
|
||||||
<div className="rounded-lg border bg-background p-6 shadow-sm">
|
<div className="bg-background rounded-lg border p-6 shadow-sm">
|
||||||
<h3 className="mb-4 text-lg font-semibold">WACE 솔루션에 오신 것을 환영합니다!</h3>
|
<h3 className="mb-4 text-lg font-semibold">WACE 솔루션에 오신 것을 환영합니다!</h3>
|
||||||
<p className="mb-6 text-muted-foreground">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
<p className="text-muted-foreground mb-6">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<span className="inline-flex items-center rounded-md bg-success/10 px-2 py-1 text-xs font-medium text-success ring-1 ring-success/10 ring-inset">
|
<span className="bg-success/10 text-success ring-success/10 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset">
|
||||||
Next.js
|
Next.js
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary ring-1 ring-primary/10 ring-inset">
|
<span className="bg-primary/10 text-primary ring-primary/10 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset">
|
||||||
Shadcn/ui
|
Shadcn/ui
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,13 @@ export interface ScreenViewPageProps {
|
||||||
|
|
||||||
function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
|
function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
|
||||||
// 스케줄 자동 생성 서비스 활성화
|
// 스케줄 자동 생성 서비스 활성화
|
||||||
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
|
const {
|
||||||
|
showConfirmDialog,
|
||||||
|
previewResult,
|
||||||
|
handleConfirm,
|
||||||
|
closeDialog,
|
||||||
|
isLoading: scheduleLoading,
|
||||||
|
} = useScheduleGenerator();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -300,19 +306,25 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||||
isVisible: false,
|
isVisible: false,
|
||||||
isLocked: false,
|
isLocked: false,
|
||||||
// Zone 기반 조건 (Zone에서 트리거 정보를 가져옴)
|
// Zone 기반 조건 (Zone에서 트리거 정보를 가져옴)
|
||||||
condition: zone ? {
|
condition: zone
|
||||||
|
? {
|
||||||
targetComponentId: zone.trigger_component_id || "",
|
targetComponentId: zone.trigger_component_id || "",
|
||||||
operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq",
|
operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq",
|
||||||
value: conditionValue,
|
value: conditionValue,
|
||||||
} : condConfig.targetComponentId ? {
|
}
|
||||||
|
: condConfig.targetComponentId
|
||||||
|
? {
|
||||||
targetComponentId: condConfig.targetComponentId,
|
targetComponentId: condConfig.targetComponentId,
|
||||||
operator: condConfig.operator || "eq",
|
operator: condConfig.operator || "eq",
|
||||||
value: condConfig.value,
|
value: condConfig.value,
|
||||||
} : undefined,
|
}
|
||||||
|
: undefined,
|
||||||
// Zone 기반: displayRegion은 Zone에서 가져옴
|
// Zone 기반: displayRegion은 Zone에서 가져옴
|
||||||
zoneId: zoneId || undefined,
|
zoneId: zoneId || undefined,
|
||||||
conditionValue: conditionValue || undefined,
|
conditionValue: conditionValue || undefined,
|
||||||
displayRegion: zone ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } : condConfig.displayRegion || undefined,
|
displayRegion: zone
|
||||||
|
? { x: zone.x, y: zone.y, width: zone.width, height: zone.height }
|
||||||
|
: condConfig.displayRegion || undefined,
|
||||||
components: layerComponents,
|
components: layerComponents,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -322,20 +334,33 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({
|
console.log(
|
||||||
id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue,
|
"🔄 조건부 레이어 로드 완료:",
|
||||||
|
layerDefinitions.length,
|
||||||
|
"개",
|
||||||
|
layerDefinitions.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
name: l.name,
|
||||||
|
zoneId: l.zoneId,
|
||||||
|
conditionValue: l.conditionValue,
|
||||||
componentCount: l.components.length,
|
componentCount: l.components.length,
|
||||||
condition: l.condition ? {
|
condition: l.condition
|
||||||
|
? {
|
||||||
targetComponentId: l.condition.targetComponentId,
|
targetComponentId: l.condition.targetComponentId,
|
||||||
operator: l.condition.operator,
|
operator: l.condition.operator,
|
||||||
value: l.condition.value,
|
value: l.condition.value,
|
||||||
} : "없음",
|
}
|
||||||
})));
|
: "없음",
|
||||||
console.log("🗺️ Zone 정보:", loadedZones.map(z => ({
|
})),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"🗺️ Zone 정보:",
|
||||||
|
loadedZones.map((z) => ({
|
||||||
zone_id: z.zone_id,
|
zone_id: z.zone_id,
|
||||||
trigger_component_id: z.trigger_component_id,
|
trigger_component_id: z.trigger_component_id,
|
||||||
trigger_operator: z.trigger_operator,
|
trigger_operator: z.trigger_operator,
|
||||||
})));
|
})),
|
||||||
|
);
|
||||||
setConditionalLayers(layerDefinitions);
|
setConditionalLayers(layerDefinitions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이어/Zone 로드 실패:", error);
|
console.error("레이어/Zone 로드 실패:", error);
|
||||||
|
|
@ -381,10 +406,13 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||||
break;
|
break;
|
||||||
case "in":
|
case "in":
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
isMatch = value.some(v => String(v) === String(targetValue ?? ""));
|
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
|
||||||
} else if (typeof value === "string" && value.includes(",")) {
|
} else if (typeof value === "string" && value.includes(",")) {
|
||||||
// 쉼표로 구분된 문자열도 지원
|
// 쉼표로 구분된 문자열도 지원
|
||||||
isMatch = value.split(",").map(v => v.trim()).includes(String(targetValue ?? ""));
|
isMatch = value
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.includes(String(targetValue ?? ""));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -468,9 +496,7 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||||
// 테이블 위젯이 없는 경우에만 자동 로드 (테이블이 있으면 행 선택으로 데이터 로드)
|
// 테이블 위젯이 없는 경우에만 자동 로드 (테이블이 있으면 행 선택으로 데이터 로드)
|
||||||
const hasTableWidget = layout.components.some(
|
const hasTableWidget = layout.components.some(
|
||||||
(comp: any) =>
|
(comp: any) =>
|
||||||
comp.componentType === "table-list" ||
|
comp.componentType === "table-list" || comp.componentType === "v2-table-list" || comp.widgetType === "table",
|
||||||
comp.componentType === "v2-table-list" ||
|
|
||||||
comp.widgetType === "table"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasTableWidget) {
|
if (hasTableWidget) {
|
||||||
|
|
@ -480,7 +506,8 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||||
// 인풋 컴포넌트들 중 메인 테이블의 컬럼을 사용하는 것들 찾기
|
// 인풋 컴포넌트들 중 메인 테이블의 컬럼을 사용하는 것들 찾기
|
||||||
const inputComponents = layout.components.filter((comp: any) => {
|
const inputComponents = layout.components.filter((comp: any) => {
|
||||||
const compType = comp.componentType || comp.widgetType;
|
const compType = comp.componentType || comp.widgetType;
|
||||||
const isInputType = compType?.includes("input") ||
|
const isInputType =
|
||||||
|
compType?.includes("input") ||
|
||||||
compType?.includes("select") ||
|
compType?.includes("select") ||
|
||||||
compType?.includes("textarea") ||
|
compType?.includes("textarea") ||
|
||||||
compType?.includes("v2-input") ||
|
compType?.includes("v2-input") ||
|
||||||
|
|
@ -504,7 +531,7 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||||
mainTableName,
|
mainTableName,
|
||||||
"company_code",
|
"company_code",
|
||||||
companyCode,
|
companyCode,
|
||||||
"*" // 모든 컬럼
|
"*", // 모든 컬럼
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result && result.record) {
|
if (result && result.record) {
|
||||||
|
|
@ -888,7 +915,7 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||||
if (component.position.y >= zoneBottom) {
|
if (component.position.y >= zoneBottom) {
|
||||||
// Zone에 매칭되는 활성 레이어가 있는지 확인
|
// Zone에 매칭되는 활성 레이어가 있는지 확인
|
||||||
const hasActiveLayer = conditionalLayers.some(
|
const hasActiveLayer = conditionalLayers.some(
|
||||||
l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id)
|
(l) => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id),
|
||||||
);
|
);
|
||||||
if (!hasActiveLayer) {
|
if (!hasActiveLayer) {
|
||||||
// Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거)
|
// Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거)
|
||||||
|
|
@ -1224,7 +1251,7 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||||
if (!isActive || !layer.components || layer.components.length === 0) return null;
|
if (!isActive || !layer.components || layer.components.length === 0) return null;
|
||||||
|
|
||||||
// Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정
|
// Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정
|
||||||
const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null;
|
const zone = layer.zoneId ? zones.find((z) => z.zone_id === layer.zoneId) : null;
|
||||||
const region = zone
|
const region = zone
|
||||||
? { x: zone.x, y: zone.y, width: zone.width, height: zone.height }
|
? { x: zone.x, y: zone.y, width: zone.width, height: zone.height }
|
||||||
: layer.displayRegion;
|
: layer.displayRegion;
|
||||||
|
|
|
||||||
|
|
@ -38,30 +38,30 @@ export default function TestFlowPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted p-8">
|
<div className="bg-muted min-h-screen p-8">
|
||||||
<div className="mx-auto max-w-7xl space-y-8">
|
<div className="mx-auto max-w-7xl space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">플로우 위젯 테스트</h1>
|
<h1 className="text-foreground text-3xl font-bold">플로우 위젯 테스트</h1>
|
||||||
<p className="mt-2 text-muted-foreground">두 가지 플로우를 테스트할 수 있습니다</p>
|
<p className="text-muted-foreground mt-2">두 가지 플로우를 테스트할 수 있습니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 문서 승인 플로우 */}
|
{/* 문서 승인 플로우 */}
|
||||||
<div className="rounded-lg bg-background p-6 shadow-lg">
|
<div className="bg-background rounded-lg p-6 shadow-lg">
|
||||||
<h2 className="mb-4 text-xl font-semibold text-foreground">문서 승인 플로우 (4단계)</h2>
|
<h2 className="text-foreground mb-4 text-xl font-semibold">문서 승인 플로우 (4단계)</h2>
|
||||||
<FlowWidget component={documentFlow} />
|
<FlowWidget component={documentFlow} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 작업 요청 워크플로우 */}
|
{/* 작업 요청 워크플로우 */}
|
||||||
<div className="rounded-lg bg-background p-6 shadow-lg">
|
<div className="bg-background rounded-lg p-6 shadow-lg">
|
||||||
<h2 className="mb-4 text-xl font-semibold text-foreground">작업 요청 워크플로우 (6단계)</h2>
|
<h2 className="text-foreground mb-4 text-xl font-semibold">작업 요청 워크플로우 (6단계)</h2>
|
||||||
<FlowWidget component={workRequestFlow} />
|
<FlowWidget component={workRequestFlow} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 사용 안내 */}
|
{/* 사용 안내 */}
|
||||||
<div className="mt-8 rounded-lg border border-primary/20 bg-primary/5 p-6">
|
<div className="border-primary/20 bg-primary/5 mt-8 rounded-lg border p-6">
|
||||||
<h3 className="mb-2 text-lg font-semibold text-primary">사용 방법</h3>
|
<h3 className="text-primary mb-2 text-lg font-semibold">사용 방법</h3>
|
||||||
<ul className="list-inside list-disc space-y-1 text-primary/80">
|
<ul className="text-primary/80 list-inside list-disc space-y-1">
|
||||||
<li>각 플로우 단계를 클릭하면 해당 단계의 데이터 목록이 표시됩니다</li>
|
<li>각 플로우 단계를 클릭하면 해당 단계의 데이터 목록이 표시됩니다</li>
|
||||||
<li>데이터 행을 체크하고 "다음 단계로 이동" 버튼을 클릭하면 데이터가 이동됩니다</li>
|
<li>데이터 행을 체크하고 "다음 단계로 이동" 버튼을 클릭하면 데이터가 이동됩니다</li>
|
||||||
<li>이동 후 자동으로 데이터 목록이 새로고침됩니다</li>
|
<li>이동 후 자동으로 데이터 목록이 새로고침됩니다</li>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
||||||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
|
|
@ -28,10 +27,7 @@ import {
|
||||||
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
|
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
|
||||||
import "@/lib/registry/pop-components";
|
import "@/lib/registry/pop-components";
|
||||||
import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
|
import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
|
||||||
import {
|
import { useResponsiveModeWithOverride, type DeviceType } from "@/hooks/useDeviceOrientation";
|
||||||
useResponsiveModeWithOverride,
|
|
||||||
type DeviceType,
|
|
||||||
} from "@/hooks/useDeviceOrientation";
|
|
||||||
|
|
||||||
// 디바이스별 크기 (너비만, 높이는 콘텐츠 기반)
|
// 디바이스별 크기 (너비만, 높이는 콘텐츠 기반)
|
||||||
const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width: number; label: string }>> = {
|
const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width: number; label: string }>> = {
|
||||||
|
|
@ -69,7 +65,7 @@ function PopScreenViewPage() {
|
||||||
// 프리뷰 모드에서는 수동 전환 가능
|
// 프리뷰 모드에서는 수동 전환 가능
|
||||||
const { mode, setDevice, setOrientation, isAutoDetect } = useResponsiveModeWithOverride(
|
const { mode, setDevice, setOrientation, isAutoDetect } = useResponsiveModeWithOverride(
|
||||||
isPreviewMode ? "tablet" : undefined,
|
isPreviewMode ? "tablet" : undefined,
|
||||||
isPreviewMode ? true : undefined
|
isPreviewMode ? true : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 현재 모드 정보
|
// 현재 모드 정보
|
||||||
|
|
@ -89,9 +85,7 @@ function PopScreenViewPage() {
|
||||||
// 모드 결정:
|
// 모드 결정:
|
||||||
// - 프리뷰 모드: 수동 선택한 device/orientation 사용
|
// - 프리뷰 모드: 수동 선택한 device/orientation 사용
|
||||||
// - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치)
|
// - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치)
|
||||||
const currentModeKey = isPreviewMode
|
const currentModeKey = isPreviewMode ? getModeKey(deviceType, isLandscape) : detectGridMode(viewportWidth);
|
||||||
? getModeKey(deviceType, isLandscape)
|
|
||||||
: detectGridMode(viewportWidth);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateViewportWidth = () => {
|
const updateViewportWidth = () => {
|
||||||
|
|
@ -136,7 +130,7 @@ function PopScreenViewPage() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[POP] 화면 로드 실패:", error);
|
console.error("[POP] 화면 로드 실패:", error);
|
||||||
setError("화면을 불러오는데 실패했습니다.");
|
setError("화면을 불러오는데 실패했습니다.");
|
||||||
showErrorToast("POP 화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." });
|
toast.error("화면을 불러오는데 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +180,7 @@ function PopScreenViewPage() {
|
||||||
if (error || !screen) {
|
if (error || !screen) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full items-center justify-center bg-gray-100">
|
<div className="flex h-screen w-full items-center justify-center bg-gray-100">
|
||||||
<div className="text-center max-w-md p-6">
|
<div className="max-w-md p-6 text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||||
<span className="text-2xl">!</span>
|
<span className="text-2xl">!</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,24 +199,22 @@ function PopScreenViewPage() {
|
||||||
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
||||||
<ActiveTabProvider>
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div className="h-screen bg-gray-100 flex flex-col">
|
<div className="flex h-screen flex-col bg-gray-100">
|
||||||
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
||||||
{isPreviewMode && (
|
{isPreviewMode && (
|
||||||
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
<div className="sticky top-0 z-50 border-b bg-white shadow-sm">
|
||||||
<div className="flex items-center justify-between px-4 py-2">
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => window.close()}>
|
<Button variant="ghost" size="sm" onClick={() => window.close()}>
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
닫기
|
닫기
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-medium">{screen.screenName}</span>
|
<span className="text-sm font-medium">{screen.screenName}</span>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">({currentModeKey.replace("_", " ")})</span>
|
||||||
({currentModeKey.replace("_", " ")})
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
<div className="flex items-center gap-1 rounded-lg bg-gray-100 p-1">
|
||||||
<Button
|
<Button
|
||||||
variant={deviceType === "mobile" ? "default" : "ghost"}
|
variant={deviceType === "mobile" ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -243,7 +235,7 @@ function PopScreenViewPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
<div className="flex items-center gap-1 rounded-lg bg-gray-100 p-1">
|
||||||
<Button
|
<Button
|
||||||
variant={isLandscape ? "default" : "ghost"}
|
variant={isLandscape ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -286,28 +278,29 @@ function PopScreenViewPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* POP 화면 컨텐츠 */}
|
{/* POP 화면 컨텐츠 */}
|
||||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
|
<div className={`flex flex-1 flex-col overflow-auto ${isPreviewMode ? "items-center py-4" : ""}`}>
|
||||||
{/* 현재 모드 표시 (일반 모드) */}
|
{/* 현재 모드 표시 (일반 모드) */}
|
||||||
{!isPreviewMode && (
|
{!isPreviewMode && (
|
||||||
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
<div className="absolute top-2 right-2 z-10 rounded bg-black/50 px-2 py-1 text-xs text-white">
|
||||||
{currentModeKey.replace("_", " ")}
|
{currentModeKey.replace("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`}
|
className={`bg-white transition-all duration-300 ${isPreviewMode ? "overflow-auto rounded-3xl border-8 border-gray-800 shadow-2xl" : "min-h-full w-full"}`}
|
||||||
style={isPreviewMode ? {
|
style={
|
||||||
|
isPreviewMode
|
||||||
|
? {
|
||||||
width: currentDevice.width,
|
width: currentDevice.width,
|
||||||
maxHeight: "80vh",
|
maxHeight: "80vh",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
} : undefined}
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* v5 그리드 렌더러 */}
|
{/* v5 그리드 렌더러 */}
|
||||||
{hasComponents ? (
|
{hasComponents ? (
|
||||||
<div
|
<div className="mx-auto min-h-full" style={{ maxWidth: 1366 }}>
|
||||||
className="mx-auto min-h-full"
|
|
||||||
style={{ maxWidth: 1366 }}
|
|
||||||
>
|
|
||||||
{(() => {
|
{(() => {
|
||||||
// Gap 프리셋 계산
|
// Gap 프리셋 계산
|
||||||
const currentGapPreset = layout.settings.gapPreset || "medium";
|
const currentGapPreset = layout.settings.gapPreset || "medium";
|
||||||
|
|
@ -332,14 +325,12 @@ function PopScreenViewPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 빈 화면
|
// 빈 화면
|
||||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
|
<div className="flex min-h-[400px] flex-col items-center justify-center p-8 text-center">
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
|
||||||
<Smartphone className="h-8 w-8 text-gray-400" />
|
<Smartphone className="h-8 w-8 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">
|
<h3 className="mb-2 text-lg font-semibold text-gray-800">화면이 비어있습니다</h3>
|
||||||
화면이 비어있습니다
|
<p className="max-w-xs text-sm text-gray-500">
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 max-w-xs">
|
|
||||||
POP 화면 디자이너에서 컴포넌트를 추가하여 화면을 구성하세요.
|
POP 화면 디자이너에서 컴포넌트를 추가하여 화면을 구성하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,3 @@ import { PopApp } from "@/components/pop";
|
||||||
export default function PopWorkPage() {
|
export default function PopWorkPage() {
|
||||||
return <PopApp />;
|
return <PopApp />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,3 @@ import { PopApp } from "@/components/pop";
|
||||||
export default function PopWorkPage() {
|
export default function PopWorkPage() {
|
||||||
return <PopApp />;
|
return <PopApp />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,11 @@ export default function TestAutocompleteMapping() {
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 space-y-6">
|
<div className="container mx-auto space-y-6 py-8">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>AutocompleteSearchInput 필드 자동 매핑 테스트</CardTitle>
|
<CardTitle>AutocompleteSearchInput 필드 자동 매핑 테스트</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>거래처를 선택하면 아래 입력 필드들이 자동으로 채워집니다</CardDescription>
|
||||||
거래처를 선택하면 아래 입력 필드들이 자동으로 채워집니다
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* 검색 컴포넌트 */}
|
{/* 검색 컴포넌트 */}
|
||||||
|
|
@ -61,9 +59,7 @@ export default function TestAutocompleteMapping() {
|
||||||
|
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<div className="border-t pt-6">
|
<div className="border-t pt-6">
|
||||||
<h3 className="text-sm font-semibold mb-4">
|
<h3 className="mb-4 text-sm font-semibold">자동으로 채워지는 필드들</h3>
|
||||||
자동으로 채워지는 필드들
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 거래처명 */}
|
{/* 거래처명 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -102,8 +98,8 @@ export default function TestAutocompleteMapping() {
|
||||||
|
|
||||||
{/* 상태 표시 */}
|
{/* 상태 표시 */}
|
||||||
<div className="border-t pt-6">
|
<div className="border-t pt-6">
|
||||||
<h3 className="text-sm font-semibold mb-2">현재 상태</h3>
|
<h3 className="mb-2 text-sm font-semibold">현재 상태</h3>
|
||||||
<div className="p-4 bg-muted rounded-lg">
|
<div className="bg-muted rounded-lg p-4">
|
||||||
<pre className="text-xs">
|
<pre className="text-xs">
|
||||||
{JSON.stringify(
|
{JSON.stringify(
|
||||||
{
|
{
|
||||||
|
|
@ -113,7 +109,7 @@ export default function TestAutocompleteMapping() {
|
||||||
phone,
|
phone,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2,
|
||||||
)}
|
)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,7 +123,7 @@ export default function TestAutocompleteMapping() {
|
||||||
<CardTitle className="text-base">사용 방법</CardTitle>
|
<CardTitle className="text-base">사용 방법</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 text-sm">
|
<CardContent className="space-y-2 text-sm">
|
||||||
<ol className="list-decimal list-inside space-y-2">
|
<ol className="list-inside list-decimal space-y-2">
|
||||||
<li>위의 검색 필드에 거래처명이나 코드를 입력하세요</li>
|
<li>위의 검색 필드에 거래처명이나 코드를 입력하세요</li>
|
||||||
<li>드롭다운에서 원하는 거래처를 선택하세요</li>
|
<li>드롭다운에서 원하는 거래처를 선택하세요</li>
|
||||||
<li>아래 입력 필드들이 자동으로 채워지는 것을 확인하세요</li>
|
<li>아래 입력 필드들이 자동으로 채워지는 것을 확인하세요</li>
|
||||||
|
|
@ -138,4 +134,3 @@ export default function TestAutocompleteMapping() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,22 +56,22 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
|
|
||||||
// 파일 아이콘 가져오기
|
// 파일 아이콘 가져오기
|
||||||
const getFileIcon = (fileName: string, size: number = 16) => {
|
const getFileIcon = (fileName: string, size: number = 16) => {
|
||||||
const extension = fileName.split('.').pop()?.toLowerCase() || '';
|
const extension = fileName.split(".").pop()?.toLowerCase() || "";
|
||||||
const iconProps = { size, className: "text-muted-foreground" };
|
const iconProps = { size, className: "text-muted-foreground" };
|
||||||
|
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) {
|
if (["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(extension)) {
|
||||||
return <Image {...iconProps} className="text-primary" />;
|
return <Image {...iconProps} className="text-primary" />;
|
||||||
}
|
}
|
||||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(extension)) {
|
if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(extension)) {
|
||||||
return <Video {...iconProps} className="text-purple-600" />;
|
return <Video {...iconProps} className="text-purple-600" />;
|
||||||
}
|
}
|
||||||
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(extension)) {
|
if (["mp3", "wav", "flac", "aac", "ogg"].includes(extension)) {
|
||||||
return <Music {...iconProps} className="text-green-600" />;
|
return <Music {...iconProps} className="text-green-600" />;
|
||||||
}
|
}
|
||||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) {
|
if (["zip", "rar", "7z", "tar", "gz"].includes(extension)) {
|
||||||
return <Archive {...iconProps} className="text-yellow-600" />;
|
return <Archive {...iconProps} className="text-yellow-600" />;
|
||||||
}
|
}
|
||||||
if (['txt', 'md', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) {
|
if (["txt", "md", "doc", "docx", "pdf", "rtf"].includes(extension)) {
|
||||||
return <FileText {...iconProps} className="text-destructive" />;
|
return <FileText {...iconProps} className="text-destructive" />;
|
||||||
}
|
}
|
||||||
return <File {...iconProps} />;
|
return <File {...iconProps} />;
|
||||||
|
|
@ -97,28 +97,27 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
|
|
||||||
// 탭별 필터링
|
// 탭별 필터링
|
||||||
if (tab === "images") {
|
if (tab === "images") {
|
||||||
filtered = files.filter(file => {
|
filtered = files.filter((file) => {
|
||||||
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || '';
|
const ext = file.realFileName?.split(".").pop()?.toLowerCase() || "";
|
||||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext);
|
return ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext);
|
||||||
});
|
});
|
||||||
} else if (tab === "documents") {
|
} else if (tab === "documents") {
|
||||||
filtered = files.filter(file => {
|
filtered = files.filter((file) => {
|
||||||
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || '';
|
const ext = file.realFileName?.split(".").pop()?.toLowerCase() || "";
|
||||||
return ['txt', 'md', 'doc', 'docx', 'pdf', 'rtf', 'hwp', 'hwpx'].includes(ext);
|
return ["txt", "md", "doc", "docx", "pdf", "rtf", "hwp", "hwpx"].includes(ext);
|
||||||
});
|
});
|
||||||
} else if (tab === "recent") {
|
} else if (tab === "recent") {
|
||||||
filtered = files
|
filtered = files.sort((a, b) => new Date(b.uploadTime).getTime() - new Date(a.uploadTime).getTime()).slice(0, 20);
|
||||||
.sort((a, b) => new Date(b.uploadTime).getTime() - new Date(a.uploadTime).getTime())
|
|
||||||
.slice(0, 20);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 필터링
|
// 검색 필터링
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
filtered = filtered.filter(file =>
|
filtered = filtered.filter(
|
||||||
|
(file) =>
|
||||||
file.realFileName?.toLowerCase().includes(lowerQuery) ||
|
file.realFileName?.toLowerCase().includes(lowerQuery) ||
|
||||||
file.savedFileName?.toLowerCase().includes(lowerQuery) ||
|
file.savedFileName?.toLowerCase().includes(lowerQuery) ||
|
||||||
file.uploadPage?.toLowerCase().includes(lowerQuery)
|
file.uploadPage?.toLowerCase().includes(lowerQuery),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,24 +164,19 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
<div className={`w-full ${className}`}>
|
<div className={`w-full ${className}`}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<File className="w-5 h-5" />
|
<File className="h-5 w-5" />
|
||||||
전역 파일 저장소
|
전역 파일 저장소
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary" className="flex items-center gap-1">
|
<Badge variant="secondary" className="flex items-center gap-1">
|
||||||
<Info className="w-3 h-3" />
|
<Info className="h-3 w-3" />
|
||||||
{registryInfo.accessibleFiles}개 파일
|
{registryInfo.accessibleFiles}개 파일
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={refreshFiles} className="flex items-center gap-1">
|
||||||
variant="outline"
|
<RefreshCw className="h-3 w-3" />
|
||||||
size="sm"
|
|
||||||
onClick={refreshFiles}
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3 h-3" />
|
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -192,7 +186,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="파일명으로 검색..."
|
placeholder="파일명으로 검색..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
|
|
@ -214,37 +208,32 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value={selectedTab} className="mt-4">
|
<TabsContent value={selectedTab} className="mt-4">
|
||||||
<div
|
<div className="space-y-2 overflow-y-auto" style={{ maxHeight }}>
|
||||||
className="space-y-2 overflow-y-auto"
|
|
||||||
style={{ maxHeight }}
|
|
||||||
>
|
|
||||||
{filteredFiles.length === 0 ? (
|
{filteredFiles.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="py-8 text-center text-gray-500">
|
||||||
{searchQuery ? "검색 결과가 없습니다." : "저장된 파일이 없습니다."}
|
{searchQuery ? "검색 결과가 없습니다." : "저장된 파일이 없습니다."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredFiles.map((file) => (
|
filteredFiles.map((file) => (
|
||||||
<Card key={file.objid} className="p-3">
|
<Card key={file.objid} className="p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
{getFileIcon(file.realFileName || file.savedFileName || "", 20)}
|
{getFileIcon(file.realFileName || file.savedFileName || "", 20)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium truncate">
|
<div className="truncate font-medium">{file.realFileName || file.savedFileName}</div>
|
||||||
{file.realFileName || file.savedFileName}
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 flex items-center gap-2">
|
|
||||||
<span>{formatFileSize(file.fileSize)}</span>
|
<span>{formatFileSize(file.fileSize)}</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{new Date(file.uploadTime).toLocaleDateString()}
|
{new Date(file.uploadTime).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<MapPin className="w-3 h-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
{file.uploadPage.split('/').pop() || 'Unknown'}
|
{file.uploadPage.split("/").pop() || "Unknown"}
|
||||||
</div>
|
</div>
|
||||||
{file.screenId && (
|
{file.screenId && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Monitor className="w-3 h-3" />
|
<Monitor className="h-3 w-3" />
|
||||||
Screen {file.screenId}
|
Screen {file.screenId}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -259,7 +248,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
onClick={() => handleView(file)}
|
onClick={() => handleView(file)}
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -267,15 +256,15 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
onClick={() => handleDownload(file)}
|
onClick={() => handleDownload(file)}
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemove(file)}
|
onClick={() => handleRemove(file)}
|
||||||
className="flex items-center gap-1 text-destructive hover:text-red-700"
|
className="text-destructive flex items-center gap-1 hover:text-red-700"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,10 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -199,23 +187,23 @@ export default function AdvancedBatchModal({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden">
|
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-[800px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>고급 배치 생성</DialogTitle>
|
<DialogTitle>고급 배치 생성</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6 py-2">
|
<form onSubmit={handleSubmit} className="space-y-6 py-2">
|
||||||
{/* 1. 기본 정보 섹션 */}
|
{/* 1. 기본 정보 섹션 */}
|
||||||
<div className="space-y-4 border rounded-md p-4 bg-slate-50">
|
<div className="space-y-4 rounded-md border bg-slate-50 p-4">
|
||||||
<h3 className="text-sm font-semibold text-slate-900">기본 정보</h3>
|
<h3 className="text-sm font-semibold text-slate-900">기본 정보</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">배치 타입 *</Label>
|
<Label className="text-xs">배치 타입 *</Label>
|
||||||
<div className="mt-1 p-2 bg-white border rounded text-sm font-medium text-slate-600">
|
<div className="mt-1 rounded border bg-white p-2 text-sm font-medium text-slate-600">
|
||||||
{formData.job_type === "rest_to_db" ? "🌐 REST API → 💾 DB" : "💾 DB → 🌐 REST API"}
|
{formData.job_type === "rest_to_db" ? "🌐 REST API → 💾 DB" : "💾 DB → 🌐 REST API"}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-400 mt-1">
|
<p className="mt-1 text-[10px] text-slate-400">
|
||||||
{formData.job_type === "rest_to_db"
|
{formData.job_type === "rest_to_db"
|
||||||
? "REST API에서 데이터를 가져와 데이터베이스에 저장합니다."
|
? "REST API에서 데이터를 가져와 데이터베이스에 저장합니다."
|
||||||
: "데이터베이스의 데이터를 REST API로 전송합니다."}
|
: "데이터베이스의 데이터를 REST API로 전송합니다."}
|
||||||
|
|
@ -223,22 +211,26 @@ export default function AdvancedBatchModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="schedule_cron" className="text-xs">실행 스케줄 *</Label>
|
<Label htmlFor="schedule_cron" className="text-xs">
|
||||||
<div className="flex gap-2 mt-1">
|
실행 스케줄 *
|
||||||
|
</Label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="schedule_cron"
|
id="schedule_cron"
|
||||||
value={formData.schedule_cron || ""}
|
value={formData.schedule_cron || ""}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, schedule_cron: e.target.value }))}
|
||||||
placeholder="예: 0 12 * * *"
|
placeholder="예: 0 12 * * *"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
<Select onValueChange={(val) => setFormData(prev => ({ ...prev, schedule_cron: val }))}>
|
<Select onValueChange={(val) => setFormData((prev) => ({ ...prev, schedule_cron: val }))}>
|
||||||
<SelectTrigger className="w-[100px]">
|
<SelectTrigger className="w-[100px]">
|
||||||
<SelectValue placeholder="프리셋" />
|
<SelectValue placeholder="프리셋" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{schedulePresets.map(p => (
|
{schedulePresets.map((p) => (
|
||||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
<SelectItem key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -246,22 +238,26 @@ export default function AdvancedBatchModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<Label htmlFor="job_name" className="text-xs">배치명 *</Label>
|
<Label htmlFor="job_name" className="text-xs">
|
||||||
|
배치명 *
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="job_name"
|
id="job_name"
|
||||||
value={formData.job_name || ""}
|
value={formData.job_name || ""}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, job_name: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, job_name: e.target.value }))}
|
||||||
placeholder="배치명을 입력하세요"
|
placeholder="배치명을 입력하세요"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<Label htmlFor="description" className="text-xs">설명</Label>
|
<Label htmlFor="description" className="text-xs">
|
||||||
|
설명
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={formData.description || ""}
|
value={formData.description || ""}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
||||||
placeholder="배치에 대한 설명을 입력하세요"
|
placeholder="배치에 대한 설명을 입력하세요"
|
||||||
className="mt-1 min-h-[60px]"
|
className="mt-1 min-h-[60px]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -270,7 +266,7 @@ export default function AdvancedBatchModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. REST API 설정 섹션 (Source) */}
|
{/* 2. REST API 설정 섹션 (Source) */}
|
||||||
<div className="space-y-4 border rounded-md p-4 bg-white">
|
<div className="space-y-4 rounded-md border bg-white p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg">🌐</span>
|
<span className="text-lg">🌐</span>
|
||||||
<h3 className="text-sm font-semibold text-slate-900">
|
<h3 className="text-sm font-semibold text-slate-900">
|
||||||
|
|
@ -278,46 +274,54 @@ export default function AdvancedBatchModal({
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<Label htmlFor="api_url" className="text-xs">API 서버 URL *</Label>
|
<Label htmlFor="api_url" className="text-xs">
|
||||||
|
API 서버 URL *
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="api_url"
|
id="api_url"
|
||||||
value={configData.apiUrl || ""}
|
value={configData.apiUrl || ""}
|
||||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiUrl: e.target.value }))}
|
onChange={(e) => setConfigData((prev) => ({ ...prev, apiUrl: e.target.value }))}
|
||||||
placeholder="https://api.example.com"
|
placeholder="https://api.example.com"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<Label htmlFor="api_key" className="text-xs">API 키 (선택)</Label>
|
<Label htmlFor="api_key" className="text-xs">
|
||||||
|
API 키 (선택)
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="api_key"
|
id="api_key"
|
||||||
type="password"
|
type="password"
|
||||||
value={configData.apiKey || ""}
|
value={configData.apiKey || ""}
|
||||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiKey: e.target.value }))}
|
onChange={(e) => setConfigData((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
placeholder="인증에 필요한 API Key가 있다면 입력하세요"
|
placeholder="인증에 필요한 API Key가 있다면 입력하세요"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="endpoint" className="text-xs">엔드포인트 *</Label>
|
<Label htmlFor="endpoint" className="text-xs">
|
||||||
|
엔드포인트 *
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="endpoint"
|
id="endpoint"
|
||||||
value={configData.endpoint || ""}
|
value={configData.endpoint || ""}
|
||||||
onChange={(e) => setConfigData(prev => ({ ...prev, endpoint: e.target.value }))}
|
onChange={(e) => setConfigData((prev) => ({ ...prev, endpoint: e.target.value }))}
|
||||||
placeholder="/api/token"
|
placeholder="/api/token"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="http_method" className="text-xs">HTTP 메서드</Label>
|
<Label htmlFor="http_method" className="text-xs">
|
||||||
|
HTTP 메서드
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={configData.httpMethod || "GET"}
|
value={configData.httpMethod || "GET"}
|
||||||
onValueChange={(val) => setConfigData(prev => ({ ...prev, httpMethod: val }))}
|
onValueChange={(val) => setConfigData((prev) => ({ ...prev, httpMethod: val }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1">
|
<SelectTrigger className="mt-1">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -333,16 +337,18 @@ export default function AdvancedBatchModal({
|
||||||
|
|
||||||
{/* POST/PUT 일 때 Body 입력창 노출 */}
|
{/* POST/PUT 일 때 Body 입력창 노출 */}
|
||||||
{(configData.httpMethod === "POST" || configData.httpMethod === "PUT") && (
|
{(configData.httpMethod === "POST" || configData.httpMethod === "PUT") && (
|
||||||
<div className="sm:col-span-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
<div className="animate-in fade-in slide-in-from-top-2 duration-200 sm:col-span-2">
|
||||||
<Label htmlFor="api_body" className="text-xs">Request Body (JSON)</Label>
|
<Label htmlFor="api_body" className="text-xs">
|
||||||
|
Request Body (JSON)
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="api_body"
|
id="api_body"
|
||||||
value={configData.apiBody || ""}
|
value={configData.apiBody || ""}
|
||||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiBody: e.target.value }))}
|
onChange={(e) => setConfigData((prev) => ({ ...prev, apiBody: e.target.value }))}
|
||||||
placeholder='{"username": "myuser", "password": "mypassword"}'
|
placeholder='{"username": "myuser", "password": "mypassword"}'
|
||||||
className="mt-1 font-mono text-xs min-h-[100px]"
|
className="mt-1 min-h-[100px] font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-slate-500 mt-1">
|
<p className="mt-1 text-[10px] text-slate-500">
|
||||||
* 토큰 발급 요청 시 인증 정보를 JSON 형식으로 입력하세요.
|
* 토큰 발급 요청 시 인증 정보를 JSON 형식으로 입력하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -351,7 +357,7 @@ export default function AdvancedBatchModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3. 데이터베이스 설정 섹션 (Target) */}
|
{/* 3. 데이터베이스 설정 섹션 (Target) */}
|
||||||
<div className="space-y-4 border rounded-md p-4 bg-white">
|
<div className="space-y-4 rounded-md border bg-white p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg">💾</span>
|
<span className="text-lg">💾</span>
|
||||||
<h3 className="text-sm font-semibold text-slate-900">
|
<h3 className="text-sm font-semibold text-slate-900">
|
||||||
|
|
@ -359,14 +365,14 @@ export default function AdvancedBatchModal({
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">데이터베이스 커넥션 선택</Label>
|
<Label className="text-xs">데이터베이스 커넥션 선택</Label>
|
||||||
<Select
|
<Select
|
||||||
value={configData.targetConnectionId?.toString() || ""}
|
value={configData.targetConnectionId?.toString() || ""}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
const connId = parseInt(val);
|
const connId = parseInt(val);
|
||||||
setConfigData(prev => ({ ...prev, targetConnectionId: connId }));
|
setConfigData((prev) => ({ ...prev, targetConnectionId: connId }));
|
||||||
loadTables(connId); // 테이블 목록 로드
|
loadTables(connId); // 테이블 목록 로드
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -374,7 +380,7 @@ export default function AdvancedBatchModal({
|
||||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{connections.map(conn => (
|
{connections.map((conn) => (
|
||||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||||
{conn.connection_name || conn.name} ({conn.db_type})
|
{conn.connection_name || conn.name} ({conn.db_type})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -387,7 +393,7 @@ export default function AdvancedBatchModal({
|
||||||
<Label className="text-xs">테이블 선택</Label>
|
<Label className="text-xs">테이블 선택</Label>
|
||||||
<Select
|
<Select
|
||||||
value={configData.targetTable || ""}
|
value={configData.targetTable || ""}
|
||||||
onValueChange={(val) => setConfigData(prev => ({ ...prev, targetTable: val }))}
|
onValueChange={(val) => setConfigData((prev) => ({ ...prev, targetTable: val }))}
|
||||||
disabled={!configData.targetConnectionId}
|
disabled={!configData.targetConnectionId}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1">
|
<SelectTrigger className="mt-1">
|
||||||
|
|
@ -395,11 +401,13 @@ export default function AdvancedBatchModal({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{targetTables.length > 0 ? (
|
{targetTables.length > 0 ? (
|
||||||
targetTables.map(table => (
|
targetTables.map((table) => (
|
||||||
<SelectItem key={table} value={table}>{table}</SelectItem>
|
<SelectItem key={table} value={table}>
|
||||||
|
{table}
|
||||||
|
</SelectItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 text-xs text-center text-slate-400">테이블 없음</div>
|
<div className="p-2 text-center text-xs text-slate-400">테이블 없음</div>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -420,5 +428,3 @@ export default function AdvancedBatchModal({
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,9 +214,7 @@ export function AuthenticationConfig({
|
||||||
id="db-value-column"
|
id="db-value-column"
|
||||||
type="text"
|
type="text"
|
||||||
value={authConfig.dbValueColumn || ""}
|
value={authConfig.dbValueColumn || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateAuthConfig("dbValueColumn", e.target.value)}
|
||||||
updateAuthConfig("dbValueColumn", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="예: access_token"
|
placeholder="예: access_token"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -227,9 +225,7 @@ export function AuthenticationConfig({
|
||||||
id="db-where-column"
|
id="db-where-column"
|
||||||
type="text"
|
type="text"
|
||||||
value={authConfig.dbWhereColumn || ""}
|
value={authConfig.dbWhereColumn || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateAuthConfig("dbWhereColumn", e.target.value)}
|
||||||
updateAuthConfig("dbWhereColumn", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="예: service_name"
|
placeholder="예: service_name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -240,9 +236,7 @@ export function AuthenticationConfig({
|
||||||
id="db-where-value"
|
id="db-where-value"
|
||||||
type="text"
|
type="text"
|
||||||
value={authConfig.dbWhereValue || ""}
|
value={authConfig.dbWhereValue || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateAuthConfig("dbWhereValue", e.target.value)}
|
||||||
updateAuthConfig("dbWhereValue", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="예: kakao"
|
placeholder="예: kakao"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -253,31 +247,23 @@ export function AuthenticationConfig({
|
||||||
id="db-header-name"
|
id="db-header-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={authConfig.dbHeaderName || ""}
|
value={authConfig.dbHeaderName || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateAuthConfig("dbHeaderName", e.target.value)}
|
||||||
updateAuthConfig("dbHeaderName", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="기본값: Authorization"
|
placeholder="기본값: Authorization"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="db-header-template">
|
<Label htmlFor="db-header-template">헤더 템플릿 (선택, {{value}} 치환)</Label>
|
||||||
헤더 템플릿 (선택, {{value}} 치환)
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="db-header-template"
|
id="db-header-template"
|
||||||
type="text"
|
type="text"
|
||||||
value={authConfig.dbHeaderTemplate || ""}
|
value={authConfig.dbHeaderTemplate || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateAuthConfig("dbHeaderTemplate", e.target.value)}
|
||||||
updateAuthConfig("dbHeaderTemplate", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder='기본값: "Bearer {{value}}"'
|
placeholder='기본값: "Bearer {{value}}"'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">company_code는 현재 로그인한 사용자의 회사 코드로 자동 필터링됩니다.</p>
|
||||||
company_code는 현재 로그인한 사용자의 회사 코드로 자동 필터링됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,7 @@ import React from "react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Play, Pause, Edit, Trash2, RefreshCw, Clock, Database, Calendar, Activity, Settings } from "lucide-react";
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
Edit,
|
|
||||||
Trash2,
|
|
||||||
RefreshCw,
|
|
||||||
Clock,
|
|
||||||
Database,
|
|
||||||
Calendar,
|
|
||||||
Activity,
|
|
||||||
Settings
|
|
||||||
} from "lucide-react";
|
|
||||||
import { BatchConfig } from "@/lib/api/batch";
|
import { BatchConfig } from "@/lib/api/batch";
|
||||||
|
|
||||||
interface BatchCardProps {
|
interface BatchCardProps {
|
||||||
|
|
@ -35,28 +24,26 @@ export default function BatchCard({
|
||||||
onToggleStatus,
|
onToggleStatus,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
getMappingSummary
|
getMappingSummary,
|
||||||
}: BatchCardProps) {
|
}: BatchCardProps) {
|
||||||
// 상태에 따른 스타일 결정
|
// 상태에 따른 스타일 결정
|
||||||
const isExecuting = executingBatch === batch.id;
|
const isExecuting = executingBatch === batch.id;
|
||||||
const isActive = batch.is_active === 'Y';
|
const isActive = batch.is_active === "Y";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-lg border bg-card shadow-sm transition-colors hover:bg-muted/50">
|
<Card className="bg-card hover:bg-muted/50 rounded-lg border shadow-sm transition-colors">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-4 flex items-start justify-between">
|
<div className="mb-4 flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<Settings className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
<Settings className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||||
<h3 className="text-base font-semibold truncate">{batch.batch_name}</h3>
|
<h3 className="truncate text-base font-semibold">{batch.batch_name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{batch.description || "설명 없음"}</p>
|
||||||
{batch.description || '설명 없음'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={isActive ? 'default' : 'secondary'} className="ml-2 flex-shrink-0">
|
<Badge variant={isActive ? "default" : "secondary"} className="ml-2 flex-shrink-0">
|
||||||
{isExecuting ? '실행 중' : isActive ? '활성' : '비활성'}
|
{isExecuting ? "실행 중" : isActive ? "활성" : "비활성"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -64,34 +51,30 @@ export default function BatchCard({
|
||||||
<div className="space-y-2 border-t pt-4">
|
<div className="space-y-2 border-t pt-4">
|
||||||
{/* 스케줄 정보 */}
|
{/* 스케줄 정보 */}
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="flex items-center gap-2 text-muted-foreground">
|
<span className="text-muted-foreground flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
스케줄
|
스케줄
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium truncate ml-2">{batch.cron_schedule}</span>
|
<span className="ml-2 truncate font-medium">{batch.cron_schedule}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 생성일 정보 */}
|
{/* 생성일 정보 */}
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="flex items-center gap-2 text-muted-foreground">
|
<span className="text-muted-foreground flex items-center gap-2">
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
생성일
|
생성일
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">{new Date(batch.created_date).toLocaleDateString("ko-KR")}</span>
|
||||||
{new Date(batch.created_date).toLocaleDateString('ko-KR')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 매핑 정보 */}
|
{/* 매핑 정보 */}
|
||||||
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="flex items-center gap-2 text-muted-foreground">
|
<span className="text-muted-foreground flex items-center gap-2">
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
매핑
|
매핑
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">{batch.batch_mappings.length}개</span>
|
||||||
{batch.batch_mappings.length}개
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,15 +82,12 @@ export default function BatchCard({
|
||||||
{/* 실행 중 프로그레스 */}
|
{/* 실행 중 프로그레스 */}
|
||||||
{isExecuting && (
|
{isExecuting && (
|
||||||
<div className="mt-4 space-y-2 border-t pt-4">
|
<div className="mt-4 space-y-2 border-t pt-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-primary">
|
<div className="text-primary flex items-center gap-2 text-sm">
|
||||||
<Activity className="h-4 w-4 animate-pulse" />
|
<Activity className="h-4 w-4 animate-pulse" />
|
||||||
<span>실행 중...</span>
|
<span>실행 중...</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
<div className="bg-secondary h-2 w-full overflow-hidden rounded-full">
|
||||||
<div
|
<div className="bg-primary h-full animate-pulse rounded-full" style={{ width: "45%" }} />
|
||||||
className="h-full animate-pulse rounded-full bg-primary"
|
|
||||||
style={{ width: '45%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -122,11 +102,7 @@ export default function BatchCard({
|
||||||
disabled={isExecuting}
|
disabled={isExecuting}
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
>
|
>
|
||||||
{isExecuting ? (
|
{isExecuting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
실행
|
실행
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
@ -137,21 +113,12 @@ export default function BatchCard({
|
||||||
onClick={() => onToggleStatus(batch.id, batch.is_active)}
|
onClick={() => onToggleStatus(batch.id, batch.is_active)}
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
>
|
>
|
||||||
{isActive ? (
|
{isActive ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
<Pause className="h-4 w-4" />
|
{isActive ? "비활성" : "활성"}
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{isActive ? '비활성' : '활성'}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 수정 버튼 */}
|
{/* 수정 버튼 */}
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => onEdit(batch.id)} className="h-9 flex-1 gap-2 text-sm">
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onEdit(batch.id)}
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
수정
|
수정
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
|
|
||||||
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -32,12 +20,7 @@ interface BatchJobModalProps {
|
||||||
job?: BatchJob | null;
|
job?: BatchJob | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BatchJobModal({
|
export default function BatchJobModal({ isOpen, onClose, onSave, job }: BatchJobModalProps) {
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
job,
|
|
||||||
}: BatchJobModalProps) {
|
|
||||||
const [formData, setFormData] = useState<Partial<BatchJob>>({
|
const [formData, setFormData] = useState<Partial<BatchJob>>({
|
||||||
job_name: "",
|
job_name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
@ -133,23 +116,21 @@ export default function BatchJobModal({
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 작업 저장 오류:", error);
|
console.error("배치 작업 저장 오류:", error);
|
||||||
toast.error(
|
toast.error(error instanceof Error ? error.message : "배치 작업 저장에 실패했습니다.");
|
||||||
error instanceof Error ? error.message : "배치 작업 저장에 실패했습니다."
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSchedulePresetSelect = (preset: string) => {
|
const handleSchedulePresetSelect = (preset: string) => {
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
schedule_cron: preset,
|
schedule_cron: preset,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJobTypeChange = (jobType: string) => {
|
const handleJobTypeChange = (jobType: string) => {
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
job_type: jobType as any,
|
job_type: jobType as any,
|
||||||
config_json: {},
|
config_json: {},
|
||||||
|
|
@ -157,7 +138,7 @@ export default function BatchJobModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCollectionConfigChange = (configId: string) => {
|
const handleCollectionConfigChange = (configId: string) => {
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
config_json: {
|
config_json: {
|
||||||
...prev.config_json,
|
...prev.config_json,
|
||||||
|
|
@ -172,9 +153,7 @@ export default function BatchJobModal({
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">
|
<DialogTitle className="text-base sm:text-lg">{job ? "배치 작업 수정" : "새 배치 작업"}</DialogTitle>
|
||||||
{job ? "배치 작업 수정" : "새 배치 작업"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||||
|
|
@ -184,13 +163,13 @@ export default function BatchJobModal({
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="job_name" className="text-xs sm:text-sm">작업명 *</Label>
|
<Label htmlFor="job_name" className="text-xs sm:text-sm">
|
||||||
|
작업명 *
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="job_name"
|
id="job_name"
|
||||||
value={formData.job_name || ""}
|
value={formData.job_name || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData((prev) => ({ ...prev, job_name: e.target.value }))}
|
||||||
setFormData(prev => ({ ...prev, job_name: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="배치 작업명을 입력하세요"
|
placeholder="배치 작업명을 입력하세요"
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
required
|
required
|
||||||
|
|
@ -198,11 +177,10 @@ export default function BatchJobModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="job_type" className="text-xs sm:text-sm">작업 타입 *</Label>
|
<Label htmlFor="job_type" className="text-xs sm:text-sm">
|
||||||
<Select
|
작업 타입 *
|
||||||
value={formData.job_type || "collection"}
|
</Label>
|
||||||
onValueChange={handleJobTypeChange}
|
<Select value={formData.job_type || "collection"} onValueChange={handleJobTypeChange}>
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -218,13 +196,13 @@ export default function BatchJobModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="description" className="text-xs sm:text-sm">설명</Label>
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||||
|
설명
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={formData.description || ""}
|
value={formData.description || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
||||||
setFormData(prev => ({ ...prev, description: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="배치 작업에 대한 설명을 입력하세요"
|
placeholder="배치 작업에 대한 설명을 입력하세요"
|
||||||
className="min-h-[60px] text-xs sm:min-h-[80px] sm:text-sm"
|
className="min-h-[60px] text-xs sm:min-h-[80px] sm:text-sm"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
|
@ -233,12 +211,14 @@ export default function BatchJobModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 작업 설정 */}
|
{/* 작업 설정 */}
|
||||||
{formData.job_type === 'collection' && (
|
{formData.job_type === "collection" && (
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<h3 className="text-sm font-semibold sm:text-base">수집 설정</h3>
|
<h3 className="text-sm font-semibold sm:text-base">수집 설정</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="collection_config" className="text-xs sm:text-sm">수집 설정</Label>
|
<Label htmlFor="collection_config" className="text-xs sm:text-sm">
|
||||||
|
수집 설정
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.config_json?.collectionConfigId?.toString() || ""}
|
value={formData.config_json?.collectionConfigId?.toString() || ""}
|
||||||
onValueChange={handleCollectionConfigChange}
|
onValueChange={handleCollectionConfigChange}
|
||||||
|
|
@ -263,14 +243,14 @@ export default function BatchJobModal({
|
||||||
<h3 className="text-sm font-semibold sm:text-base">스케줄 설정</h3>
|
<h3 className="text-sm font-semibold sm:text-base">스케줄 설정</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="schedule_cron" className="text-xs sm:text-sm">Cron 표현식</Label>
|
<Label htmlFor="schedule_cron" className="text-xs sm:text-sm">
|
||||||
|
Cron 표현식
|
||||||
|
</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="schedule_cron"
|
id="schedule_cron"
|
||||||
value={formData.schedule_cron || ""}
|
value={formData.schedule_cron || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData((prev) => ({ ...prev, schedule_cron: e.target.value }))}
|
||||||
setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="예: 0 0 * * * (매일 자정)"
|
placeholder="예: 0 0 * * * (매일 자정)"
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
@ -296,30 +276,24 @@ export default function BatchJobModal({
|
||||||
<h3 className="text-sm font-semibold sm:text-base">실행 통계</h3>
|
<h3 className="text-sm font-semibold sm:text-base">실행 통계</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 sm:gap-4">
|
<div className="grid grid-cols-3 gap-2 sm:gap-4">
|
||||||
<div className="rounded-lg border bg-card p-3 sm:p-4">
|
<div className="bg-card rounded-lg border p-3 sm:p-4">
|
||||||
<div className="text-xl font-bold text-primary sm:text-2xl">
|
<div className="text-primary text-xl font-bold sm:text-2xl">{formData.execution_count || 0}</div>
|
||||||
{formData.execution_count || 0}
|
<div className="text-muted-foreground text-xs sm:text-sm">총 실행</div>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground sm:text-sm">총 실행</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-3 sm:p-4">
|
<div className="bg-card rounded-lg border p-3 sm:p-4">
|
||||||
<div className="text-xl font-bold text-primary sm:text-2xl">
|
<div className="text-primary text-xl font-bold sm:text-2xl">{formData.success_count || 0}</div>
|
||||||
{formData.success_count || 0}
|
<div className="text-muted-foreground text-xs sm:text-sm">성공</div>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground sm:text-sm">성공</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-3 sm:p-4">
|
<div className="bg-card rounded-lg border p-3 sm:p-4">
|
||||||
<div className="text-xl font-bold text-destructive sm:text-2xl">
|
<div className="text-destructive text-xl font-bold sm:text-2xl">{formData.failure_count || 0}</div>
|
||||||
{formData.failure_count || 0}
|
<div className="text-muted-foreground text-xs sm:text-sm">실패</div>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground sm:text-sm">실패</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.last_executed_at && (
|
{formData.last_executed_at && (
|
||||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||||
마지막 실행: {new Date(formData.last_executed_at).toLocaleString()}
|
마지막 실행: {new Date(formData.last_executed_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -332,11 +306,11 @@ export default function BatchJobModal({
|
||||||
<Switch
|
<Switch
|
||||||
id="is_active"
|
id="is_active"
|
||||||
checked={formData.is_active === "Y"}
|
checked={formData.is_active === "Y"}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, is_active: checked ? "Y" : "N" }))}
|
||||||
setFormData(prev => ({ ...prev, is_active: checked ? "Y" : "N" }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="is_active" className="text-xs sm:text-sm">활성화</Label>
|
<Label htmlFor="is_active" className="text-xs sm:text-sm">
|
||||||
|
활성화
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||||
|
|
@ -353,11 +327,7 @@ export default function BatchJobModal({
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" disabled={isLoading} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
||||||
>
|
|
||||||
{isLoading ? "저장 중..." : "저장"}
|
{isLoading ? "저장 중..." : "저장"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,8 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all",
|
"bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all",
|
||||||
isSelected
|
isSelected ? "shadow-md" : "hover:shadow-md",
|
||||||
? "shadow-md"
|
|
||||||
: "hover:shadow-md",
|
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
|
|
@ -70,8 +68,8 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
|
||||||
{category.is_active === "Y" ? "활성" : "비활성"}
|
{category.is_active === "Y" ? "활성" : "비활성"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{category.category_code}</p>
|
<p className="text-muted-foreground mt-1 text-xs">{category.category_code}</p>
|
||||||
{category.description && <p className="mt-1 text-xs text-muted-foreground">{category.description}</p>}
|
{category.description && <p className="text-muted-foreground mt-1 text-xs">{category.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 액션 버튼 */}
|
{/* 액션 버튼 */}
|
||||||
|
|
|
||||||
|
|
@ -174,17 +174,25 @@ export function CodeCategoryFormModal({
|
||||||
{/* 카테고리 코드 */}
|
{/* 카테고리 코드 */}
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="categoryCode" className="text-xs sm:text-sm">카테고리 코드 *</Label>
|
<Label htmlFor="categoryCode" className="text-xs sm:text-sm">
|
||||||
|
카테고리 코드 *
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="categoryCode"
|
id="categoryCode"
|
||||||
{...createForm.register("categoryCode")}
|
{...createForm.register("categoryCode")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="카테고리 코드를 입력하세요"
|
placeholder="카테고리 코드를 입력하세요"
|
||||||
className={createForm.formState.errors.categoryCode ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"}
|
className={
|
||||||
|
createForm.formState.errors.categoryCode
|
||||||
|
? "border-destructive h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
: "h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
}
|
||||||
onBlur={() => handleFieldBlur("categoryCode")}
|
onBlur={() => handleFieldBlur("categoryCode")}
|
||||||
/>
|
/>
|
||||||
{createForm.formState.errors.categoryCode && (
|
{createForm.formState.errors.categoryCode && (
|
||||||
<p className="text-[10px] sm:text-xs text-destructive">{createForm.formState.errors.categoryCode.message}</p>
|
<p className="text-destructive text-[10px] sm:text-xs">
|
||||||
|
{createForm.formState.errors.categoryCode.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{!createForm.formState.errors.categoryCode && (
|
{!createForm.formState.errors.categoryCode && (
|
||||||
<ValidationMessage
|
<ValidationMessage
|
||||||
|
|
@ -199,9 +207,16 @@ export function CodeCategoryFormModal({
|
||||||
{/* 카테고리 코드 표시 (수정 시) */}
|
{/* 카테고리 코드 표시 (수정 시) */}
|
||||||
{isEditing && editingCategory && (
|
{isEditing && editingCategory && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="categoryCodeDisplay" className="text-xs sm:text-sm">카테고리 코드</Label>
|
<Label htmlFor="categoryCodeDisplay" className="text-xs sm:text-sm">
|
||||||
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed" />
|
카테고리 코드
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground">카테고리 코드는 수정할 수 없습니다.</p>
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="categoryCodeDisplay"
|
||||||
|
value={editingCategory.category_code}
|
||||||
|
disabled
|
||||||
|
className="bg-muted h-8 cursor-not-allowed text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-[10px] sm:text-xs">카테고리 코드는 수정할 수 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -226,10 +241,10 @@ export function CodeCategoryFormModal({
|
||||||
/>
|
/>
|
||||||
{isEditing
|
{isEditing
|
||||||
? updateForm.formState.errors.categoryName && (
|
? updateForm.formState.errors.categoryName && (
|
||||||
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryName.message}</p>
|
<p className="text-destructive text-sm">{updateForm.formState.errors.categoryName.message}</p>
|
||||||
)
|
)
|
||||||
: createForm.formState.errors.categoryName && (
|
: createForm.formState.errors.categoryName && (
|
||||||
<p className="text-sm text-destructive">{createForm.formState.errors.categoryName.message}</p>
|
<p className="text-destructive text-sm">{createForm.formState.errors.categoryName.message}</p>
|
||||||
)}
|
)}
|
||||||
{!(isEditing ? updateForm.formState.errors.categoryName : createForm.formState.errors.categoryName) && (
|
{!(isEditing ? updateForm.formState.errors.categoryName : createForm.formState.errors.categoryName) && (
|
||||||
<ValidationMessage
|
<ValidationMessage
|
||||||
|
|
@ -261,10 +276,10 @@ export function CodeCategoryFormModal({
|
||||||
/>
|
/>
|
||||||
{isEditing
|
{isEditing
|
||||||
? updateForm.formState.errors.categoryNameEng && (
|
? updateForm.formState.errors.categoryNameEng && (
|
||||||
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryNameEng.message}</p>
|
<p className="text-destructive text-sm">{updateForm.formState.errors.categoryNameEng.message}</p>
|
||||||
)
|
)
|
||||||
: createForm.formState.errors.categoryNameEng && (
|
: createForm.formState.errors.categoryNameEng && (
|
||||||
<p className="text-sm text-destructive">{createForm.formState.errors.categoryNameEng.message}</p>
|
<p className="text-destructive text-sm">{createForm.formState.errors.categoryNameEng.message}</p>
|
||||||
)}
|
)}
|
||||||
{!(isEditing
|
{!(isEditing
|
||||||
? updateForm.formState.errors.categoryNameEng
|
? updateForm.formState.errors.categoryNameEng
|
||||||
|
|
@ -299,10 +314,10 @@ export function CodeCategoryFormModal({
|
||||||
/>
|
/>
|
||||||
{isEditing
|
{isEditing
|
||||||
? updateForm.formState.errors.description && (
|
? updateForm.formState.errors.description && (
|
||||||
<p className="text-sm text-destructive">{updateForm.formState.errors.description.message}</p>
|
<p className="text-destructive text-sm">{updateForm.formState.errors.description.message}</p>
|
||||||
)
|
)
|
||||||
: createForm.formState.errors.description && (
|
: createForm.formState.errors.description && (
|
||||||
<p className="text-sm text-destructive">{createForm.formState.errors.description.message}</p>
|
<p className="text-destructive text-sm">{createForm.formState.errors.description.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -329,10 +344,10 @@ export function CodeCategoryFormModal({
|
||||||
/>
|
/>
|
||||||
{isEditing
|
{isEditing
|
||||||
? updateForm.formState.errors.sortOrder && (
|
? updateForm.formState.errors.sortOrder && (
|
||||||
<p className="text-sm text-destructive">{updateForm.formState.errors.sortOrder.message}</p>
|
<p className="text-destructive text-sm">{updateForm.formState.errors.sortOrder.message}</p>
|
||||||
)
|
)
|
||||||
: createForm.formState.errors.sortOrder && (
|
: createForm.formState.errors.sortOrder && (
|
||||||
<p className="text-sm text-destructive">{createForm.formState.errors.sortOrder.message}</p>
|
<p className="text-destructive text-sm">{createForm.formState.errors.sortOrder.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||||
{/* 검색 + 버튼 */}
|
{/* 검색 + 버튼 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="카테고리 검색..."
|
placeholder="카테고리 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -119,9 +119,9 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||||
id="activeOnly"
|
id="activeOnly"
|
||||||
checked={showActiveOnly}
|
checked={showActiveOnly}
|
||||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||||
className="h-4 w-4 rounded border-input"
|
className="border-input h-4 w-4 rounded"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="activeOnly" className="text-sm text-muted-foreground">
|
<label htmlFor="activeOnly" className="text-muted-foreground text-sm">
|
||||||
활성만 표시
|
활성만 표시
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -135,7 +135,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||||
</div>
|
</div>
|
||||||
) : categories.length === 0 ? (
|
) : categories.length === 0 ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
|
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -156,13 +156,13 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
<LoadingSpinner size="sm" />
|
<LoadingSpinner size="sm" />
|
||||||
<span className="ml-2 text-sm text-muted-foreground">추가 로딩 중...</span>
|
<span className="text-muted-foreground ml-2 text-sm">추가 로딩 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 더 이상 데이터가 없을 때 */}
|
{/* 더 이상 데이터가 없을 때 */}
|
||||||
{!hasNextPage && categories.length > 0 && (
|
{!hasNextPage && categories.length > 0 && (
|
||||||
<div className="py-4 text-center text-sm text-muted-foreground">모든 카테고리를 불러왔습니다.</div>
|
<div className="text-muted-foreground py-4 text-center text-sm">모든 카테고리를 불러왔습니다.</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
|
|
||||||
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
|
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
|
||||||
|
|
@ -31,12 +19,7 @@ interface CollectionConfigModalProps {
|
||||||
config?: DataCollectionConfig | null;
|
config?: DataCollectionConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CollectionConfigModal({
|
export default function CollectionConfigModal({ isOpen, onClose, onSave, config }: CollectionConfigModalProps) {
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
config,
|
|
||||||
}: CollectionConfigModalProps) {
|
|
||||||
const [formData, setFormData] = useState<Partial<DataCollectionConfig>>({
|
const [formData, setFormData] = useState<Partial<DataCollectionConfig>>({
|
||||||
config_name: "",
|
config_name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
@ -107,7 +90,7 @@ export default function CollectionConfigModal({
|
||||||
|
|
||||||
const handleConnectionChange = (connectionId: string) => {
|
const handleConnectionChange = (connectionId: string) => {
|
||||||
const id = parseInt(connectionId);
|
const id = parseInt(connectionId);
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
source_connection_id: id,
|
source_connection_id: id,
|
||||||
source_table: "",
|
source_table: "",
|
||||||
|
|
@ -140,16 +123,14 @@ export default function CollectionConfigModal({
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("수집 설정 저장 오류:", error);
|
console.error("수집 설정 저장 오류:", error);
|
||||||
toast.error(
|
toast.error(error instanceof Error ? error.message : "수집 설정 저장에 실패했습니다.");
|
||||||
error instanceof Error ? error.message : "수집 설정 저장에 실패했습니다."
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSchedulePresetSelect = (preset: string) => {
|
const handleSchedulePresetSelect = (preset: string) => {
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
schedule_cron: preset,
|
schedule_cron: preset,
|
||||||
}));
|
}));
|
||||||
|
|
@ -167,9 +148,7 @@ export default function CollectionConfigModal({
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{config ? "수집 설정 수정" : "새 수집 설정"}</DialogTitle>
|
||||||
{config ? "수집 설정 수정" : "새 수집 설정"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
|
@ -183,9 +162,7 @@ export default function CollectionConfigModal({
|
||||||
<Input
|
<Input
|
||||||
id="config_name"
|
id="config_name"
|
||||||
value={formData.config_name || ""}
|
value={formData.config_name || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData((prev) => ({ ...prev, config_name: e.target.value }))}
|
||||||
setFormData(prev => ({ ...prev, config_name: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="수집 설정명을 입력하세요"
|
placeholder="수집 설정명을 입력하세요"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
@ -195,9 +172,7 @@ export default function CollectionConfigModal({
|
||||||
<Label htmlFor="collection_type">수집 타입 *</Label>
|
<Label htmlFor="collection_type">수집 타입 *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.collection_type || "full"}
|
value={formData.collection_type || "full"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => setFormData((prev) => ({ ...prev, collection_type: value as any }))}
|
||||||
setFormData(prev => ({ ...prev, collection_type: value as any }))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -218,9 +193,7 @@ export default function CollectionConfigModal({
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={formData.description || ""}
|
value={formData.description || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
||||||
setFormData(prev => ({ ...prev, description: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="수집 설정에 대한 설명을 입력하세요"
|
placeholder="수집 설정에 대한 설명을 입력하세요"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
|
@ -234,10 +207,7 @@ export default function CollectionConfigModal({
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="source_connection">소스 연결 *</Label>
|
<Label htmlFor="source_connection">소스 연결 *</Label>
|
||||||
<Select
|
<Select value={formData.source_connection_id?.toString() || ""} onValueChange={handleConnectionChange}>
|
||||||
value={formData.source_connection_id?.toString() || ""}
|
|
||||||
onValueChange={handleConnectionChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="연결을 선택하세요" />
|
<SelectValue placeholder="연결을 선택하세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -255,9 +225,7 @@ export default function CollectionConfigModal({
|
||||||
<Label htmlFor="source_table">소스 테이블 *</Label>
|
<Label htmlFor="source_table">소스 테이블 *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.source_table || ""}
|
value={formData.source_table || ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => setFormData((prev) => ({ ...prev, source_table: value }))}
|
||||||
setFormData(prev => ({ ...prev, source_table: value }))
|
|
||||||
}
|
|
||||||
disabled={!formData.source_connection_id}
|
disabled={!formData.source_connection_id}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -279,9 +247,7 @@ export default function CollectionConfigModal({
|
||||||
<Input
|
<Input
|
||||||
id="target_table"
|
id="target_table"
|
||||||
value={formData.target_table || ""}
|
value={formData.target_table || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData((prev) => ({ ...prev, target_table: e.target.value }))}
|
||||||
setFormData(prev => ({ ...prev, target_table: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="대상 테이블명 (선택사항)"
|
placeholder="대상 테이블명 (선택사항)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -297,9 +263,7 @@ export default function CollectionConfigModal({
|
||||||
<Input
|
<Input
|
||||||
id="schedule_cron"
|
id="schedule_cron"
|
||||||
value={formData.schedule_cron || ""}
|
value={formData.schedule_cron || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData((prev) => ({ ...prev, schedule_cron: e.target.value }))}
|
||||||
setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="예: 0 0 * * * (매일 자정)"
|
placeholder="예: 0 0 * * * (매일 자정)"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
|
|
@ -324,9 +288,7 @@ export default function CollectionConfigModal({
|
||||||
<Switch
|
<Switch
|
||||||
id="is_active"
|
id="is_active"
|
||||||
checked={formData.is_active === "Y"}
|
checked={formData.is_active === "Y"}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, is_active: checked ? "Y" : "N" }))}
|
||||||
setFormData(prev => ({ ...prev, is_active: checked ? "Y" : "N" }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="is_active">활성화</Label>
|
<Label htmlFor="is_active">활성화</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컬럼 정의 테이블 */}
|
{/* 컬럼 정의 테이블 */}
|
||||||
<div className="overflow-hidden bg-card shadow-sm">
|
<div className="bg-card overflow-hidden shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|
@ -188,7 +188,10 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
|
||||||
const hasRowError = rowErrors.length > 0;
|
const hasRowError = rowErrors.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={index} className={`transition-colors hover:bg-muted/50 ${hasRowError ? "bg-destructive/10" : ""}`}>
|
<TableRow
|
||||||
|
key={index}
|
||||||
|
className={`hover:bg-muted/50 transition-colors ${hasRowError ? "bg-destructive/10" : ""}`}
|
||||||
|
>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -199,7 +202,7 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
|
||||||
className={`text-sm ${hasRowError ? "border-destructive" : ""}`}
|
className={`text-sm ${hasRowError ? "border-destructive" : ""}`}
|
||||||
/>
|
/>
|
||||||
{rowErrors.length > 0 && (
|
{rowErrors.length > 0 && (
|
||||||
<div className="space-y-1 text-xs text-destructive">
|
<div className="text-destructive space-y-1 text-xs">
|
||||||
{rowErrors.map((error, i) => (
|
{rowErrors.map((error, i) => (
|
||||||
<div key={i}>{error}</div>
|
<div key={i}>{error}</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -160,9 +160,9 @@ export function CompanyFormModal({
|
||||||
className={businessNumberError ? "border-destructive" : ""}
|
className={businessNumberError ? "border-destructive" : ""}
|
||||||
/>
|
/>
|
||||||
{businessNumberError ? (
|
{businessNumberError ? (
|
||||||
<p className="text-xs text-destructive">{businessNumberError}</p>
|
<p className="text-destructive text-xs">{businessNumberError}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">10자리 숫자 (자동 하이픈 추가)</p>
|
<p className="text-muted-foreground text-xs">10자리 숫자 (자동 하이픈 추가)</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -231,8 +231,8 @@ export function CompanyFormModal({
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md bg-destructive/10 p-3">
|
<div className="bg-destructive/10 rounded-md p-3">
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-destructive text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
|
||||||
}
|
}
|
||||||
|
|
||||||
// companies 배열에서 현재 회사 찾기
|
// companies 배열에서 현재 회사 찾기
|
||||||
const currentCompany = companies.find(c => c.company_code === user.companyCode);
|
const currentCompany = companies.find((c) => c.company_code === user.companyCode);
|
||||||
return currentCompany?.company_name || user.companyCode;
|
return currentCompany?.company_name || user.companyCode;
|
||||||
}, [user?.companyCode, companies]);
|
}, [user?.companyCode, companies]);
|
||||||
|
|
||||||
|
|
@ -61,9 +61,10 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
|
||||||
if (searchText.trim() === "") {
|
if (searchText.trim() === "") {
|
||||||
setFilteredCompanies(companies);
|
setFilteredCompanies(companies);
|
||||||
} else {
|
} else {
|
||||||
const filtered = companies.filter(company =>
|
const filtered = companies.filter(
|
||||||
|
(company) =>
|
||||||
company.company_name.toLowerCase().includes(searchText.toLowerCase()) ||
|
company.company_name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
company.company_code.toLowerCase().includes(searchText.toLowerCase())
|
company.company_code.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
);
|
);
|
||||||
setFilteredCompanies(filtered);
|
setFilteredCompanies(filtered);
|
||||||
}
|
}
|
||||||
|
|
@ -132,13 +133,13 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 현재 회사 정보 */}
|
{/* 현재 회사 정보 */}
|
||||||
<div className="rounded-lg border bg-gradient-to-r from-primary/10 to-primary/5 p-4">
|
<div className="from-primary/10 to-primary/5 rounded-lg border bg-gradient-to-r p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
|
<div className="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
<Building2 className="h-5 w-5 text-primary" />
|
<Building2 className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">현재 관리 회사</p>
|
<p className="text-muted-foreground text-xs">현재 관리 회사</p>
|
||||||
<p className="text-sm font-semibold">{currentCompanyName}</p>
|
<p className="text-sm font-semibold">{currentCompanyName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -146,7 +147,7 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
|
||||||
|
|
||||||
{/* 회사 검색 */}
|
{/* 회사 검색 */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="회사명 또는 코드 검색..."
|
placeholder="회사명 또는 코드 검색..."
|
||||||
value={searchText}
|
value={searchText}
|
||||||
|
|
@ -158,33 +159,23 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
|
||||||
{/* 회사 목록 */}
|
{/* 회사 목록 */}
|
||||||
<div className="max-h-[400px] space-y-2 overflow-y-auto rounded-lg border p-2">
|
<div className="max-h-[400px] space-y-2 overflow-y-auto rounded-lg border p-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground p-4 text-center text-sm">로딩 중...</div>
|
||||||
로딩 중...
|
|
||||||
</div>
|
|
||||||
) : filteredCompanies.length === 0 ? (
|
) : filteredCompanies.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground p-4 text-center text-sm">검색 결과가 없습니다.</div>
|
||||||
검색 결과가 없습니다.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
filteredCompanies.map((company) => (
|
filteredCompanies.map((company) => (
|
||||||
<div
|
<div
|
||||||
key={company.company_code}
|
key={company.company_code}
|
||||||
className={`flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent ${
|
className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm transition-colors ${
|
||||||
company.company_code === user?.companyCode
|
company.company_code === user?.companyCode ? "bg-accent/50 font-semibold" : ""
|
||||||
? "bg-accent/50 font-semibold"
|
|
||||||
: ""
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleCompanySwitch(company.company_code)}
|
onClick={() => handleCompanySwitch(company.company_code)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{company.company_name}</span>
|
<span className="font-medium">{company.company_name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">{company.company_code}</span>
|
||||||
{company.company_code}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{company.company_code === user?.companyCode && (
|
{company.company_code === user?.companyCode && <span className="text-primary text-xs">현재</span>}
|
||||||
<span className="text-xs text-primary">현재</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -192,4 +183,3 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ export function CompanyToolbar({ totalCount, onCreateClick }: CompanyToolbarProp
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
{/* 왼쪽: 카운트 정보 */}
|
{/* 왼쪽: 카운트 정보 */}
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">
|
||||||
총 <span className="font-semibold text-foreground">{totalCount.toLocaleString()}</span> 건
|
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽: 등록 버튼 */}
|
{/* 오른쪽: 등록 버튼 */}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export function CreateTableModal({
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
mode = "create",
|
mode = "create",
|
||||||
sourceTableName
|
sourceTableName,
|
||||||
}: CreateTableModalProps) {
|
}: CreateTableModalProps) {
|
||||||
const isDuplicateMode = mode === "duplicate" && sourceTableName;
|
const isDuplicateMode = mode === "duplicate" && sourceTableName;
|
||||||
|
|
||||||
|
|
@ -331,8 +331,7 @@ export function CreateTableModal({
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isDuplicateMode
|
{isDuplicateMode
|
||||||
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
|
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
|
||||||
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
|
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."}
|
||||||
}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -478,8 +477,10 @@ export function CreateTableModal({
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
생성 중...
|
생성 중...
|
||||||
</>
|
</>
|
||||||
|
) : isDuplicateMode ? (
|
||||||
|
"복제 생성"
|
||||||
) : (
|
) : (
|
||||||
isDuplicateMode ? "복제 생성" : "테이블 생성"
|
"테이블 생성"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -278,14 +272,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
{log.success ? (
|
{log.success ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="h-4 w-4 text-destructive" />
|
<XCircle className="text-destructive h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<span className={log.success ? "text-green-600" : "text-destructive"}>
|
<span className={log.success ? "text-green-600" : "text-destructive"}>
|
||||||
{log.success ? "성공" : "실패"}
|
{log.success ? "성공" : "실패"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{log.error_message && (
|
{log.error_message && (
|
||||||
<div className="mt-1 max-w-xs truncate text-xs text-destructive">{log.error_message}</div>
|
<div className="text-destructive mt-1 max-w-xs truncate text-xs">{log.error_message}</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
@ -332,7 +326,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-destructive">{statistics.failedExecutions}</div>
|
<div className="text-destructive text-2xl font-bold">{statistics.failedExecutions}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -381,13 +375,13 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
{statistics.recentFailures.length > 0 && (
|
{statistics.recentFailures.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base text-destructive">최근 실패 로그</CardTitle>
|
<CardTitle className="text-destructive text-base">최근 실패 로그</CardTitle>
|
||||||
<CardDescription>최근 발생한 DDL 실행 실패 내역입니다.</CardDescription>
|
<CardDescription>최근 발생한 DDL 실행 실패 내역입니다.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{statistics.recentFailures.map((failure, index) => (
|
{statistics.recentFailures.map((failure, index) => (
|
||||||
<div key={index} className="rounded-lg border border-destructive/20 bg-destructive/10 p-3">
|
<div key={index} className="border-destructive/20 bg-destructive/10 rounded-lg border p-3">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={getDDLTypeBadgeVariant(failure.ddl_type)}>{failure.ddl_type}</Badge>
|
<Badge variant={getDDLTypeBadgeVariant(failure.ddl_type)}>{failure.ddl_type}</Badge>
|
||||||
|
|
@ -397,7 +391,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
{format(new Date(failure.executed_at), "MM-dd HH:mm", { locale: ko })}
|
{format(new Date(failure.executed_at), "MM-dd HH:mm", { locale: ko })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-destructive">{failure.error_message}</div>
|
<div className="text-destructive text-sm">{failure.error_message}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ interface DiskUsageSummaryProps {
|
||||||
export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) {
|
export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) {
|
||||||
if (!diskUsageInfo) {
|
if (!diskUsageInfo) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
<div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold">디스크 사용량</h3>
|
<h3 className="text-sm font-semibold">디스크 사용량</h3>
|
||||||
<p className="text-xs text-muted-foreground">전체 회사 파일 저장 현황</p>
|
<p className="text-muted-foreground text-xs">전체 회사 파일 저장 현황</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -32,7 +32,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
|
||||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
<div className="text-muted-foreground flex items-center justify-center py-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<HardDrive className="mx-auto mb-2 h-8 w-8" />
|
<HardDrive className="mx-auto mb-2 h-8 w-8" />
|
||||||
<p className="text-sm">디스크 사용량 정보를 불러오는 중...</p>
|
<p className="text-sm">디스크 사용량 정보를 불러오는 중...</p>
|
||||||
|
|
@ -46,11 +46,11 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
|
||||||
const lastCheckedDate = new Date(lastChecked);
|
const lastCheckedDate = new Date(lastChecked);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
<div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold">디스크 사용량 현황</h3>
|
<h3 className="text-sm font-semibold">디스크 사용량 현황</h3>
|
||||||
<p className="text-xs text-muted-foreground">전체 회사 파일 저장 통계</p>
|
<p className="text-muted-foreground text-xs">전체 회사 파일 저장 통계</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -67,36 +67,36 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
{/* 총 회사 수 */}
|
{/* 총 회사 수 */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Building2 className="h-4 w-4 text-primary" />
|
<Building2 className="text-primary h-4 w-4" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">총 회사</p>
|
<p className="text-muted-foreground text-xs">총 회사</p>
|
||||||
<p className="text-lg font-semibold">{summary.totalCompanies}개</p>
|
<p className="text-lg font-semibold">{summary.totalCompanies}개</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 총 파일 수 */}
|
{/* 총 파일 수 */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<FileText className="h-4 w-4 text-primary" />
|
<FileText className="text-primary h-4 w-4" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">총 파일</p>
|
<p className="text-muted-foreground text-xs">총 파일</p>
|
||||||
<p className="text-lg font-semibold">{summary.totalFiles.toLocaleString()}개</p>
|
<p className="text-lg font-semibold">{summary.totalFiles.toLocaleString()}개</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 총 용량 */}
|
{/* 총 용량 */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<HardDrive className="h-4 w-4 text-primary" />
|
<HardDrive className="text-primary h-4 w-4" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">총 용량</p>
|
<p className="text-muted-foreground text-xs">총 용량</p>
|
||||||
<p className="text-lg font-semibold">{summary.totalSizeMB.toFixed(1)} MB</p>
|
<p className="text-lg font-semibold">{summary.totalSizeMB.toFixed(1)} MB</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 마지막 업데이트 */}
|
{/* 마지막 업데이트 */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
<Clock className="text-muted-foreground h-4 w-4" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">마지막 확인</p>
|
<p className="text-muted-foreground text-xs">마지막 확인</p>
|
||||||
<p className="text-xs font-medium">
|
<p className="text-xs font-medium">
|
||||||
{lastCheckedDate.toLocaleString("ko-KR", {
|
{lastCheckedDate.toLocaleString("ko-KR", {
|
||||||
month: "short",
|
month: "short",
|
||||||
|
|
@ -112,7 +112,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
|
||||||
{/* 용량 기준 상태 표시 */}
|
{/* 용량 기준 상태 표시 */}
|
||||||
<div className="mt-4 border-t pt-4">
|
<div className="mt-4 border-t pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-muted-foreground">저장소 상태</span>
|
<span className="text-muted-foreground text-xs">저장소 상태</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={summary.totalSizeMB > 1000 ? "destructive" : summary.totalSizeMB > 500 ? "secondary" : "default"}
|
variant={summary.totalSizeMB > 1000 ? "destructive" : summary.totalSizeMB > 500 ? "secondary" : "default"}
|
||||||
>
|
>
|
||||||
|
|
@ -121,7 +121,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 간단한 진행 바 */}
|
{/* 간단한 진행 바 */}
|
||||||
<div className="mt-2 h-2 w-full rounded-full bg-muted">
|
<div className="bg-muted mt-2 h-2 w-full rounded-full">
|
||||||
<div
|
<div
|
||||||
className={`h-2 rounded-full transition-all duration-300 ${
|
className={`h-2 rounded-full transition-all duration-300 ${
|
||||||
summary.totalSizeMB > 1000 ? "bg-destructive" : summary.totalSizeMB > 500 ? "bg-primary/60" : "bg-primary"
|
summary.totalSizeMB > 1000 ? "bg-destructive" : summary.totalSizeMB > 500 ? "bg-primary/60" : "bg-primary"
|
||||||
|
|
@ -131,7 +131,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-1 flex justify-between text-xs">
|
||||||
<span>0 MB</span>
|
<span>0 MB</span>
|
||||||
<span>2,000 MB (권장 최대)</span>
|
<span>2,000 MB (권장 최대)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -373,7 +373,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
||||||
|
|
||||||
{/* Discord 설정 */}
|
{/* Discord 설정 */}
|
||||||
{formData.api_type === "discord" && (
|
{formData.api_type === "discord" && (
|
||||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
|
<div className="bg-muted/20 space-y-3 rounded-lg border p-3 sm:p-4">
|
||||||
<h4 className="text-xs font-semibold sm:text-sm">Discord 설정</h4>
|
<h4 className="text-xs font-semibold sm:text-sm">Discord 설정</h4>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="discord_webhook" className="text-xs sm:text-sm">
|
<Label htmlFor="discord_webhook" className="text-xs sm:text-sm">
|
||||||
|
|
@ -416,7 +416,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
||||||
|
|
||||||
{/* Slack 설정 */}
|
{/* Slack 설정 */}
|
||||||
{formData.api_type === "slack" && (
|
{formData.api_type === "slack" && (
|
||||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
|
<div className="bg-muted/20 space-y-3 rounded-lg border p-3 sm:p-4">
|
||||||
<h4 className="text-xs font-semibold sm:text-sm">Slack 설정</h4>
|
<h4 className="text-xs font-semibold sm:text-sm">Slack 설정</h4>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="slack_webhook" className="text-xs sm:text-sm">
|
<Label htmlFor="slack_webhook" className="text-xs sm:text-sm">
|
||||||
|
|
@ -459,7 +459,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
||||||
|
|
||||||
{/* 카카오톡 설정 */}
|
{/* 카카오톡 설정 */}
|
||||||
{formData.api_type === "kakao-talk" && (
|
{formData.api_type === "kakao-talk" && (
|
||||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
|
<div className="bg-muted/20 space-y-3 rounded-lg border p-3 sm:p-4">
|
||||||
<h4 className="text-xs font-semibold sm:text-sm">카카오톡 설정</h4>
|
<h4 className="text-xs font-semibold sm:text-sm">카카오톡 설정</h4>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="kakao_token" className="text-xs sm:text-sm">
|
<Label htmlFor="kakao_token" className="text-xs sm:text-sm">
|
||||||
|
|
@ -491,7 +491,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
||||||
|
|
||||||
{/* 일반 API 설정 */}
|
{/* 일반 API 설정 */}
|
||||||
{formData.api_type === "generic" && (
|
{formData.api_type === "generic" && (
|
||||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
|
<div className="bg-muted/20 space-y-3 rounded-lg border p-3 sm:p-4">
|
||||||
<h4 className="text-xs font-semibold sm:text-sm">일반 API 설정</h4>
|
<h4 className="text-xs font-semibold sm:text-sm">일반 API 설정</h4>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="generic_url" className="text-xs sm:text-sm">
|
<Label htmlFor="generic_url" className="text-xs sm:text-sm">
|
||||||
|
|
@ -518,10 +518,18 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="GET" className="text-xs sm:text-sm">GET</SelectItem>
|
<SelectItem value="GET" className="text-xs sm:text-sm">
|
||||||
<SelectItem value="POST" className="text-xs sm:text-sm">POST</SelectItem>
|
GET
|
||||||
<SelectItem value="PUT" className="text-xs sm:text-sm">PUT</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="DELETE" className="text-xs sm:text-sm">DELETE</SelectItem>
|
<SelectItem value="POST" className="text-xs sm:text-sm">
|
||||||
|
POST
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="PUT" className="text-xs sm:text-sm">
|
||||||
|
PUT
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="DELETE" className="text-xs sm:text-sm">
|
||||||
|
DELETE
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -559,7 +567,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
||||||
|
|
||||||
{/* 다른 호출 타입들 (이메일, FTP, 큐) */}
|
{/* 다른 호출 타입들 (이메일, FTP, 큐) */}
|
||||||
{formData.call_type !== "rest-api" && (
|
{formData.call_type !== "rest-api" && (
|
||||||
<div className="rounded-lg border bg-muted/20 p-3 text-center text-xs text-muted-foreground sm:p-4 sm:text-sm">
|
<div className="bg-muted/20 text-muted-foreground rounded-lg border p-3 text-center text-xs sm:p-4 sm:text-sm">
|
||||||
{formData.call_type} 타입의 설정은 아직 구현되지 않았습니다.
|
{formData.call_type} 타입의 설정은 아직 구현되지 않았습니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -145,4 +139,3 @@ export default function LanguageModal({ isOpen, onClose, onSave, languageData }:
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
|
|
||||||
|
|
||||||
DialogHeader,
|
|
||||||
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
|
@ -311,7 +304,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
>
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<IconComponent className="h-5 w-5 text-muted-foreground" />
|
<IconComponent className="text-muted-foreground h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{category.name}</div>
|
<div className="font-medium">{category.name}</div>
|
||||||
<div className="text-xs text-gray-500">{category.description}</div>
|
<div className="text-xs text-gray-500">{category.description}</div>
|
||||||
|
|
@ -363,7 +356,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
<div className="font-medium">{template.name}</div>
|
<div className="font-medium">{template.name}</div>
|
||||||
<Badge variant="secondary">{template.zones}개 영역</Badge>
|
<Badge variant="secondary">{template.zones}개 영역</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">{template.description}</div>
|
<div className="text-muted-foreground text-sm">{template.description}</div>
|
||||||
<div className="text-xs text-gray-500">예: {template.example}</div>
|
<div className="text-xs text-gray-500">예: {template.example}</div>
|
||||||
<div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div>
|
<div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -428,7 +421,11 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{generationResult ? (
|
{generationResult ? (
|
||||||
<Alert
|
<Alert
|
||||||
className={generationResult.success ? "border-green-200 bg-green-50" : "border-destructive/20 bg-destructive/10"}
|
className={
|
||||||
|
generationResult.success
|
||||||
|
? "border-green-200 bg-green-50"
|
||||||
|
: "border-destructive/20 bg-destructive/10"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}>
|
<AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}>
|
||||||
|
|
@ -480,7 +477,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
<div>
|
<div>
|
||||||
<strong>생성될 파일:</strong>
|
<strong>생성될 파일:</strong>
|
||||||
</div>
|
</div>
|
||||||
<ul className="ml-4 space-y-1 text-xs text-muted-foreground">
|
<ul className="text-muted-foreground ml-4 space-y-1 text-xs">
|
||||||
<li>• {formData.name.toLowerCase()}/index.ts</li>
|
<li>• {formData.name.toLowerCase()}/index.ts</li>
|
||||||
<li>
|
<li>
|
||||||
• {formData.name.toLowerCase()}/{formData.name}Layout.tsx
|
• {formData.name.toLowerCase()}/{formData.name}Layout.tsx
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue