refactor: Pipeline 네이밍 통일 및 AI 에이전트/장비 연결 기능 추가

- Docker/K8s 배포 설정을 pipeline-backend/pipeline-front로 통일
- 네임스페이스, 서비스, PVC 등 k8s 리소스명 pipeline-* 로 변경
- AI 에이전트 관리 기능 추가 (에이전트, 그룹, 프로바이더, 대화, API 키, 지식베이스)
- 장비 연결 관리 기능 추가 (PLC/Modbus/OPC-UA/MQTT)
- 배치 스케줄러에 AI agent/device collection/crawling 타입 추가
- 배치 편집 UI 개선 (6가지 실행 방식 지원)
- 회사별 페이지(COMPANY_*) 제거 및 AdminPageRenderer 최적화
- 메뉴 재구성: 장비 연결 관리 시스템관리로 이동, 에이전트 오케스트레이션으로 개명
- ai-assistant 디렉토리 제거 (backend-node로 통합)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-20 12:14:50 +09:00
parent fdaf07896a
commit 37cac72085
479 changed files with 11173 additions and 385275 deletions
+373
View File
@@ -0,0 +1,373 @@
# PIPELINE 멀티 에이전트 아키텍처 설계서
## 1. 개요
PIPELINE은 OpenClaw 기반 멀티 에이전트 AI 플랫폼을 빌트인으로 탑재하여,
여러 AI 에이전트가 기존 시스템의 데이터를 활용하여 협업하고 행동하는 구조를 제공합니다.
### 설계 원칙
```
에이전트 = 템플릿 (재사용 가능)
커넥터 = 데이터 소스 (동적 연결)
그룹 = 에이전트 + 커넥터 조립 (멀티 에이전트 단위)
```
- 에이전트는 **역할(시스템 프롬프트)**만 정의하고, 데이터 접근은 커넥터가 담당
- 같은 에이전트가 **다른 커넥터를 물고 다른 회사/시스템에서 동작** 가능
- 외부 시스템은 **멀티 에이전트 그룹 1개만 호출**하면 내부에서 자동 분업
---
## 2. 전체 아키텍처
```
┌─────────────────────────────────────────────────────────────┐
│ PIPELINE 플랫폼 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 멀티 에이전트 워크스페이스 │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ [MES 멀티 에이전트] │ │ │
│ │ │ │ │ │
│ │ │ ① 영업담당 에이전트 │ │ │
│ │ │ └─ 커넥터: A회사 수주DB │ │ │
│ │ │ ↓ │ │ │
│ │ │ ② 설계담당 에이전트 │ │ │
│ │ │ └─ 커넥터: CAD REST API + BOM 파일 │ │ │
│ │ │ ↓ │ │ │
│ │ │ ③ 구매담당 에이전트 │ │ │
│ │ │ └─ 커넥터: 자재DB + 원자재 시세 크롤러 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ [SCM 멀티 에이전트] │ │ │
│ │ │ │ │ │
│ │ │ ① 물류담당 에이전트 │ │ │
│ │ │ └─ 커넥터: B회사 재고DB + PLC 설비데이터 │ │ │
│ │ │ ↓ │ │ │
│ │ │ ② 품질담당 에이전트 │ │ │
│ │ │ └─ 커넥터: 검사성적서 파일 + 불량DB │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ 에이전트 관리 │ │ 데이터 커넥터 허브 │ │
│ │ (템플릿) │ │ ┌──────┐ ┌────────┐ │ │
│ │ - 영업담당 │ │ │ DB │ │REST API│ │ │
│ │ - 설계담당 │ │ └──────┘ └────────┘ │ │
│ │ - 구매담당 │ │ ┌──────┐ ┌────────┐ ┌────┐│ │
│ │ - 물류담당 │ │ │ PLC │ │ File │ │크롤││ │
│ │ - 품질담당 │ │ └──────┘ └────────┘ └────┘│ │
│ └─────────────────┘ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ OpenClaw Gateway (내장) │ │
│ │ - MCP 도구 동적 주입 │ │
│ │ - LLM 프로바이더 (Claude / GPT / Gemini / Ollama) │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────┬────────────────────────────────────────┘
│ API 키 (sk-pipe-xxxxx)
┌────────────────▼────────────────────┐
│ 외부 시스템 │
│ MES / ERP / WMS / SCM / 모바일 앱 │
└─────────────────────────────────────┘
```
---
## 3. 핵심 개념
### 3-1. 에이전트 (Agent) = 재사용 템플릿
```
에이전트: "영업담당"
├── 모델: Claude Sonnet 4
├── 시스템 프롬프트: "당신은 영업 데이터 분석 전문가입니다..."
└── 커넥터: 없음 (그룹에서 동적 할당)
이 에이전트는 여러 그룹에서 재사용:
- A회사 MES 그룹 → 커넥터: A회사 수주DB
- B회사 MES 그룹 → 커넥터: B회사 수주DB
- C회사 SCM 그룹 → 커넥터: C회사 거래처DB
```
### 3-2. 데이터 커넥터 허브 (Data Connector Hub)
| 커넥터 유형 | 데이터 소스 | 에이전트 활용 |
|------------|-----------|-------------|
| **Database** | PostgreSQL, MSSQL, MariaDB, Oracle | SQL 쿼리로 데이터 조회/분석 |
| **REST API** | 외부 시스템 API 호출 | JSON 데이터 수집/명령 전달 |
| **PLC** | 장비 데이터 (Modbus, OPC-UA) | 실시간 설비 상태 모니터링 |
| **File** | CSV, Excel, JSON, PDF | 문서 분석, 데이터 추출 |
| **Crawler** | 웹 크롤링 (시세, 뉴스, 규격) | 외부 정보 수집 |
### 3-3. 멀티 에이전트 그룹 = 에이전트 + 커넥터 조립
```
[A회사 MES 멀티 에이전트]
├── ① 영업담당 + A회사 수주DB ← 같은 에이전트
├── ② 구매담당 + A회사 자재DB + 시세 크롤러
└── ③ 품질담당 + A회사 검사DB
[B회사 MES 멀티 에이전트]
├── ① 영업담당 + B회사 수주DB ← 같은 에이전트, 다른 커넥터
├── ② 구매담당 + B회사 자재DB
└── ③ 설계담당 + B회사 CAD API + BOM 파일
```
**에이전트 5개 + 그룹 2개 = 6개 조합** (에이전트 따로 만들면 6개 필요)
---
## 4. 실행 흐름
### 4-1. 외부 시스템 호출 시
```
POST /api/ai/v1/groups/a-company-mes
{
"message": "이번 달 납기 지연 가능한 건 알려줘"
}
┌─ 실행 파이프라인 ──────────────────────────────────────┐
│ │
│ ① 영업담당 에이전트 실행 │
│ MCP 도구: A회사 수주DB 쿼리 실행 │
│ → "수주 10건 중 3건 납기 위험 (A-2026-001, ...)" │
│ │ │
│ ▼ │
│ ② 설계담당 에이전트 실행 │
│ MCP 도구: CAD API 호출 + BOM 파일 분석 │
│ → "A-2026-001: BOM 분석 결과 부품 2개 미확보" │
│ │ │
│ ▼ │
│ ③ 구매담당 에이전트 실행 │
│ MCP 도구: 자재DB 조회 + 원자재 시세 크롤링 │
│ → "부품A: 납품 5일 소요, 긴급 발주 추천" │
│ → "부품B: 재고 있음 (창고 3번)" │
│ │ │
│ ▼ │
│ 최종 응답 취합 │
│ → 납기 위험 3건, 부품 미확보 2건, 긴급 발주 1건 추천 │
│ │
└────────────────────────────────────────────────────────┘
```
### 4-2. 스케줄 실행 (예정)
```
[매일 오전 9시 자동 실행]
→ SCM 멀티 에이전트 실행
→ 결과를 이메일/슬랙/대시보드에 전송
```
---
## 5. DB 스키마
### 기존 테이블 (활용)
| 테이블 | 용도 |
|--------|------|
| `external_db_connections` | DB 커넥션 정보 (호스트, 포트, 계정) |
| `external_rest_api_connections` | REST API 커넥션 정보 (URL, 인증) |
### 새로 추가한 테이블
```sql
-- AI 에이전트 (템플릿)
ai_agents
id, agent_id, name, model, system_prompt, status
-- LLM 프로바이더 (API 키)
ai_llm_providers
id, name(anthropic/openai/google), api_key_encrypted, model_name
-- 멀티 에이전트 그룹
ai_agent_groups
id, group_id, name, description, status
-- 그룹 멤버 (에이전트 + 커넥터 매핑)
ai_agent_group_members
group_id ai_agent_groups
agent_id ai_agents
role_name ( )
connectors (JSONB: )
[
{"type":"database", "connection_id":3, "name":"A회사 수주DB"},
{"type":"rest_api", "connection_id":5, "name":"CAD API"},
{"type":"crawler", "config_id":2, "name":"원자재 시세"},
{"type":"file", "path":"/uploads/bom.csv", "name":"BOM 파일"},
{"type":"plc", "connection_id":1, "name":"1호기 PLC"}
]
execution_order ( )
-- API 키 (외부 서비스용)
ai_agent_api_keys
key_hash, user_id, rate_limit, monthly_token_limit
-- 사용량 로그
ai_agent_usage_logs
tokens, cost, response_time
```
---
## 6. API 엔드포인트
### 관리 API (Pipeline JWT 인증)
| Method | Path | 설명 |
|--------|------|------|
| GET/POST | `/api/ai-agents` | 에이전트 CRUD |
| GET/POST | `/api/ai-agents/providers/*` | LLM 프로바이더 관리 |
| GET/POST | `/api/ai-agents/keys/*` | API 키 발급/관리 |
| GET/POST | `/api/ai-agent-groups` | 멀티 에이전트 그룹 CRUD |
| POST | `/api/ai-agent-groups/:id/members` | 그룹에 에이전트+커넥터 추가 |
| GET | `/api/ai-agent-groups/connectors` | 사용 가능한 커넥터 목록 |
### 외부 서비스 API (API Key 인증)
| Method | Path | 설명 |
|--------|------|------|
| POST | `/api/ai/v1/chat/completions` | 단일 에이전트 채팅 |
| POST | `/api/ai/v1/groups/:groupId` | 멀티 에이전트 실행 (예정) |
| GET | `/api/ai/v1/models` | 사용 가능 모델 목록 |
---
## 7. 관리 화면 (프론트엔드)
| 메뉴 | 경로 | 기능 |
|------|------|------|
| 멀티 에이전트 워크스페이스 | `/admin/aiAssistant/workspace` | 그룹 생성, 에이전트+커넥터 조립 |
| 에이전트 관리 | `/admin/aiAssistant/agents` | 에이전트 템플릿 CRUD |
| LLM 프로바이더 | `/admin/aiAssistant/providers` | Claude/GPT/Gemini API 키 등록 |
| API 키 발급 | `/admin/aiAssistant/api-keys-manage` | 외부 서비스용 키 생성 |
| 대화 모니터링 | `/admin/aiAssistant/conversations` | 에이전트 대화 내역 열람 |
---
## 8. 예측 진화 시스템
### 이력 누적 → 자동 학습 구조
```
에이전트는 매번 실행할 때마다:
1. 현재 데이터 조회 (MCP 커넥터)
2. 과거 분석 이력 조회 (ai_analysis_logs)
3. 과거 예측 vs 실제 비교 (accuracy_score)
4. 이 모든 컨텍스트로 분석/예측 수행
5. 결과를 다시 이력에 저장
→ 실행할수록 데이터가 쌓이고, 에이전트가 참고할 이력이 늘어남
→ 별도 학습 없이 LLM의 추론 능력으로 패턴 인식/예측 가능
```
### ai_analysis_logs 테이블
```sql
ai_analysis_logs
group_id
input_message
analysis_result ( )
prediction (JSONB)
actual_result ( )
accuracy_score (0~100)
tokens_used, duration_ms
created_at
```
### 예시: 30일 후 예측 정확도 변화
```
1일차: 이력 없음 → 예측 정확도: N/A
7일차: 이력 6건 참고 → 예측 정확도: 60%
14일차: 이력 13건 참고 → 예측 정확도: 72%
30일차: 이력 29건 참고 → 패턴 인식 → 예측 정확도: 85%
```
---
## 9. 스케줄러 & 알림
### 정기 실행 구조
```
[ai_agent_schedules]
├── cron_expression: "0 9 * * *" (매일 오전 9시)
├── group_id → MES 멀티 에이전트
├── input_message: "오늘 납기 지연 위험이 있는 건 알려줘"
├── notification:
│ ├── system_notice: true (시스템 공지)
│ ├── webhook: "https://hooks.slack.com/..." (슬랙)
│ └── email: ["buyer@company.com"] (이메일)
└── is_active: true
```
### 알림 흐름
```
[cron 실행] → [멀티 에이전트 실행] → [결과 분석]
┌─────────────────────────┤
▼ ▼ ▼
[시스템 공지 저장] [슬랙 웹훅 발송] [이메일 발송]
system_notice 테이블 POST webhook URL SMTP 발송
```
---
## 10. 커넥터 유형 (구현 완료)
| 커넥터 | 구현 방식 | 에이전트 활용 |
|--------|----------|-------------|
| **Database** | 기존 external_db_connections 활용 | SQL 쿼리로 데이터 조회 |
| **REST API** | 기존 external_rest_api_connections 활용 | JSON 데이터 수집 |
| **File** | 파일 시스템 직접 읽기 (CSV/JSON) | 문서 분석, 데이터 추출 |
| **Crawler** | 기존 crawl_configs 활용 | 웹 크롤링 데이터 수집 |
| **PLC** | AAS 디지털 트윈 REST API → DB 저장 | 설비 상태 조회/분석 |
### PLC 데이터 흐름
```
[PLC 장비] → [AAS 디지털 트윈] → REST API
[Pipeline DB 저장]
(plc_device_data 등)
[에이전트가 DB 조회]
"1호기 온도 추세 분석"
```
- AAS에서 장비 종류, 벤더 등 **마스터 데이터** 관리
- Pipeline에서 실제 **PLC 통신 스크립트** 구현 (Modbus TCP/OPC-UA)
- 수집 데이터를 DB에 저장 → 에이전트가 이력 분석/예측
---
## 11. 확장 계획
| 기능 | 설명 | 우선순위 |
|------|------|---------|
| 워크플로우 편집기 | 드래그앤드롭으로 파이프라인 구성 | 중 |
| PLC 통신 모듈 | Modbus TCP/OPC-UA 직접 통신 | 중 |
| 실시간 모니터링 | WebSocket으로 에이전트 실행 실시간 스트리밍 | 중 |
| A/B 테스트 | 다른 모델/프롬프트로 비교 실험 | 하 |
| 에이전트 마켓플레이스 | 템플릿 공유/배포 | 하 |
---
## 9. 기술 스택
| 구성요소 | 기술 |
|---------|------|
| 멀티 에이전트 엔진 | OpenClaw (Node.js, MCP 지원) |
| 백엔드 | Express.js + TypeScript |
| 프론트엔드 | Next.js 15 + shadcn/ui |
| 데이터베이스 | PostgreSQL |
| LLM | Claude, GPT, Gemini, DeepSeek, Ollama |
| 커넥터 | 기존 external_db/rest_api + PLC/File/Crawler 확장 |
+181
View File
@@ -0,0 +1,181 @@
# OpenClaw 멀티 에이전트 AI 통합
## 개요
Pipeline 프로젝트에 OpenClaw 멀티 에이전트 AI 플랫폼을 빌트인으로 통합했습니다.
Pipeline UI에서 에이전트 생성/관리, API 키 발급, 대화 모니터링, 사용량 추적이 가능하며,
외부 서비스에서 Pipeline이 발급한 API 키로 에이전트를 사용할 수 있습니다.
## 아키텍처
```
외부 서비스 (ERP, MES, 물류 등)
│ POST /api/ai/v1/chat/completions
│ Authorization: Bearer sk-pipe-xxxxx
Pipeline Backend (:8080)
├── 인증 (JWT 또는 API Key)
├── Rate Limit 체크
├── 프록시 → OpenClaw Gateway (:18789)
├── 응답 로깅 (토큰, 비용)
└── DB 저장 (PostgreSQL)
OpenClaw Gateway (내장 프로세스)
├── Agent A (데이터 분석)
├── Agent B (보고서 작성)
├── Agent C (모니터링)
└── LLM: Claude / GPT / Ollama
```
## DB 테이블
| 테이블 | 용도 |
|--------|------|
| `ai_agents` | 에이전트 정의 (이름, 모델, 시스템프롬프트, 도구, 상태) |
| `ai_agent_api_keys` | 외부 서비스용 API 키 (해시 저장, 사용량 제한) |
| `ai_agent_conversations` | 대화 세션 |
| `ai_agent_messages` | 대화 메시지 |
| `ai_agent_usage_logs` | 토큰 사용량, 비용, 응답시간 로그 |
| `ai_llm_providers` | LLM API 키 관리 (암호화 저장) |
## API 엔드포인트
### 에이전트 관리 (Pipeline JWT 인증)
```
GET /api/ai-agents 에이전트 목록
GET /api/ai-agents/:id 에이전트 상세
POST /api/ai-agents 에이전트 생성
PUT /api/ai-agents/:id 에이전트 수정
DELETE /api/ai-agents/:id 에이전트 삭제
```
### API 키 관리 (Pipeline JWT 인증)
```
GET /api/ai-agents/keys/list 키 목록
POST /api/ai-agents/keys 키 발급 (sk-pipe-xxx 형태, 한 번만 노출)
DELETE /api/ai-agents/keys/:id 키 폐기
```
### LLM 프로바이더 관리 (Pipeline JWT 인증)
```
GET /api/ai-agents/providers/list 프로바이더 목록
POST /api/ai-agents/providers 프로바이더 추가 (API 키 암호화 저장)
PUT /api/ai-agents/providers/:id 프로바이더 수정
DELETE /api/ai-agents/providers/:id 프로바이더 삭제
```
### 대화 모니터링 (Pipeline JWT 인증)
```
GET /api/ai-agents/conversations/list 대화 목록 (페이징)
GET /api/ai-agents/conversations/:id 대화 + 메시지 상세
```
### 사용량 (Pipeline JWT 인증)
```
GET /api/ai-agents/usage/summary 요약 (오늘/이번달 토큰, 비용)
GET /api/ai-agents/usage/logs 상세 로그 (페이징)
GET /api/ai-agents/usage/daily 일별 차트 데이터
```
### 외부 서비스용 프록시 (API Key 인증)
```
POST /api/ai/v1/chat/completions OpenAI 호환 채팅 (스트리밍 지원)
GET /api/ai/v1/models 사용 가능 모델 목록
GET /api/ai/v1/health Gateway 상태
```
## 외부 서비스에서 사용하기
### 1. API 키 발급
Pipeline 관리자 화면 > AI 에이전트 > API 키 관리에서 키를 발급합니다.
`sk-pipe-xxxxxxxx` 형태의 키가 생성되며, 최초 1회만 노출됩니다.
### 2. 채팅 API 호출
```bash
curl -X POST https://pipeline.example.com/api/ai/v1/chat/completions \
-H "Authorization: Bearer sk-pipe-xxxxx" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4-20250514",
"messages": [
{"role": "user", "content": "매출 데이터를 분석해줘"}
]
}'
```
### 3. 응답 형태 (OpenAI 호환)
```json
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"model": "claude-sonnet-4-20250514",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "매출 데이터 분석 결과..." },
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 15,
"completion_tokens": 200,
"total_tokens": 215
}
}
```
## 프론트엔드 관리 화면
| 경로 | 기능 |
|------|------|
| `/admin/aiAssistant/agents` | 에이전트 CRUD (카드형 목록) |
| `/admin/aiAssistant/providers` | LLM API 키 등록/관리 |
| `/admin/aiAssistant/conversations` | 대화 모니터링 (메시지 열람) |
| `/admin/aiAssistant/api-keys` | 외부 서비스용 API 키 발급 |
| `/admin/aiAssistant/usage` | 사용량 대시보드 |
## 환경 변수
```env
# OpenClaw Gateway 포트 (기본: 18789)
OPENCLAW_GATEWAY_PORT=18789
# OpenClaw 활성화 여부 (기본: true)
OPENCLAW_ENABLED=true
```
## 파일 구조
```
backend-node/src/
├── controllers/aiAgentController.ts # 에이전트/키/프로바이더/대화/사용량 컨트롤러
├── services/
│ ├── aiAgentService.ts # 에이전트 CRUD
│ ├── aiAgentApiKeyService.ts # API 키 발급/검증
│ ├── aiAgentProviderService.ts # LLM 프로바이더 관리
│ ├── aiAgentConversationService.ts # 대화 로깅
│ └── aiAgentUsageService.ts # 사용량 추적
├── routes/
│ ├── aiAgentRoutes.ts # 관리 API 라우트
│ └── openClawProxyRoutes.ts # 외부 서비스 프록시
├── middleware/
│ └── aiApiKeyAuthMiddleware.ts # API 키 인증
├── utils/startOpenClaw.ts # Gateway 프로세스 관리
├── types/aiAgent.ts # TypeScript 타입
└── db/migrations/
└── 300_create_openclaw_tables.sql # 마이그레이션
frontend/
├── lib/api/aiAgent.ts # API 클라이언트
└── app/(main)/admin/aiAssistant/
├── agents/page.tsx # 에이전트 관리
├── providers/page.tsx # 프로바이더 관리
└── conversations/page.tsx # 대화 모니터링
```
-25
View File
@@ -1,25 +0,0 @@
# AI Assistant API (VEXPLOR 내장) - 환경 변수
# 이 파일을 .env 로 복사한 뒤 값 설정
NODE_ENV=development
PORT=3100
# PostgreSQL (AI 어시스턴트 전용 DB)
DB_HOST=localhost
DB_PORT=5432
DB_USER=ai_assistant
DB_PASSWORD=ai_assistant_password
DB_NAME=ai_assistant_db
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
JWT_REFRESH_EXPIRES_IN=30d
# LLM (구글 키 등)
GEMINI_API_KEY=your-gemini-api-key
GEMINI_MODEL=gemini-2.0-flash
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
-17
View File
@@ -1,17 +0,0 @@
# AI 어시스턴트 API - Docker (Windows 개발용)
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=development
EXPOSE 3100
CMD ["node", "src/app.js"]
-43
View File
@@ -1,43 +0,0 @@
# AI 어시스턴트 API (VEXPLOR 내장)
VEXPLOR와 **같은 서비스**로 동작하도록 이 API는 포트 3100에서 구동되고, backend-node가 `/api/ai/v1` 요청을 여기로 프록시합니다.
## 동작 방식
- **프론트(9771)** → `/api/ai/v1/*` 호출
- **Next.js** → `8080/api/ai/v1/*` 로 rewrite
- **backend-node(8080)** → `3100/api/v1/*` 로 프록시 → **이 서비스**
따라서 사용자는 **다른 포트를 쓰지 않고** VEXPLOR만 켜도 AI 기능을 사용할 수 있습니다.
## 서비스 올리는 순서 (한 번에 동작하게)
1. **AI 어시스턴트 API (이 폴더, 포트 3100)**
```bash
cd ai-assistant
npm install
cp .env.example .env # 필요 시 DB, JWT, GEMINI_API_KEY 등 수정
npm start
```
2. **backend-node (포트 8080)**
```bash
cd backend-node
npm run dev
```
3. **프론트 (포트 9771)**
```bash
cd frontend
npm run dev
```
브라우저에서는 `http://localhost:9771` 만 사용하면 되고, AI API는 같은 오리진의 `/api/ai/v1` 로 호출됩니다.
## 환경 변수
- `.env.example` 을 `.env` 로 복사 후 수정
- `PORT=3100` (기본값)
- PostgreSQL: `DB_*`
- JWT: `JWT_SECRET`, `JWT_REFRESH_SECRET`
- LLM: `GEMINI_API_KEY` 등
-3455
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
{
"name": "ai-assistant-api",
"version": "1.0.0",
"description": "AI Assistant API (VEXPLOR 내장) - 포트 3100에서 구동, backend-node가 /api/ai/v1 로 프록시",
"private": true,
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js"
},
"dependencies": {
"@google/genai": "^1.0.0",
"axios": "^1.6.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"pg-hstore": "^2.3.4",
"sequelize": "^6.35.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"zod": "^3.22.4"
},
"devDependencies": {
"nodemon": "^3.0.3"
},
"engines": {
"node": ">=18.0.0"
}
}
-186
View File
@@ -1,186 +0,0 @@
// src/app.js
// AI Assistant API 서버 메인 엔트리포인트
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./config/swagger.config');
const logger = require('./config/logger.config');
const { sequelize } = require('./models');
const routes = require('./routes');
const errorHandler = require('./middlewares/error-handler.middleware');
const app = express();
// VEXPLOR 내장 시 backend-node가 이 포트로 프록시하므로 기본 3100 사용
const PORT = process.env.PORT || 3100;
// ===========================================
// 미들웨어 설정
// ===========================================
// Trust proxy (Docker/Nginx 환경)
app.set('trust proxy', 1);
// CORS 설정 (helmet보다 먼저 설정)
app.use(cors({
origin: true, // 모든 origin 허용
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
}));
// Preflight 요청 처리
app.options('*', cors());
// 보안 헤더 (CORS 이후에 설정)
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
crossOriginOpenerPolicy: { policy: 'unsafe-none' },
}));
// 요청 본문 파싱
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// 압축
app.use(compression());
// Rate Limiting (전역)
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// 요청 로깅
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
next();
});
// ===========================================
// 헬스 체크
// ===========================================
app.get('/health', (req, res) => {
res.json({
success: true,
data: {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
},
});
});
// ===========================================
// Swagger API 문서
// ===========================================
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
explorer: true,
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'AI Assistant API 문서',
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
},
}));
// Swagger JSON
app.get('/api-docs.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
// ===========================================
// API 라우트
// ===========================================
app.use('/api/v1', routes);
// ===========================================
// 404 처리
// ===========================================
app.use((req, res) => {
res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: `요청한 리소스를 찾을 수 없습니다: ${req.method} ${req.originalUrl}`,
},
});
});
// ===========================================
// 에러 핸들러
// ===========================================
app.use(errorHandler);
// ===========================================
// 서버 시작
// ===========================================
async function startServer() {
try {
// 데이터베이스 연결
await sequelize.authenticate();
logger.info('✅ 데이터베이스 연결 성공');
// 테이블 동기화 (테이블이 없으면 생성)
await sequelize.sync();
logger.info('✅ 데이터베이스 스키마 동기화 완료');
// 초기 데이터 설정 (관리자 계정, LLM 프로바이더)
const initService = require('./services/init.service');
await initService.initialize();
// 서버 시작
app.listen(PORT, () => {
logger.info(`🚀 AI Assistant API 서버가 포트 ${PORT}에서 실행 중입니다`);
logger.info(`📚 API 문서 (Swagger): http://localhost:${PORT}/api-docs`);
logger.info(`📚 API 엔드포인트: http://localhost:${PORT}/api/v1`);
});
} catch (error) {
logger.error('❌ 서버 시작 실패:', error);
process.exit(1);
}
}
// 프로세스 종료 처리
process.on('SIGTERM', async () => {
logger.info('SIGTERM 신호 수신, 서버 종료 중...');
await sequelize.close();
process.exit(0);
});
process.on('SIGINT', async () => {
logger.info('SIGINT 신호 수신, 서버 종료 중...');
await sequelize.close();
process.exit(0);
});
startServer();
module.exports = app;
@@ -1,474 +0,0 @@
// src/controllers/admin.controller.js
// 관리자 컨트롤러
const { LLMProvider, User, UsageLog, ApiKey } = require('../models');
const { Op } = require('sequelize');
const logger = require('../config/logger.config');
// ===== LLM 프로바이더 관리 =====
/**
* LLM 프로바이더 목록 조회
*/
exports.getProviders = async (req, res, next) => {
try {
const providers = await LLMProvider.findAll({
order: [['priority', 'ASC']],
attributes: [
'id',
'name',
'displayName',
'endpoint',
'modelName',
'priority',
'maxTokens',
'temperature',
'timeoutMs',
'costPer1kInputTokens',
'costPer1kOutputTokens',
'isActive',
'isHealthy',
'lastHealthCheck',
'createdAt',
'updatedAt',
// API 키는 마스킹해서 반환
'apiKey',
],
});
// API 키 마스킹
const maskedProviders = providers.map((p) => {
const data = p.toJSON();
if (data.apiKey) {
// 앞 8자만 보여주고 나머지는 마스킹
data.apiKey = data.apiKey.substring(0, 8) + '****' + data.apiKey.slice(-4);
data.hasApiKey = true;
} else {
data.hasApiKey = false;
}
return data;
});
return res.json({
success: true,
data: maskedProviders,
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 추가
*/
exports.createProvider = async (req, res, next) => {
try {
const {
name,
displayName,
endpoint,
apiKey,
modelName,
priority = 50,
maxTokens = 4096,
temperature = 0.7,
timeoutMs = 60000,
costPer1kInputTokens = 0,
costPer1kOutputTokens = 0,
} = req.body;
// 중복 이름 확인
const existing = await LLMProvider.findOne({ where: { name } });
if (existing) {
return res.status(409).json({
success: false,
error: {
code: 'PROVIDER_EXISTS',
message: '이미 존재하는 프로바이더 이름입니다.',
},
});
}
const provider = await LLMProvider.create({
name,
displayName,
endpoint,
apiKey,
modelName,
priority,
maxTokens,
temperature,
timeoutMs,
costPer1kInputTokens,
costPer1kOutputTokens,
isActive: true,
isHealthy: true,
});
logger.info(`LLM 프로바이더 추가: ${name} (${modelName})`);
return res.status(201).json({
success: true,
data: {
id: provider.id,
name: provider.name,
displayName: provider.displayName,
modelName: provider.modelName,
priority: provider.priority,
isActive: provider.isActive,
message: 'LLM 프로바이더가 추가되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 수정
*/
exports.updateProvider = async (req, res, next) => {
try {
const { id } = req.params;
const updates = req.body;
const provider = await LLMProvider.findByPk(id);
if (!provider) {
return res.status(404).json({
success: false,
error: {
code: 'PROVIDER_NOT_FOUND',
message: 'LLM 프로바이더를 찾을 수 없습니다.',
},
});
}
// 허용된 필드만 업데이트
const allowedFields = [
'displayName',
'endpoint',
'apiKey',
'modelName',
'priority',
'maxTokens',
'temperature',
'timeoutMs',
'costPer1kInputTokens',
'costPer1kOutputTokens',
'isActive',
'isHealthy',
];
allowedFields.forEach((field) => {
if (updates[field] !== undefined) {
provider[field] = updates[field];
}
});
await provider.save();
logger.info(`LLM 프로바이더 수정: ${provider.name}`);
return res.json({
success: true,
data: {
id: provider.id,
name: provider.name,
displayName: provider.displayName,
modelName: provider.modelName,
isActive: provider.isActive,
message: 'LLM 프로바이더가 수정되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 삭제
*/
exports.deleteProvider = async (req, res, next) => {
try {
const { id } = req.params;
const provider = await LLMProvider.findByPk(id);
if (!provider) {
return res.status(404).json({
success: false,
error: {
code: 'PROVIDER_NOT_FOUND',
message: 'LLM 프로바이더를 찾을 수 없습니다.',
},
});
}
const providerName = provider.name;
await provider.destroy();
logger.info(`LLM 프로바이더 삭제: ${providerName}`);
return res.json({
success: true,
data: {
message: 'LLM 프로바이더가 삭제되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
// ===== 사용자 관리 =====
/**
* 사용자 목록 조회
*/
exports.getUsers = async (req, res, next) => {
try {
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 100;
const offset = (page - 1) * limit;
const { count, rows: users } = await User.findAndCountAll({
attributes: [
'id',
'email',
'name',
'role',
'status',
'plan',
'monthlyTokenLimit',
'lastLoginAt',
'createdAt',
],
order: [['createdAt', 'DESC']],
limit,
offset,
});
// 페이지네이션 없이 간단한 배열로 반환 (프론트엔드 호환)
return res.json({
success: true,
data: users,
});
} catch (error) {
return next(error);
}
};
/**
* 사용자 정보 수정
*/
exports.updateUser = async (req, res, next) => {
try {
const { id } = req.params;
const { role, status, plan, monthlyTokenLimit } = req.body;
const user = await User.findByPk(id);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
if (role) user.role = role;
if (status) user.status = status;
if (plan) user.plan = plan;
if (monthlyTokenLimit !== undefined) user.monthlyTokenLimit = monthlyTokenLimit;
await user.save();
logger.info(`사용자 정보 수정: ${user.email} (role: ${user.role}, status: ${user.status})`);
return res.json({
success: true,
data: user.toSafeJSON(),
});
} catch (error) {
return next(error);
}
};
// ===== 시스템 통계 =====
/**
* 사용자별 사용량 통계
*/
exports.getUsageByUser = async (req, res, next) => {
try {
const days = parseInt(req.query.days, 10) || 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
// 사용자별 집계 (raw SQL 사용)
const userStats = await UsageLog.sequelize.query(`
SELECT
u.id as "userId",
u.email,
u.name,
COALESCE(SUM(ul.total_tokens), 0) as "totalTokens",
COALESCE(SUM(ul.cost_usd), 0) as "totalCost",
COUNT(ul.id) as "requestCount"
FROM users u
LEFT JOIN usage_logs ul ON u.id = ul.user_id AND ul.created_at >= :startDate
GROUP BY u.id, u.email, u.name
HAVING COUNT(ul.id) > 0
ORDER BY SUM(ul.total_tokens) DESC NULLS LAST
`, {
replacements: { startDate },
type: UsageLog.sequelize.QueryTypes.SELECT,
});
// 데이터 정리
const result = userStats.map((stat) => ({
userId: stat.userId,
email: stat.email || 'Unknown',
name: stat.name || '',
totalTokens: parseInt(stat.totalTokens, 10) || 0,
totalCost: parseFloat(stat.totalCost) || 0,
requestCount: parseInt(stat.requestCount, 10) || 0,
}));
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
};
/**
* 프로바이더별 사용량 통계
*/
exports.getUsageByProvider = async (req, res, next) => {
try {
const days = parseInt(req.query.days, 10) || 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
// 프로바이더별 집계 (컬럼명 수정: providerName, modelName)
const providerStats = await UsageLog.findAll({
where: {
createdAt: { [Op.gte]: startDate },
},
attributes: [
'providerName',
'modelName',
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('prompt_tokens')), 'promptTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('completion_tokens')), 'completionTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
[UsageLog.sequelize.fn('AVG', UsageLog.sequelize.col('response_time_ms')), 'avgResponseTime'],
],
group: ['providerName', 'modelName'],
order: [[UsageLog.sequelize.literal('"totalTokens"'), 'DESC']],
raw: true,
});
// 데이터 정리
const result = providerStats.map((stat) => ({
provider: stat.providerName || 'Unknown',
model: stat.modelName || 'Unknown',
totalTokens: parseInt(stat.totalTokens, 10) || 0,
promptTokens: parseInt(stat.promptTokens, 10) || 0,
completionTokens: parseInt(stat.completionTokens, 10) || 0,
totalCost: parseFloat(stat.totalCost) || 0,
requestCount: parseInt(stat.requestCount, 10) || 0,
avgResponseTime: Math.round(parseFloat(stat.avgResponseTime) || 0),
}));
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
};
/**
* 시스템 통계 조회
*/
exports.getStats = async (req, res, next) => {
try {
// 전체 사용자 수
const totalUsers = await User.count();
const activeUsers = await User.count({ where: { status: 'active' } });
// 전체 API 키 수
const totalApiKeys = await ApiKey.count();
const activeApiKeys = await ApiKey.count({ where: { status: 'active' } });
// 오늘 사용량
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayUsage = await UsageLog.findOne({
where: {
createdAt: { [Op.gte]: today },
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
// 이번 달 사용량
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const monthlyUsage = await UsageLog.findOne({
where: {
createdAt: { [Op.gte]: monthStart },
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
// 활성 프로바이더 수
const activeProviders = await LLMProvider.count({ where: { isActive: true, isHealthy: true } });
return res.json({
success: true,
data: {
users: {
total: totalUsers,
active: activeUsers,
},
apiKeys: {
total: totalApiKeys,
active: activeApiKeys,
},
providers: {
active: activeProviders,
},
usage: {
today: {
tokens: parseInt(todayUsage?.totalTokens, 10) || 0,
cost: parseFloat(todayUsage?.totalCost) || 0,
requests: parseInt(todayUsage?.requestCount, 10) || 0,
},
monthly: {
tokens: parseInt(monthlyUsage?.totalTokens, 10) || 0,
cost: parseFloat(monthlyUsage?.totalCost) || 0,
requests: parseInt(monthlyUsage?.requestCount, 10) || 0,
},
},
},
});
} catch (error) {
return next(error);
}
};
@@ -1,215 +0,0 @@
// src/controllers/api-key.controller.js
// API 키 컨트롤러
const { ApiKey } = require('../models');
const logger = require('../config/logger.config');
/**
* API 키 발급
*/
exports.create = async (req, res, next) => {
try {
const { name, expiresInDays, permissions } = req.body;
const userId = req.user.userId;
// API 키 생성
const rawKey = ApiKey.generateKey();
const keyHash = ApiKey.hashKey(rawKey);
const keyPrefix = rawKey.substring(0, 12);
// 만료 일시 계산
let expiresAt = null;
if (expiresInDays) {
expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
}
const apiKey = await ApiKey.create({
userId,
name,
keyPrefix,
keyHash,
permissions: permissions || ['chat:read', 'chat:write'],
expiresAt,
});
logger.info(`API 키 발급: ${name} (user: ${userId})`);
// 주의: 원본 키는 이 응답에서만 반환됨 (다시 조회 불가)
return res.status(201).json({
success: true,
data: {
id: apiKey.id,
name: apiKey.name,
key: rawKey, // 원본 키 (한 번만 표시)
keyPrefix: apiKey.keyPrefix,
permissions: apiKey.permissions,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt,
message: '⚠️ API 키는 이 응답에서만 확인할 수 있습니다. 안전한 곳에 저장하세요.',
},
});
} catch (error) {
return next(error);
}
};
/**
* API 키 목록 조회
*/
exports.list = async (req, res, next) => {
try {
const userId = req.user.userId;
const apiKeys = await ApiKey.findAll({
where: { userId },
attributes: [
'id',
'name',
'keyPrefix',
'permissions',
'rateLimit',
'status',
'expiresAt',
'lastUsedAt',
'totalRequests',
'createdAt',
],
order: [['createdAt', 'DESC']],
});
return res.json({
success: true,
data: apiKeys,
});
} catch (error) {
return next(error);
}
};
/**
* API 키 상세 조회
*/
exports.get = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
attributes: [
'id',
'name',
'keyPrefix',
'permissions',
'rateLimit',
'status',
'expiresAt',
'lastUsedAt',
'totalRequests',
'createdAt',
'updatedAt',
],
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
return res.json({
success: true,
data: apiKey,
});
} catch (error) {
return next(error);
}
};
/**
* API 키 수정
*/
exports.update = async (req, res, next) => {
try {
const { id } = req.params;
const { name, status } = req.body;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
if (name) apiKey.name = name;
if (status) apiKey.status = status;
await apiKey.save();
logger.info(`API 키 수정: ${apiKey.name} (id: ${id})`);
return res.json({
success: true,
data: {
id: apiKey.id,
name: apiKey.name,
keyPrefix: apiKey.keyPrefix,
status: apiKey.status,
updatedAt: apiKey.updatedAt,
},
});
} catch (error) {
return next(error);
}
};
/**
* API 키 폐기
*/
exports.revoke = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
apiKey.status = 'revoked';
await apiKey.save();
logger.info(`API 키 폐기: ${apiKey.name} (id: ${id})`);
return res.json({
success: true,
data: {
message: 'API 키가 폐기되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
@@ -1,195 +0,0 @@
// src/controllers/auth.controller.js
// 인증 컨트롤러
const jwt = require('jsonwebtoken');
const { User } = require('../models');
const logger = require('../config/logger.config');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret';
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '30d';
/**
* JWT 토큰 생성
*/
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ userId: user.id },
JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
);
return { accessToken, refreshToken };
}
/**
* 회원가입
*/
exports.register = async (req, res, next) => {
try {
const { email, password, name } = req.body;
// 이메일 중복 확인
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
return res.status(409).json({
success: false,
error: {
code: 'EMAIL_ALREADY_EXISTS',
message: '이미 등록된 이메일입니다.',
},
});
}
// 사용자 생성
const user = await User.create({
email,
password,
name,
});
// 토큰 생성
const tokens = generateTokens(user);
logger.info(`새 사용자 가입: ${email}`);
return res.status(201).json({
success: true,
data: {
user: user.toSafeJSON(),
...tokens,
},
});
} catch (error) {
return next(error);
}
};
/**
* 로그인
*/
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
// 사용자 조회
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_CREDENTIALS',
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
},
});
}
// 비밀번호 검증
const isValidPassword = await user.validatePassword(password);
if (!isValidPassword) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_CREDENTIALS',
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
},
});
}
// 계정 상태 확인
if (user.status !== 'active') {
return res.status(403).json({
success: false,
error: {
code: 'ACCOUNT_INACTIVE',
message: '계정이 비활성화되었습니다. 관리자에게 문의하세요.',
},
});
}
// 마지막 로그인 시간 업데이트
user.lastLoginAt = new Date();
await user.save();
// 토큰 생성
const tokens = generateTokens(user);
logger.info(`사용자 로그인: ${email}`);
return res.json({
success: true,
data: {
user: user.toSafeJSON(),
...tokens,
},
});
} catch (error) {
return next(error);
}
};
/**
* 토큰 갱신
*/
exports.refresh = async (req, res, next) => {
try {
const { refreshToken } = req.body;
// 리프레시 토큰 검증
let decoded;
try {
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
} catch (error) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_REFRESH_TOKEN',
message: '유효하지 않은 리프레시 토큰입니다.',
},
});
}
// 사용자 조회
const user = await User.findByPk(decoded.userId);
if (!user || user.status !== 'active') {
return res.status(401).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 새 토큰 생성
const tokens = generateTokens(user);
return res.json({
success: true,
data: tokens,
});
} catch (error) {
return next(error);
}
};
/**
* 로그아웃
*/
exports.logout = async (req, res) => {
// 클라이언트에서 토큰 삭제 처리
// 서버에서는 특별한 처리 없음 (필요시 블랙리스트 구현)
return res.json({
success: true,
data: {
message: '로그아웃되었습니다.',
},
});
};
@@ -1,152 +0,0 @@
// src/controllers/chat.controller.js
// 채팅 컨트롤러 (OpenAI 호환 API)
const llmService = require('../services/llm.service');
const logger = require('../config/logger.config');
/**
* 채팅 완성 API (OpenAI 호환)
* POST /api/v1/chat/completions
*/
exports.completions = async (req, res, next) => {
try {
const {
model = 'gemini-2.0-flash',
messages,
temperature = 0.7,
max_tokens = 4096,
stream = false,
} = req.body;
const startTime = Date.now();
// 스트리밍 응답 처리
if (stream) {
return handleStreamingResponse(req, res, {
model,
messages,
temperature,
maxTokens: max_tokens,
});
}
// 일반 응답 처리
const result = await llmService.chat({
model,
messages,
temperature,
maxTokens: max_tokens,
userId: req.user.id,
apiKeyId: req.apiKey?.id,
});
const responseTime = Date.now() - startTime;
// 사용량 정보 저장 (미들웨어에서 처리)
req.usageData = {
providerId: result.providerId,
providerName: result.provider,
modelName: result.model,
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
costUsd: result.cost,
responseTimeMs: responseTime,
success: true,
};
// OpenAI 호환 응답 형식
return res.json({
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: result.model,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: result.text,
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: result.usage.promptTokens,
completion_tokens: result.usage.completionTokens,
total_tokens: result.usage.totalTokens,
},
});
} catch (error) {
logger.error('채팅 완성 오류:', error);
// 사용량 정보 저장 (실패)
req.usageData = {
success: false,
errorMessage: error.message,
};
return next(error);
}
};
/**
* 스트리밍 응답 처리
*/
async function handleStreamingResponse(req, res, params) {
const { model, messages, temperature, maxTokens } = params;
// SSE 헤더 설정
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// 스트리밍 응답 생성
const stream = await llmService.chatStream({
model,
messages,
temperature,
maxTokens,
userId: req.user.id,
apiKeyId: req.apiKey?.id,
});
// 스트림 이벤트 처리
for await (const chunk of stream) {
const data = {
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
delta: {
content: chunk.text,
},
finish_reason: chunk.done ? 'stop' : null,
},
],
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
// 스트림 종료
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
logger.error('스트리밍 오류:', error);
const errorData = {
error: {
message: error.message,
type: 'server_error',
},
};
res.write(`data: ${JSON.stringify(errorData)}\n\n`);
res.end();
}
}
@@ -1,67 +0,0 @@
// src/controllers/model.controller.js
// 모델 컨트롤러
const { LLMProvider } = require('../models');
/**
* 사용 가능한 모델 목록 조회
*/
exports.list = async (req, res, next) => {
try {
const providers = await LLMProvider.getActiveProviders();
// OpenAI 호환 형식으로 변환
const models = providers.map((provider) => ({
id: provider.modelName,
object: 'model',
created: Math.floor(new Date(provider.createdAt).getTime() / 1000),
owned_by: provider.name,
permission: [],
root: provider.modelName,
parent: null,
}));
return res.json({
object: 'list',
data: models,
});
} catch (error) {
return next(error);
}
};
/**
* 모델 상세 정보 조회
*/
exports.get = async (req, res, next) => {
try {
const { id } = req.params;
const provider = await LLMProvider.findOne({
where: { modelName: id, isActive: true },
});
if (!provider) {
return res.status(404).json({
error: {
message: `모델 '${id}'을(를) 찾을 수 없습니다.`,
type: 'invalid_request_error',
param: 'model',
code: 'model_not_found',
},
});
}
return res.json({
id: provider.modelName,
object: 'model',
created: Math.floor(new Date(provider.createdAt).getTime() / 1000),
owned_by: provider.name,
permission: [],
root: provider.modelName,
parent: null,
});
} catch (error) {
return next(error);
}
};
@@ -1,177 +0,0 @@
// src/controllers/usage.controller.js
// 사용량 컨트롤러
const { UsageLog, User } = require('../models');
const { Op } = require('sequelize');
/**
* 사용량 요약 조회
*/
exports.getSummary = async (req, res, next) => {
try {
const userId = req.user.userId;
// 사용자 정보 조회
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 이번 달 사용량
const now = new Date();
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(
userId,
now.getFullYear(),
now.getMonth() + 1
);
// 오늘 사용량
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart);
todayEnd.setDate(todayEnd.getDate() + 1);
const todayUsage = await UsageLog.findOne({
where: {
userId,
createdAt: {
[Op.between]: [todayStart, todayEnd],
},
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
return res.json({
success: true,
data: {
plan: user.plan,
limit: {
monthly: user.monthlyTokenLimit,
remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens),
},
usage: {
today: {
tokens: parseInt(todayUsage?.totalTokens, 10) || 0,
cost: parseFloat(todayUsage?.totalCost) || 0,
requests: parseInt(todayUsage?.requestCount, 10) || 0,
},
monthly: monthlyUsage,
},
},
});
} catch (error) {
return next(error);
}
};
/**
* 일별 사용량 조회
*/
exports.getDailyUsage = async (req, res, next) => {
try {
const userId = req.user.userId;
const { startDate, endDate } = req.query;
// 기본값: 최근 30일
const end = endDate ? new Date(endDate) : new Date();
const start = startDate ? new Date(startDate) : new Date(end);
if (!startDate) {
start.setDate(start.getDate() - 30);
}
const dailyUsage = await UsageLog.getDailyUsageByUser(userId, start, end);
return res.json({
success: true,
data: {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
usage: dailyUsage,
},
});
} catch (error) {
return next(error);
}
};
/**
* 월별 사용량 조회
*/
exports.getMonthlyUsage = async (req, res, next) => {
try {
const userId = req.user.userId;
const now = new Date();
const year = parseInt(req.query.year, 10) || now.getFullYear();
const month = parseInt(req.query.month, 10) || (now.getMonth() + 1);
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(userId, year, month);
return res.json({
success: true,
data: {
year,
month,
usage: monthlyUsage,
},
});
} catch (error) {
return next(error);
}
};
/**
* 사용량 로그 목록 조회
*/
exports.getLogs = async (req, res, next) => {
try {
const userId = req.user.userId;
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 20;
const offset = (page - 1) * limit;
const { count, rows: logs } = await UsageLog.findAndCountAll({
where: { userId },
attributes: [
'id',
'providerName',
'modelName',
'promptTokens',
'completionTokens',
'totalTokens',
'costUsd',
'responseTimeMs',
'success',
'errorMessage',
'createdAt',
],
order: [['createdAt', 'DESC']],
limit,
offset,
});
return res.json({
success: true,
data: {
logs,
pagination: {
total: count,
page,
limit,
totalPages: Math.ceil(count / limit),
},
},
});
} catch (error) {
return next(error);
}
};
@@ -1,113 +0,0 @@
// src/controllers/user.controller.js
// 사용자 컨트롤러
const { User, UsageLog } = require('../models');
const logger = require('../config/logger.config');
/**
* 내 정보 조회
*/
exports.getMe = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 이번 달 사용량 조회
const now = new Date();
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(
user.id,
now.getFullYear(),
now.getMonth() + 1
);
return res.json({
success: true,
data: {
...user.toSafeJSON(),
usage: {
monthly: monthlyUsage,
limit: user.monthlyTokenLimit,
remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens),
},
},
});
} catch (error) {
return next(error);
}
};
/**
* 내 정보 수정
*/
exports.updateMe = async (req, res, next) => {
try {
const { name, password } = req.body;
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 업데이트할 필드만 설정
if (name) user.name = name;
if (password) user.password = password;
await user.save();
logger.info(`사용자 정보 수정: ${user.email}`);
return res.json({
success: true,
data: user.toSafeJSON(),
});
} catch (error) {
return next(error);
}
};
/**
* 계정 삭제
*/
exports.deleteMe = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 소프트 삭제 (상태 변경)
user.status = 'inactive';
await user.save();
logger.info(`사용자 계정 삭제: ${user.email}`);
return res.json({
success: true,
data: {
message: '계정이 삭제되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
@@ -1,257 +0,0 @@
// src/middlewares/auth.middleware.js
// 인증 미들웨어
const jwt = require('jsonwebtoken');
const { ApiKey, User } = require('../models');
const logger = require('../config/logger.config');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
/**
* JWT 토큰 인증 미들웨어
* Authorization: Bearer <JWT_TOKEN>
*/
exports.authenticateJWT = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: {
code: 'UNAUTHORIZED',
message: '인증 토큰이 필요합니다.',
},
});
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
return next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: {
code: 'TOKEN_EXPIRED',
message: '토큰이 만료되었습니다.',
},
});
}
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
} catch (error) {
return next(error);
}
};
/**
* API 키 인증 미들웨어
* Authorization: Bearer <API_KEY>
*/
exports.authenticateApiKey = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: {
message: 'API 키가 필요합니다.',
type: 'invalid_request_error',
code: 'missing_api_key',
},
});
}
const apiKeyValue = authHeader.substring(7);
// API 키 접두사 확인
const prefix = process.env.API_KEY_PREFIX || 'sk-';
if (!apiKeyValue.startsWith(prefix)) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키 형식입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
// API 키 조회
const apiKey = await ApiKey.findByKey(apiKeyValue);
if (!apiKey) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
// 만료 확인
if (apiKey.isExpired()) {
return res.status(401).json({
error: {
message: 'API 키가 만료되었습니다.',
type: 'invalid_request_error',
code: 'expired_api_key',
},
});
}
// 사용자 상태 확인
if (apiKey.user.status !== 'active') {
return res.status(403).json({
error: {
message: '계정이 비활성화되었습니다.',
type: 'invalid_request_error',
code: 'account_inactive',
},
});
}
// 사용 기록 업데이트
await apiKey.recordUsage();
// 요청 객체에 사용자 및 API 키 정보 추가
req.user = {
id: apiKey.user.id,
userId: apiKey.user.id,
email: apiKey.user.email,
role: apiKey.user.role,
plan: apiKey.user.plan,
};
req.apiKey = apiKey;
return next();
} catch (error) {
logger.error('API 키 인증 오류:', error);
return next(error);
}
};
/**
* 관리자 권한 확인 미들웨어
*/
exports.requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
error: {
code: 'FORBIDDEN',
message: '관리자 권한이 필요합니다.',
},
});
}
return next();
};
/**
* JWT 또는 API 키 인증 미들웨어
* JWT 토큰과 API 키 모두 허용
*/
exports.authenticateAny = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: {
code: 'UNAUTHORIZED',
message: '인증이 필요합니다.',
},
});
}
const token = authHeader.substring(7);
const prefix = process.env.API_KEY_PREFIX || 'sk-';
// API 키인 경우
if (token.startsWith(prefix)) {
const apiKey = await ApiKey.findByKey(token);
if (!apiKey) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
if (apiKey.isExpired()) {
return res.status(401).json({
error: {
message: 'API 키가 만료되었습니다.',
type: 'invalid_request_error',
code: 'expired_api_key',
},
});
}
if (apiKey.user.status !== 'active') {
return res.status(403).json({
error: {
message: '계정이 비활성화되었습니다.',
type: 'invalid_request_error',
code: 'account_inactive',
},
});
}
await apiKey.recordUsage();
req.user = {
id: apiKey.user.id,
userId: apiKey.user.id,
email: apiKey.user.email,
role: apiKey.user.role,
plan: apiKey.user.plan,
};
req.apiKey = apiKey;
return next();
}
// JWT 토큰인 경우
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
return next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: {
code: 'TOKEN_EXPIRED',
message: '토큰이 만료되었습니다.',
},
});
}
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
} catch (error) {
return next(error);
}
};
@@ -1,80 +0,0 @@
// src/middlewares/error-handler.middleware.js
// 에러 핸들러 미들웨어
const logger = require('../config/logger.config');
/**
* 전역 에러 핸들러
*/
module.exports = (err, req, res, _next) => {
// 에러 로깅
logger.error('에러 발생:', {
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
// Sequelize 유효성 검사 에러
if (err.name === 'SequelizeValidationError') {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '데이터 유효성 검사 실패',
details: err.errors.map((e) => ({
field: e.path,
message: e.message,
})),
},
});
}
// Sequelize 고유 제약 조건 에러
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json({
success: false,
error: {
code: 'DUPLICATE_ENTRY',
message: '중복된 데이터가 존재합니다.',
details: err.errors.map((e) => ({
field: e.path,
message: e.message,
})),
},
});
}
// JWT 에러
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
// 기본 에러 응답
const statusCode = err.statusCode || 500;
const message = err.message || '서버 오류가 발생했습니다.';
// 프로덕션 환경에서는 상세 에러 숨김
const response = {
success: false,
error: {
code: err.code || 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production' && statusCode === 500
? '서버 오류가 발생했습니다.'
: message,
},
};
// 개발 환경에서는 스택 트레이스 포함
if (process.env.NODE_ENV === 'development') {
response.error.stack = err.stack;
}
return res.status(statusCode).json(response);
};
@@ -1,50 +0,0 @@
// src/middlewares/usage-logger.middleware.js
// 사용량 로깅 미들웨어
const { UsageLog } = require('../models');
const logger = require('../config/logger.config');
/**
* 사용량 로깅 미들웨어
* 응답 완료 후 사용량 정보를 데이터베이스에 저장
*/
exports.usageLogger = (req, res, next) => {
// 응답 완료 후 처리
res.on('finish', async () => {
try {
// 사용량 데이터가 없으면 스킵
if (!req.usageData) {
return;
}
const usageData = {
userId: req.user?.id || req.user?.userId,
apiKeyId: req.apiKey?.id || null,
providerId: req.usageData.providerId || null,
providerName: req.usageData.providerName || null,
modelName: req.usageData.modelName || null,
promptTokens: req.usageData.promptTokens || 0,
completionTokens: req.usageData.completionTokens || 0,
totalTokens: req.usageData.totalTokens || 0,
costUsd: req.usageData.costUsd || 0,
responseTimeMs: req.usageData.responseTimeMs || null,
success: req.usageData.success !== false,
errorMessage: req.usageData.errorMessage || null,
requestIp: req.ip || req.connection?.remoteAddress,
userAgent: req.headers['user-agent'] || null,
};
await UsageLog.create(usageData);
logger.debug('사용량 로그 저장:', {
userId: usageData.userId,
tokens: usageData.totalTokens,
cost: usageData.costUsd,
});
} catch (error) {
logger.error('사용량 로그 저장 실패:', error);
}
});
next();
};
@@ -1,30 +0,0 @@
// src/middlewares/validation.middleware.js
// 유효성 검사 미들웨어
const { validationResult } = require('express-validator');
/**
* 요청 유효성 검사 결과 처리
*/
exports.validateRequest = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const formattedErrors = errors.array().map((error) => ({
field: error.path,
message: error.msg,
value: error.value,
}));
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '입력값이 올바르지 않습니다.',
details: formattedErrors,
},
});
}
return next();
};
-130
View File
@@ -1,130 +0,0 @@
// src/models/api-key.model.js
// API 키 모델
const { DataTypes } = require('sequelize');
const crypto = require('crypto');
module.exports = (sequelize) => {
const ApiKey = sequelize.define('ApiKey', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
comment: '소유자 사용자 ID',
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: 'API 키 이름 (사용자 지정)',
},
keyPrefix: {
type: DataTypes.STRING(12),
allowNull: false,
comment: 'API 키 접두사 (표시용)',
},
keyHash: {
type: DataTypes.STRING(64),
allowNull: false,
unique: true,
comment: 'API 키 해시 (SHA-256)',
},
permissions: {
type: DataTypes.JSONB,
defaultValue: ['chat:read', 'chat:write'],
comment: '권한 목록',
},
rateLimit: {
type: DataTypes.INTEGER,
defaultValue: 60, // 분당 60회
comment: '분당 요청 제한',
},
status: {
type: DataTypes.ENUM('active', 'revoked', 'expired'),
defaultValue: 'active',
comment: 'API 키 상태',
},
expiresAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '만료 일시 (null이면 무기한)',
},
lastUsedAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 사용 시간',
},
totalRequests: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '총 요청 수',
},
}, {
tableName: 'api_keys',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['key_hash'],
unique: true,
},
{
fields: ['user_id'],
},
{
fields: ['status'],
},
],
});
// 클래스 메서드: API 키 생성
ApiKey.generateKey = function() {
const prefix = process.env.API_KEY_PREFIX || 'sk-';
const length = parseInt(process.env.API_KEY_LENGTH, 10) || 48;
const randomPart = crypto.randomBytes(length).toString('base64url').slice(0, length);
return `${prefix}${randomPart}`;
};
// 클래스 메서드: API 키 해시 생성
ApiKey.hashKey = function(key) {
return crypto.createHash('sha256').update(key).digest('hex');
};
// 클래스 메서드: API 키로 조회
ApiKey.findByKey = async function(key) {
const keyHash = this.hashKey(key);
const apiKey = await this.findOne({
where: { keyHash, status: 'active' },
});
if (apiKey) {
// 사용자 정보 별도 조회
const { User } = require('./index');
apiKey.user = await User.findByPk(apiKey.userId);
}
return apiKey;
};
// 인스턴스 메서드: 사용 기록 업데이트
ApiKey.prototype.recordUsage = async function() {
this.lastUsedAt = new Date();
this.totalRequests += 1;
await this.save();
};
// 인스턴스 메서드: 만료 여부 확인
ApiKey.prototype.isExpired = function() {
if (!this.expiresAt) return false;
return new Date() > this.expiresAt;
};
return ApiKey;
};
-55
View File
@@ -1,55 +0,0 @@
// src/models/index.js
// Sequelize 모델 인덱스
const { Sequelize } = require('sequelize');
const config = require('../config/database.config');
const env = process.env.NODE_ENV || 'development';
const dbConfig = config[env];
// Sequelize 인스턴스 생성
const sequelize = new Sequelize(
dbConfig.database,
dbConfig.username,
dbConfig.password,
{
host: dbConfig.host,
port: dbConfig.port,
dialect: dbConfig.dialect,
logging: dbConfig.logging,
pool: dbConfig.pool,
dialectOptions: dbConfig.dialectOptions,
}
);
// 모델 임포트
const User = require('./user.model')(sequelize);
const ApiKey = require('./api-key.model')(sequelize);
const UsageLog = require('./usage-log.model')(sequelize);
const LLMProvider = require('./llm-provider.model')(sequelize);
// 관계 설정
// User - ApiKey (1:N)
User.hasMany(ApiKey, { foreignKey: 'userId', as: 'apiKeys' });
ApiKey.belongsTo(User, { foreignKey: 'userId', as: 'user' });
// User - UsageLog (1:N)
User.hasMany(UsageLog, { foreignKey: 'userId', as: 'usageLogs' });
UsageLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
// ApiKey - UsageLog (1:N)
ApiKey.hasMany(UsageLog, { foreignKey: 'apiKeyId', as: 'usageLogs' });
UsageLog.belongsTo(ApiKey, { foreignKey: 'apiKeyId', as: 'apiKey' });
// LLMProvider - UsageLog (1:N)
LLMProvider.hasMany(UsageLog, { foreignKey: 'providerId', as: 'usageLogs' });
UsageLog.belongsTo(LLMProvider, { foreignKey: 'providerId', as: 'provider' });
module.exports = {
sequelize,
Sequelize,
User,
ApiKey,
UsageLog,
LLMProvider,
};
@@ -1,143 +0,0 @@
// src/models/llm-provider.model.js
// LLM 프로바이더 모델
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const LLMProvider = sequelize.define('LLMProvider', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
comment: '프로바이더 이름 (gemini, openai, claude 등)',
},
displayName: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '표시 이름',
},
endpoint: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'API 엔드포인트 URL',
},
apiKey: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'API 키 (암호화 저장 권장)',
},
modelName: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '기본 모델 이름',
},
priority: {
type: DataTypes.INTEGER,
defaultValue: 100,
comment: '우선순위 (낮을수록 우선)',
},
maxTokens: {
type: DataTypes.INTEGER,
defaultValue: 4096,
comment: '최대 토큰 수',
},
temperature: {
type: DataTypes.FLOAT,
defaultValue: 0.7,
comment: '기본 온도',
},
timeoutMs: {
type: DataTypes.INTEGER,
defaultValue: 60000,
comment: '타임아웃 (밀리초)',
},
costPer1kInputTokens: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '입력 토큰 1K당 비용 (USD)',
},
costPer1kOutputTokens: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '출력 토큰 1K당 비용 (USD)',
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '활성화 여부',
},
isHealthy: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '건강 상태',
},
lastHealthCheck: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 헬스 체크 시간',
},
healthCheckUrl: {
type: DataTypes.STRING(500),
allowNull: true,
comment: '헬스 체크 URL',
},
config: {
type: DataTypes.JSONB,
defaultValue: {},
comment: '추가 설정',
},
}, {
tableName: 'llm_providers',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['name'],
unique: true,
},
{
fields: ['priority'],
},
{
fields: ['is_active', 'is_healthy'],
},
],
});
// 클래스 메서드: 활성 프로바이더 목록 조회 (우선순위 순)
LLMProvider.getActiveProviders = async function() {
return this.findAll({
where: { isActive: true },
order: [['priority', 'ASC']],
});
};
// 클래스 메서드: 건강한 프로바이더 목록 조회
LLMProvider.getHealthyProviders = async function() {
return this.findAll({
where: { isActive: true, isHealthy: true },
order: [['priority', 'ASC']],
});
};
// 인스턴스 메서드: 헬스 상태 업데이트
LLMProvider.prototype.updateHealth = async function(isHealthy) {
this.isHealthy = isHealthy;
this.lastHealthCheck = new Date();
await this.save();
};
// 인스턴스 메서드: 비용 계산
LLMProvider.prototype.calculateCost = function(promptTokens, completionTokens) {
const inputCost = (promptTokens / 1000) * parseFloat(this.costPer1kInputTokens || 0);
const outputCost = (completionTokens / 1000) * parseFloat(this.costPer1kOutputTokens || 0);
return inputCost + outputCost;
};
return LLMProvider;
};
-164
View File
@@ -1,164 +0,0 @@
// src/models/usage-log.model.js
// 사용량 로그 모델
const { DataTypes, Op } = require('sequelize');
module.exports = (sequelize) => {
const UsageLog = sequelize.define('UsageLog', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
comment: '사용자 ID',
},
apiKeyId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'api_keys',
key: 'id',
},
comment: 'API 키 ID',
},
providerId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'llm_providers',
key: 'id',
},
comment: 'LLM 프로바이더 ID',
},
providerName: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'LLM 프로바이더 이름',
},
modelName: {
type: DataTypes.STRING(100),
allowNull: true,
comment: '사용된 모델 이름',
},
promptTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '프롬프트 토큰 수',
},
completionTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '완성 토큰 수',
},
totalTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '총 토큰 수',
},
costUsd: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '비용 (USD)',
},
responseTimeMs: {
type: DataTypes.INTEGER,
allowNull: true,
comment: '응답 시간 (밀리초)',
},
success: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '성공 여부',
},
errorMessage: {
type: DataTypes.TEXT,
allowNull: true,
comment: '에러 메시지',
},
requestIp: {
type: DataTypes.STRING(45),
allowNull: true,
comment: '요청 IP 주소',
},
userAgent: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'User-Agent',
},
}, {
tableName: 'usage_logs',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['user_id'],
},
{
fields: ['api_key_id'],
},
{
fields: ['created_at'],
},
{
fields: ['provider_name'],
},
],
});
// 클래스 메서드: 사용자별 일별 사용량 조회
UsageLog.getDailyUsageByUser = async function(userId, startDate, endDate) {
return this.findAll({
where: {
userId,
createdAt: {
[Op.between]: [startDate, endDate],
},
},
attributes: [
[sequelize.fn('DATE', sequelize.col('created_at')), 'date'],
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'],
[sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'],
[sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'],
],
group: [sequelize.fn('DATE', sequelize.col('created_at'))],
order: [[sequelize.fn('DATE', sequelize.col('created_at')), 'ASC']],
raw: true,
});
};
// 클래스 메서드: 사용자별 월간 총 사용량 조회
UsageLog.getMonthlyTotalByUser = async function(userId, year, month) {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0, 23, 59, 59);
const result = await this.findOne({
where: {
userId,
createdAt: {
[Op.between]: [startDate, endDate],
},
},
attributes: [
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'],
[sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'],
[sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'],
],
raw: true,
});
return {
totalTokens: parseInt(result.totalTokens, 10) || 0,
totalCost: parseFloat(result.totalCost) || 0,
requestCount: parseInt(result.requestCount, 10) || 0,
};
};
return UsageLog;
};
-92
View File
@@ -1,92 +0,0 @@
// src/models/user.model.js
// 사용자 모델
const { DataTypes } = require('sequelize');
const bcrypt = require('bcryptjs');
module.exports = (sequelize) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
email: {
type: DataTypes.STRING(255),
allowNull: false,
unique: true,
validate: {
isEmail: true,
},
comment: '이메일 (로그인 ID)',
},
password: {
type: DataTypes.STRING(255),
allowNull: false,
comment: '비밀번호 (해시)',
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '사용자 이름',
},
role: {
type: DataTypes.ENUM('user', 'admin'),
defaultValue: 'user',
comment: '역할 (user: 일반 사용자, admin: 관리자)',
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
defaultValue: 'active',
comment: '계정 상태',
},
plan: {
type: DataTypes.ENUM('free', 'basic', 'pro', 'enterprise'),
defaultValue: 'free',
comment: '요금제 플랜',
},
monthlyTokenLimit: {
type: DataTypes.INTEGER,
defaultValue: 100000, // 무료 플랜 기본 10만 토큰
comment: '월간 토큰 한도',
},
lastLoginAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 로그인 시간',
},
}, {
tableName: 'users',
timestamps: true,
underscored: true,
hooks: {
// 비밀번호 해싱
beforeCreate: async (user) => {
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
},
});
// 인스턴스 메서드: 비밀번호 검증
User.prototype.validatePassword = async function(password) {
return bcrypt.compare(password, this.password);
};
// 인스턴스 메서드: 안전한 JSON 변환 (비밀번호 제외)
User.prototype.toSafeJSON = function() {
const values = { ...this.get() };
delete values.password;
return values;
};
return User;
};
-151
View File
@@ -1,151 +0,0 @@
// src/routes/admin.routes.js
// 관리자 라우트
const express = require('express');
const { body, param } = require('express-validator');
const adminController = require('../controllers/admin.controller');
const { authenticateJWT, requireAdmin } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 + 관리자 권한 필요
router.use(authenticateJWT);
router.use(requireAdmin);
// ===== LLM 프로바이더 관리 =====
/**
* GET /api/v1/admin/providers
* LLM 프로바이더 목록 조회
*/
router.get('/providers', adminController.getProviders);
/**
* POST /api/v1/admin/providers
* LLM 프로바이더 추가
*/
router.post(
'/providers',
[
body('name')
.trim()
.isLength({ min: 1, max: 50 })
.withMessage('프로바이더 이름은 1-50자 사이여야 합니다'),
body('displayName')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('표시 이름은 1-100자 사이여야 합니다'),
body('modelName')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('모델 이름은 1-100자 사이여야 합니다'),
body('apiKey')
.optional()
.isString(),
body('priority')
.optional()
.isInt({ min: 1, max: 100 }),
validateRequest,
],
adminController.createProvider
);
/**
* PATCH /api/v1/admin/providers/:id
* LLM 프로바이더 수정 (API 키 설정 포함)
*/
router.patch(
'/providers/:id',
[
param('id')
.isUUID()
.withMessage('유효한 프로바이더 ID가 아닙니다'),
body('apiKey')
.optional()
.isString(),
body('modelName')
.optional()
.isString(),
body('isActive')
.optional()
.isBoolean(),
body('priority')
.optional()
.isInt({ min: 1, max: 100 }),
validateRequest,
],
adminController.updateProvider
);
/**
* DELETE /api/v1/admin/providers/:id
* LLM 프로바이더 삭제
*/
router.delete(
'/providers/:id',
[
param('id')
.isUUID()
.withMessage('유효한 프로바이더 ID가 아닙니다'),
validateRequest,
],
adminController.deleteProvider
);
// ===== 사용자 관리 =====
/**
* GET /api/v1/admin/users
* 사용자 목록 조회
*/
router.get('/users', adminController.getUsers);
/**
* PATCH /api/v1/admin/users/:id
* 사용자 정보 수정 (역할, 상태, 플랜 등)
*/
router.patch(
'/users/:id',
[
param('id')
.isUUID()
.withMessage('유효한 사용자 ID가 아닙니다'),
body('role')
.optional()
.isIn(['user', 'admin']),
body('status')
.optional()
.isIn(['active', 'inactive', 'suspended']),
body('plan')
.optional()
.isIn(['free', 'basic', 'pro', 'enterprise']),
body('monthlyTokenLimit')
.optional()
.isInt({ min: 0 }),
validateRequest,
],
adminController.updateUser
);
// ===== 시스템 통계 =====
/**
* GET /api/v1/admin/stats
* 시스템 통계 조회
*/
router.get('/stats', adminController.getStats);
/**
* GET /api/v1/admin/usage/by-user
* 사용자별 사용량 통계
*/
router.get('/usage/by-user', adminController.getUsageByUser);
/**
* GET /api/v1/admin/usage/by-provider
* 프로바이더별 사용량 통계
*/
router.get('/usage/by-provider', adminController.getUsageByProvider);
module.exports = router;
-99
View File
@@ -1,99 +0,0 @@
// src/routes/api-key.routes.js
// API 키 라우트
const express = require('express');
const { body, param } = require('express-validator');
const apiKeyController = require('../controllers/api-key.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* POST /api/v1/api-keys
* API 키 발급
*/
router.post(
'/',
[
body('name')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('API 키 이름은 1-100자 사이여야 합니다'),
body('expiresInDays')
.optional()
.isInt({ min: 1, max: 365 })
.withMessage('만료 기간은 1-365일 사이여야 합니다'),
body('permissions')
.optional()
.isArray()
.withMessage('권한은 배열이어야 합니다'),
validateRequest,
],
apiKeyController.create
);
/**
* GET /api/v1/api-keys
* API 키 목록 조회
*/
router.get('/', apiKeyController.list);
/**
* GET /api/v1/api-keys/:id
* API 키 상세 조회
*/
router.get(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
validateRequest,
],
apiKeyController.get
);
/**
* PATCH /api/v1/api-keys/:id
* API 키 수정
*/
router.patch(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
body('name')
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('API 키 이름은 1-100자 사이여야 합니다'),
body('status')
.optional()
.isIn(['active', 'revoked'])
.withMessage('상태는 active 또는 revoked여야 합니다'),
validateRequest,
],
apiKeyController.update
);
/**
* DELETE /api/v1/api-keys/:id
* API 키 폐기
*/
router.delete(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
validateRequest,
],
apiKeyController.revoke
);
module.exports = router;
-76
View File
@@ -1,76 +0,0 @@
// src/routes/auth.routes.js
// 인증 라우트
const express = require('express');
const { body } = require('express-validator');
const authController = require('../controllers/auth.controller');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
/**
* POST /api/v1/auth/register
* 회원가입
*/
router.post(
'/register',
[
body('email')
.isEmail()
.normalizeEmail()
.withMessage('유효한 이메일 주소를 입력해주세요'),
body('password')
.isLength({ min: 8 })
.withMessage('비밀번호는 최소 8자 이상이어야 합니다')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'),
body('name')
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('이름은 2-100자 사이여야 합니다'),
validateRequest,
],
authController.register
);
/**
* POST /api/v1/auth/login
* 로그인
*/
router.post(
'/login',
[
body('email')
.isEmail()
.normalizeEmail()
.withMessage('유효한 이메일 주소를 입력해주세요'),
body('password')
.notEmpty()
.withMessage('비밀번호를 입력해주세요'),
validateRequest,
],
authController.login
);
/**
* POST /api/v1/auth/refresh
* 토큰 갱신
*/
router.post(
'/refresh',
[
body('refreshToken')
.notEmpty()
.withMessage('리프레시 토큰을 입력해주세요'),
validateRequest,
],
authController.refresh
);
/**
* POST /api/v1/auth/logout
* 로그아웃
*/
router.post('/logout', authController.logout);
module.exports = router;
-55
View File
@@ -1,55 +0,0 @@
// src/routes/chat.routes.js
// 채팅 API 라우트 (OpenAI 호환)
const express = require('express');
const { body } = require('express-validator');
const chatController = require('../controllers/chat.controller');
const { authenticateAny } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const { usageLogger } = require('../middlewares/usage-logger.middleware');
const router = express.Router();
/**
* POST /api/v1/chat/completions
* 채팅 완성 API (OpenAI 호환)
*
* 인증: Bearer API_KEY 또는 JWT 토큰
*/
router.post(
'/completions',
authenticateAny,
[
body('model')
.optional()
.isString()
.withMessage('모델은 문자열이어야 합니다'),
body('messages')
.isArray({ min: 1 })
.withMessage('메시지 배열이 필요합니다'),
body('messages.*.role')
.isIn(['system', 'user', 'assistant'])
.withMessage('메시지 역할은 system, user, assistant 중 하나여야 합니다'),
body('messages.*.content')
.isString()
.notEmpty()
.withMessage('메시지 내용이 필요합니다'),
body('temperature')
.optional()
.isFloat({ min: 0, max: 2 })
.withMessage('온도는 0-2 사이여야 합니다'),
body('max_tokens')
.optional()
.isInt({ min: 1, max: 128000 })
.withMessage('최대 토큰은 1-128000 사이여야 합니다'),
body('stream')
.optional()
.isBoolean()
.withMessage('스트림은 불리언이어야 합니다'),
validateRequest,
],
usageLogger,
chatController.completions
);
module.exports = router;
-45
View File
@@ -1,45 +0,0 @@
// src/routes/index.js
// API 라우트 인덱스
const express = require('express');
const authRoutes = require('./auth.routes');
const userRoutes = require('./user.routes');
const apiKeyRoutes = require('./api-key.routes');
const chatRoutes = require('./chat.routes');
const usageRoutes = require('./usage.routes');
const modelRoutes = require('./model.routes');
const adminRoutes = require('./admin.routes');
const router = express.Router();
// API 정보
router.get('/', (req, res) => {
res.json({
success: true,
data: {
name: 'AI Assistant API',
version: '1.0.0',
description: 'LLM API Platform - OpenAI 호환 API',
endpoints: {
auth: '/api/v1/auth',
users: '/api/v1/users',
apiKeys: '/api/v1/api-keys',
chat: '/api/v1/chat',
models: '/api/v1/models',
usage: '/api/v1/usage',
},
documentation: 'https://docs.example.com',
},
});
});
// 라우트 등록
router.use('/auth', authRoutes);
router.use('/users', userRoutes);
router.use('/api-keys', apiKeyRoutes);
router.use('/chat', chatRoutes);
router.use('/models', modelRoutes);
router.use('/usage', usageRoutes);
router.use('/admin', adminRoutes);
module.exports = router;
-24
View File
@@ -1,24 +0,0 @@
// src/routes/model.routes.js
// 모델 라우트
const express = require('express');
const modelController = require('../controllers/model.controller');
const { authenticateAny } = require('../middlewares/auth.middleware');
const router = express.Router();
/**
* GET /api/v1/models
* 사용 가능한 모델 목록 조회
* JWT 토큰 또는 API 키로 인증
*/
router.get('/', authenticateAny, modelController.list);
/**
* GET /api/v1/models/:id
* 모델 상세 정보 조회
* JWT 토큰 또는 API 키로 인증
*/
router.get('/:id', authenticateAny, modelController.get);
module.exports = router;
-81
View File
@@ -1,81 +0,0 @@
// src/routes/usage.routes.js
// 사용량 라우트
const express = require('express');
const { query } = require('express-validator');
const usageController = require('../controllers/usage.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* GET /api/v1/usage
* 사용량 요약 조회
*/
router.get('/', usageController.getSummary);
/**
* GET /api/v1/usage/daily
* 일별 사용량 조회
*/
router.get(
'/daily',
[
query('startDate')
.optional()
.isISO8601()
.withMessage('시작 날짜는 ISO 8601 형식이어야 합니다'),
query('endDate')
.optional()
.isISO8601()
.withMessage('종료 날짜는 ISO 8601 형식이어야 합니다'),
validateRequest,
],
usageController.getDailyUsage
);
/**
* GET /api/v1/usage/monthly
* 월별 사용량 조회
*/
router.get(
'/monthly',
[
query('year')
.optional()
.isInt({ min: 2020, max: 2100 })
.withMessage('연도는 2020-2100 사이여야 합니다'),
query('month')
.optional()
.isInt({ min: 1, max: 12 })
.withMessage('월은 1-12 사이여야 합니다'),
validateRequest,
],
usageController.getMonthlyUsage
);
/**
* GET /api/v1/usage/logs
* 사용량 로그 목록 조회
*/
router.get(
'/logs',
[
query('page')
.optional()
.isInt({ min: 1 })
.withMessage('페이지는 1 이상이어야 합니다'),
query('limit')
.optional()
.isInt({ min: 1, max: 100 })
.withMessage('한도는 1-100 사이여야 합니다'),
validateRequest,
],
usageController.getLogs
);
module.exports = router;
-50
View File
@@ -1,50 +0,0 @@
// src/routes/user.routes.js
// 사용자 라우트
const express = require('express');
const { body } = require('express-validator');
const userController = require('../controllers/user.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* GET /api/v1/users/me
* 내 정보 조회
*/
router.get('/me', userController.getMe);
/**
* PATCH /api/v1/users/me
* 내 정보 수정
*/
router.patch(
'/me',
[
body('name')
.optional()
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('이름은 2-100자 사이여야 합니다'),
body('password')
.optional()
.isLength({ min: 8 })
.withMessage('비밀번호는 최소 8자 이상이어야 합니다')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'),
validateRequest,
],
userController.updateMe
);
/**
* DELETE /api/v1/users/me
* 계정 삭제
*/
router.delete('/me', userController.deleteMe);
module.exports = router;
@@ -1,74 +0,0 @@
// src/seeders/001-llm-providers.js
// LLM 프로바이더 시드 데이터
const { v4: uuidv4 } = require('uuid');
module.exports = {
up: async (queryInterface, Sequelize) => {
const now = new Date();
await queryInterface.bulkInsert('llm_providers', [
{
id: uuidv4(),
name: 'gemini',
display_name: 'Google Gemini',
endpoint: null, // SDK 사용
api_key: process.env.GEMINI_API_KEY || '',
model_name: 'gemini-2.0-flash',
priority: 1,
max_tokens: 8192,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00025,
cost_per_1k_output_tokens: 0.001,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
{
id: uuidv4(),
name: 'openai',
display_name: 'OpenAI GPT',
endpoint: 'https://api.openai.com/v1/chat/completions',
api_key: process.env.OPENAI_API_KEY || '',
model_name: 'gpt-4o-mini',
priority: 2,
max_tokens: 4096,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00015,
cost_per_1k_output_tokens: 0.0006,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
{
id: uuidv4(),
name: 'claude',
display_name: 'Anthropic Claude',
endpoint: 'https://api.anthropic.com/v1/messages',
api_key: process.env.CLAUDE_API_KEY || '',
model_name: 'claude-3-haiku-20240307',
priority: 3,
max_tokens: 4096,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00025,
cost_per_1k_output_tokens: 0.00125,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('llm_providers', null, {});
},
};
-128
View File
@@ -1,128 +0,0 @@
// src/services/init.service.js
// 초기 데이터 설정 서비스
const { User, LLMProvider } = require('../models');
const logger = require('../config/logger.config');
/**
* 초기 관리자 계정 생성
*/
async function createDefaultAdmin() {
try {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@admin.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'Admin123!';
const existing = await User.findOne({ where: { email: adminEmail } });
if (existing) {
logger.info(`관리자 계정 이미 존재: ${adminEmail}`);
return existing;
}
const admin = await User.create({
email: adminEmail,
password: adminPassword,
name: '관리자',
role: 'admin',
status: 'active',
plan: 'enterprise',
monthlyTokenLimit: 10000000, // 1000만 토큰
});
logger.info(`✅ 기본 관리자 계정 생성: ${adminEmail}`);
return admin;
} catch (error) {
logger.error('관리자 계정 생성 실패:', error);
throw error;
}
}
/**
* 기본 LLM 프로바이더 생성
*/
async function createDefaultProviders() {
try {
const providers = [
{
name: 'gemini',
displayName: 'Google Gemini',
endpoint: null,
apiKey: process.env.GEMINI_API_KEY || '',
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
priority: 1,
maxTokens: 8192,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.001,
},
{
name: 'openai',
displayName: 'OpenAI GPT',
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY || '',
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
priority: 2,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00015,
costPer1kOutputTokens: 0.0006,
},
{
name: 'claude',
displayName: 'Anthropic Claude',
endpoint: 'https://api.anthropic.com/v1/messages',
apiKey: process.env.CLAUDE_API_KEY || '',
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
priority: 3,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.00125,
},
];
for (const providerData of providers) {
const existing = await LLMProvider.findOne({ where: { name: providerData.name } });
if (existing) {
// API 키가 환경변수에 설정되어 있고 DB에는 없으면 업데이트
if (providerData.apiKey && !existing.apiKey) {
existing.apiKey = providerData.apiKey;
existing.modelName = providerData.modelName;
await existing.save();
logger.info(`LLM 프로바이더 API 키 업데이트: ${providerData.name}`);
}
continue;
}
await LLMProvider.create({
...providerData,
isActive: true,
isHealthy: !!providerData.apiKey, // API 키가 있으면 healthy
});
logger.info(`✅ LLM 프로바이더 생성: ${providerData.name} (${providerData.modelName})`);
}
} catch (error) {
logger.error('LLM 프로바이더 생성 실패:', error);
throw error;
}
}
/**
* 초기화 실행
*/
async function initialize() {
logger.info('🔧 초기 데이터 설정 시작...');
await createDefaultAdmin();
await createDefaultProviders();
logger.info('✅ 초기 데이터 설정 완료');
}
module.exports = {
initialize,
createDefaultAdmin,
createDefaultProviders,
};
-385
View File
@@ -1,385 +0,0 @@
// src/services/llm.service.js
// LLM 서비스 - 멀티 프로바이더 지원
const axios = require('axios');
const { LLMProvider } = require('../models');
const logger = require('../config/logger.config');
class LLMService {
constructor() {
this.providers = [];
this.initialized = false;
}
/**
* 서비스 초기화
*/
async initialize() {
if (this.initialized) return;
try {
await this.loadProviders();
this.initialized = true;
logger.info('✅ LLM 서비스 초기화 완료');
} catch (error) {
logger.error('❌ LLM 서비스 초기화 실패:', error);
// 초기화 실패 시 기본 프로바이더 사용
this.providers = this.getDefaultProviders();
this.initialized = true;
}
}
/**
* 데이터베이스에서 프로바이더 로드
*/
async loadProviders() {
try {
const providers = await LLMProvider.getHealthyProviders();
if (providers.length === 0) {
logger.warn('⚠️ 활성 프로바이더가 없습니다. 기본 프로바이더 사용');
this.providers = this.getDefaultProviders();
} else {
this.providers = providers.map((p) => ({
id: p.id,
name: p.name,
endpoint: p.endpoint,
apiKey: p.apiKey,
modelName: p.modelName,
priority: p.priority,
maxTokens: p.maxTokens,
temperature: p.temperature,
timeoutMs: p.timeoutMs,
costPer1kInputTokens: parseFloat(p.costPer1kInputTokens) || 0,
costPer1kOutputTokens: parseFloat(p.costPer1kOutputTokens) || 0,
isHealthy: p.isHealthy,
config: p.config,
}));
}
logger.info(`📥 ${this.providers.length}개 프로바이더 로드됨`);
} catch (error) {
logger.error('프로바이더 로드 실패:', error);
throw error;
}
}
/**
* 기본 프로바이더 설정 (환경 변수 기반)
*/
getDefaultProviders() {
const providers = [];
// Gemini
if (process.env.GEMINI_API_KEY) {
providers.push({
id: 'default-gemini',
name: 'gemini',
apiKey: process.env.GEMINI_API_KEY,
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
priority: 1,
maxTokens: 8192,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.001,
isHealthy: true,
});
}
// OpenAI
if (process.env.OPENAI_API_KEY) {
providers.push({
id: 'default-openai',
name: 'openai',
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY,
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
priority: 2,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00015,
costPer1kOutputTokens: 0.0006,
isHealthy: true,
});
}
// Claude
if (process.env.CLAUDE_API_KEY) {
providers.push({
id: 'default-claude',
name: 'claude',
endpoint: 'https://api.anthropic.com/v1/messages',
apiKey: process.env.CLAUDE_API_KEY,
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
priority: 3,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.00125,
isHealthy: true,
});
}
return providers;
}
/**
* 채팅 API 호출 (자동 fallback)
*/
async chat(params) {
const {
model,
messages,
temperature = 0.7,
maxTokens = 4096,
userId,
apiKeyId,
} = params;
// 초기화 확인
if (!this.initialized) {
await this.initialize();
}
const startTime = Date.now();
let lastError = null;
// 요청된 모델에 맞는 프로바이더 찾기
const requestedProvider = this.providers.find(
(p) => p.modelName === model || p.name === model
);
// 우선순위 순으로 프로바이더 정렬
const sortedProviders = requestedProvider
? [requestedProvider, ...this.providers.filter((p) => p !== requestedProvider)]
: this.providers;
// 프로바이더 순회 (fallback)
for (const provider of sortedProviders) {
if (!provider.isHealthy) {
logger.warn(`⚠️ ${provider.name} 건강하지 않음, 건너뜀`);
continue;
}
try {
logger.info(`🚀 ${provider.name} (${provider.modelName}) 시도 중...`);
const result = await this.callProvider(provider, {
messages,
maxTokens: maxTokens || provider.maxTokens,
temperature: temperature || provider.temperature,
});
const responseTime = Date.now() - startTime;
// 비용 계산
const cost = this.calculateCost(
result.usage.promptTokens,
result.usage.completionTokens,
provider.costPer1kInputTokens,
provider.costPer1kOutputTokens
);
logger.info(
`${provider.name} 성공 (${responseTime}ms, ${result.usage.totalTokens} tokens)`
);
return {
text: result.text,
provider: provider.name,
providerId: provider.id,
model: provider.modelName,
usage: result.usage,
responseTime,
cost,
};
} catch (error) {
logger.error(`${provider.name} 실패:`, error.message);
lastError = error;
// 다음 프로바이더로 fallback
continue;
}
}
// 모든 프로바이더 실패
throw new Error(
`모든 LLM 프로바이더가 실패했습니다: ${lastError?.message || '알 수 없는 오류'}`
);
}
/**
* 개별 프로바이더 호출
*/
async callProvider(provider, { messages, maxTokens, temperature }) {
const timeout = provider.timeoutMs || 60000;
switch (provider.name) {
case 'gemini':
return this.callGemini(provider, { messages, maxTokens, temperature });
case 'openai':
return this.callOpenAI(provider, { messages, maxTokens, temperature, timeout });
case 'claude':
return this.callClaude(provider, { messages, maxTokens, temperature, timeout });
default:
throw new Error(`지원하지 않는 프로바이더: ${provider.name}`);
}
}
/**
* Gemini API 호출
*/
async callGemini(provider, { messages, maxTokens, temperature }) {
const { GoogleGenAI } = require('@google/genai');
const ai = new GoogleGenAI({ apiKey: provider.apiKey });
// 메시지 변환 (OpenAI 형식 -> Gemini 형식)
const contents = messages.map((msg) => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}));
// system 메시지 처리
const systemMessage = messages.find((m) => m.role === 'system');
const systemInstruction = systemMessage ? systemMessage.content : undefined;
const config = {
maxOutputTokens: maxTokens,
temperature,
};
const result = await ai.models.generateContent({
model: provider.modelName,
contents: contents.filter((c) => c.role !== 'system'),
systemInstruction,
config,
});
// 응답 텍스트 추출
let text = '';
if (result.candidates?.[0]?.content?.parts) {
text = result.candidates[0].content.parts
.filter((p) => p.text)
.map((p) => p.text)
.join('\n');
}
const usage = result.usageMetadata || {};
const promptTokens = usage.promptTokenCount ?? 0;
const completionTokens = usage.candidatesTokenCount ?? 0;
return {
text,
usage: {
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
},
};
}
/**
* OpenAI API 호출
*/
async callOpenAI(provider, { messages, maxTokens, temperature, timeout }) {
const response = await axios.post(
provider.endpoint,
{
model: provider.modelName,
messages,
max_tokens: maxTokens,
temperature,
},
{
timeout,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${provider.apiKey}`,
},
}
);
return {
text: response.data.choices[0].message.content,
usage: {
promptTokens: response.data.usage.prompt_tokens,
completionTokens: response.data.usage.completion_tokens,
totalTokens: response.data.usage.total_tokens,
},
};
}
/**
* Claude API 호출
*/
async callClaude(provider, { messages, maxTokens, temperature, timeout }) {
// system 메시지 분리
const systemMessage = messages.find((m) => m.role === 'system');
const otherMessages = messages.filter((m) => m.role !== 'system');
const response = await axios.post(
provider.endpoint,
{
model: provider.modelName,
messages: otherMessages,
system: systemMessage?.content,
max_tokens: maxTokens,
temperature,
},
{
timeout,
headers: {
'Content-Type': 'application/json',
'x-api-key': provider.apiKey,
'anthropic-version': '2023-06-01',
},
}
);
return {
text: response.data.content[0].text,
usage: {
promptTokens: response.data.usage.input_tokens,
completionTokens: response.data.usage.output_tokens,
totalTokens:
response.data.usage.input_tokens + response.data.usage.output_tokens,
},
};
}
/**
* 스트리밍 채팅 (제너레이터)
*/
async *chatStream(params) {
// 현재는 간단한 구현 (전체 응답 후 청크로 분할)
// 실제 스트리밍은 각 프로바이더의 스트리밍 API 사용 필요
const result = await this.chat(params);
// 텍스트를 청크로 분할하여 전송
const chunkSize = 10;
for (let i = 0; i < result.text.length; i += chunkSize) {
yield {
text: result.text.slice(i, i + chunkSize),
done: i + chunkSize >= result.text.length,
};
}
}
/**
* 비용 계산
*/
calculateCost(promptTokens, completionTokens, inputCost, outputCost) {
const inputTotal = (promptTokens / 1000) * inputCost;
const outputTotal = (completionTokens / 1000) * outputCost;
return parseFloat((inputTotal + outputTotal).toFixed(6));
}
}
// 싱글톤 인스턴스
const llmService = new LLMService();
module.exports = llmService;
-359
View File
@@ -1,359 +0,0 @@
// src/swagger/api-docs.js
// Swagger API 문서 정의
/**
* @swagger
* /auth/register:
* post:
* tags: [Auth]
* summary: 회원가입
* description: 새 계정을 생성합니다.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, password, name]
* properties:
* email:
* type: string
* format: email
* example: user@example.com
* password:
* type: string
* minLength: 8
* example: Password123!
* description: 8자 이상, 영문/숫자/특수문자 포함
* name:
* type: string
* example: 홍길동
* responses:
* 201:
* description: 회원가입 성공
* 400:
* description: 유효성 검사 실패
* 409:
* description: 이미 존재하는 이메일
*/
/**
* @swagger
* /auth/login:
* post:
* tags: [Auth]
* summary: 로그인
* description: 이메일과 비밀번호로 로그인합니다.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, password]
* properties:
* email:
* type: string
* format: email
* example: admin@admin.com
* password:
* type: string
* example: Admin123!
* responses:
* 200:
* description: 로그인 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* user:
* type: object
* accessToken:
* type: string
* description: JWT 액세스 토큰
* refreshToken:
* type: string
* description: JWT 리프레시 토큰
* 401:
* description: 인증 실패
*/
/**
* @swagger
* /chat/completions:
* post:
* tags: [Chat]
* summary: 채팅 완성 (OpenAI 호환)
* description: |
* AI 모델에 메시지를 보내고 응답을 받습니다.
* OpenAI API와 호환되는 형식입니다.
*
* **인증**: JWT 토큰 또는 API 키 (sk-xxx)
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ChatCompletionRequest'
* examples:
* simple:
* summary: 간단한 질문
* value:
* model: gemini-2.0-flash
* messages:
* - role: user
* content: 안녕하세요!
* with_system:
* summary: 시스템 프롬프트 포함
* value:
* model: gemini-2.0-flash
* messages:
* - role: system
* content: 당신은 친절한 AI 어시스턴트입니다.
* - role: user
* content: 파이썬으로 Hello World 출력하는 코드 알려줘
* temperature: 0.7
* max_tokens: 1000
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ChatCompletionResponse'
* 401:
* description: 인증 실패
* 429:
* description: 요청 한도 초과
*/
/**
* @swagger
* /models:
* get:
* tags: [Models]
* summary: 모델 목록 조회
* description: 사용 가능한 AI 모델 목록을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* object:
* type: string
* example: list
* data:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* example: gemini-2.0-flash
* object:
* type: string
* example: model
* owned_by:
* type: string
* example: google
*/
/**
* @swagger
* /api-keys:
* get:
* tags: [API Keys]
* summary: API 키 목록 조회
* description: 발급받은 API 키 목록을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* post:
* tags: [API Keys]
* summary: API 키 발급
* description: 새 API 키를 발급받습니다. 키는 한 번만 표시됩니다.
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name:
* type: string
* example: My API Key
* description: API 키 이름
* responses:
* 201:
* description: 발급 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* key:
* type: string
* description: 발급된 API 키 (한 번만 표시)
* example: sk-abc123def456...
*/
/**
* @swagger
* /api-keys/{id}:
* delete:
* tags: [API Keys]
* summary: API 키 폐기
* description: API 키를 폐기합니다.
* security:
* - BearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: API 키 ID
* responses:
* 200:
* description: 폐기 성공
* 404:
* description: API 키를 찾을 수 없음
*/
/**
* @swagger
* /usage:
* get:
* tags: [Usage]
* summary: 사용량 요약 조회
* description: 오늘/이번 달 사용량 요약을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* plan:
* type: string
* example: free
* limit:
* type: object
* properties:
* monthly:
* type: integer
* remaining:
* type: integer
* usage:
* type: object
* properties:
* today:
* type: object
* monthly:
* type: object
*/
/**
* @swagger
* /usage/logs:
* get:
* tags: [Usage]
* summary: 사용 로그 조회
* description: API 호출 로그를 조회합니다.
* security:
* - BearerAuth: []
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* default: 1
* - in: query
* name: limit
* schema:
* type: integer
* default: 20
* responses:
* 200:
* description: 성공
*/
/**
* @swagger
* /admin/users:
* get:
* tags: [Admin]
* summary: 사용자 목록 조회 (관리자)
* description: 모든 사용자 목록을 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
/**
* @swagger
* /admin/providers:
* get:
* tags: [Admin]
* summary: LLM 프로바이더 목록 (관리자)
* description: LLM 프로바이더 설정을 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
/**
* @swagger
* /admin/stats:
* get:
* tags: [Admin]
* summary: 시스템 통계 (관리자)
* description: 시스템 전체 통계를 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
+33 -2
View File
@@ -92,6 +92,7 @@ import screenFileRoutes from "./routes/screenFileRoutes";
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
import batchRoutes from "./routes/batchRoutes";
import batchManagementRoutes from "./routes/batchManagementRoutes";
import knowledgeRoutes from "./routes/knowledgeRoutes";
import batchExecutionLogRoutes from "./routes/batchExecutionLogRoutes";
// import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; // 파일이 존재하지 않음
import ddlRoutes from "./routes/ddlRoutes";
@@ -150,6 +151,11 @@ import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
import aiAgentRoutes from "./routes/aiAgentRoutes"; // AI 에이전트 관리
import aiAgentGroupRoutes from "./routes/aiAgentGroupRoutes"; // 멀티 에이전트 워크스페이스
import aiScheduleRoutes from "./routes/aiScheduleRoutes"; // AI 스케줄러
import aiProxyRoutes from "./routes/openClawProxyRoutes"; // AI 엔진 (자체 LLM 클라이언트)
import pipelineDeviceConnectionRoutes from "./routes/pipelineDeviceConnectionRoutes"; // 파이프라인 장비 연결
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
@@ -323,7 +329,7 @@ app.use("/api/external-db-connections", externalDbConnectionRoutes);
app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes);
app.use("/api/batch-configs", batchRoutes);
// app.use("/api/batch-configs", batchRoutes); // 레거시 → batchManagementRoutes로 통합
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
app.use("/api/batch-management", batchManagementRoutes);
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
@@ -386,7 +392,13 @@ app.use("/api/receiving", receivingRoutes); // 입고관리
app.use("/api/outbound", outboundRoutes); // 출고관리
app.use("/api/quotes", quoteRoutes); // 견적관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
// app.use("/api/ai/v1", aiAssistantProxy); // 레거시 AI 어시스턴트 (비활성)
app.use("/api/ai-agents", aiAgentRoutes); // AI 에이전트 관리
app.use("/api/ai-agent-groups", aiAgentGroupRoutes); // 멀티 에이전트 워크스페이스
app.use("/api/ai-knowledge", knowledgeRoutes); // AI 지식 파일 라이브러리
app.use("/api/ai-schedules", aiScheduleRoutes); // AI 스케줄러
app.use("/api/ai/v1", aiProxyRoutes); // AI 엔진 (자체 LLM 클라이언트)
app.use("/api/pipeline-device-connections", pipelineDeviceConnectionRoutes); // 파이프라인 장비 연결
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
app.use("/api/approval", approvalRoutes); // 결재 시스템
app.use("/api/user-mail", userMailRoutes); // 사용자 메일 계정
@@ -465,6 +477,11 @@ async function initializeServices() {
runMessengerMigration,
runSmartFactoryLogMigration,
runSmartFactoryScheduleMigration,
runOpenClawMigration,
runMultiAgentMigration,
runMenuRenameMigration,
runKnowledgeLibraryMigration,
runPipelineDeviceMigration,
} = await import("./database/runMigration");
await runDashboardMigration();
@@ -475,6 +492,11 @@ async function initializeServices() {
await runMessengerMigration();
await runSmartFactoryLogMigration();
await runSmartFactoryScheduleMigration();
await runOpenClawMigration();
await runMultiAgentMigration();
await runMenuRenameMigration();
await runKnowledgeLibraryMigration();
await runPipelineDeviceMigration();
} catch (error) {
logger.error(`❌ 마이그레이션 실패:`, error);
}
@@ -539,6 +561,15 @@ async function initializeServices() {
} catch (error) {
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
}
// AI 스케줄러 초기화
try {
const { AiSchedulerService } = await import("./services/aiSchedulerService");
await AiSchedulerService.initializeSchedules();
logger.info("🤖 AI 엔진 초기화 완료 (자체 LLM 클라이언트)");
} catch (error) {
logger.warn("⚠️ AI 스케줄러 초기화 스킵:", error);
}
}
export default app;
@@ -0,0 +1,138 @@
import { Request, Response } from "express";
import { AiAgentService } from "../services/aiAgentService";
import { AiAgentApiKeyService } from "../services/aiAgentApiKeyService";
import { AiAgentProviderService } from "../services/aiAgentProviderService";
import { AiAgentConversationService } from "../services/aiAgentConversationService";
import { AiAgentUsageService } from "../services/aiAgentUsageService";
import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
// ========== 에이전트 CRUD ==========
export class AiAgentController {
static async list(req: AuthenticatedRequest, res: Response) {
const { status, company_code, search } = req.query;
// company_code=* 이면 전체 조회, 아니면 해당 회사만
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
const companyFilter = (company_code as string) === "*" || isSuperAdmin
? undefined
: (company_code as string) || req.user?.companyCode;
const agents = await AiAgentService.list({
status: status as string,
company_code: companyFilter,
search: search as string,
});
res.json({ success: true, data: agents });
}
static async getById(req: AuthenticatedRequest, res: Response) {
const agent = await AiAgentService.getById(parseInt(req.params.id));
if (!agent) return res.status(404).json({ success: false, message: "에이전트를 찾을 수 없습니다." });
res.json({ success: true, data: agent });
}
static async create(req: AuthenticatedRequest, res: Response) {
const agent = await AiAgentService.create(req.body, req.user!.userId);
res.status(201).json({ success: true, data: agent, message: "에이전트가 생성되었습니다." });
}
static async update(req: AuthenticatedRequest, res: Response) {
const agent = await AiAgentService.update(parseInt(req.params.id), req.body);
if (!agent) return res.status(404).json({ success: false, message: "에이전트를 찾을 수 없습니다." });
res.json({ success: true, data: agent, message: "에이전트가 수정되었습니다." });
}
static async delete(req: AuthenticatedRequest, res: Response) {
await AiAgentService.delete(parseInt(req.params.id));
res.json({ success: true, message: "에이전트가 삭제되었습니다." });
}
}
// ========== API 키 관리 ==========
export class AiAgentApiKeyController {
static async list(req: AuthenticatedRequest, res: Response) {
const isAdmin = req.user?.userType === "SUPER_ADMIN" || req.user?.userType === "COMPANY_ADMIN";
const keys = await AiAgentApiKeyService.list(req.user!.userId, isAdmin);
res.json({ success: true, data: keys });
}
static async create(req: AuthenticatedRequest, res: Response) {
const { key, plainKey } = await AiAgentApiKeyService.create(req.body, req.user!.userId, req.user?.companyCode);
res.status(201).json({
success: true,
data: { ...key, plain_key: plainKey },
message: "API 키가 생성되었습니다. 키는 한 번만 표시됩니다.",
});
}
static async revoke(req: AuthenticatedRequest, res: Response) {
await AiAgentApiKeyService.revoke(parseInt(req.params.id), req.user!.userId);
res.json({ success: true, message: "API 키가 폐기되었습니다." });
}
}
// ========== LLM 프로바이더 관리 ==========
export class AiAgentProviderController {
static async list(req: Request, res: Response) {
const providers = await AiAgentProviderService.list();
res.json({ success: true, data: providers });
}
static async create(req: Request, res: Response) {
const provider = await AiAgentProviderService.create(req.body);
res.status(201).json({ success: true, data: provider, message: "프로바이더가 추가되었습니다." });
}
static async update(req: Request, res: Response) {
const provider = await AiAgentProviderService.update(parseInt(req.params.id), req.body);
if (!provider) return res.status(404).json({ success: false, message: "프로바이더를 찾을 수 없습니다." });
res.json({ success: true, data: provider, message: "프로바이더가 수정되었습니다." });
}
static async delete(req: Request, res: Response) {
await AiAgentProviderService.delete(parseInt(req.params.id));
res.json({ success: true, message: "프로바이더가 삭제되었습니다." });
}
}
// ========== 대화 모니터링 ==========
export class AiAgentConversationController {
static async list(req: Request, res: Response) {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const agentId = req.query.agent_id ? parseInt(req.query.agent_id as string) : undefined;
const result = await AiAgentConversationService.list(page, limit, agentId);
res.json({ success: true, data: result.conversations, total: result.total });
}
static async getById(req: Request, res: Response) {
const result = await AiAgentConversationService.getById(parseInt(req.params.id));
if (!result.conversation) return res.status(404).json({ success: false, message: "대화를 찾을 수 없습니다." });
res.json({ success: true, data: result });
}
static async delete(req: Request, res: Response) {
await AiAgentConversationService.delete(parseInt(req.params.id));
res.json({ success: true, message: "대화가 삭제되었습니다." });
}
}
// ========== 사용량 ==========
export class AiAgentUsageController {
static async summary(req: Request, res: Response) {
const summary = await AiAgentUsageService.getSummary();
res.json({ success: true, data: summary });
}
static async logs(req: Request, res: Response) {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const result = await AiAgentUsageService.getLogs(page, limit);
res.json({ success: true, data: result.logs, total: result.total });
}
static async daily(req: Request, res: Response) {
const days = parseInt(req.query.days as string) || 30;
const data = await AiAgentUsageService.getDailyUsage(days);
res.json({ success: true, data });
}
}
@@ -420,6 +420,26 @@ export class BatchManagementController {
}
}
/**
* 배치 설정 삭제
*/
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({ success: false, message: "유효하지 않은 배치 ID입니다." });
}
const result = await BatchService.deleteBatchConfig(
id,
req.user?.userId,
req.user?.companyCode
);
res.json(result);
} catch (error: any) {
res.status(500).json({ success: false, message: error.message || "배치 삭제에 실패했습니다." });
}
}
/**
* REST API 데이터 미리보기
*/
+88
View File
@@ -257,3 +257,91 @@ export async function runDtgManagementLogMigration() {
}
}
}
/**
* OpenClaw 멀티 에이전트 AI 통합 테이블 마이그레이션
* ai_agents, ai_agent_api_keys, ai_agent_conversations,
* ai_agent_messages, ai_agent_usage_logs, ai_llm_providers
*/
export async function runMultiAgentMigration() {
try {
console.log("🔄 멀티 에이전트 워크스페이스 마이그레이션 시작...");
const sqlFilePath = path.join(__dirname, "../../db/migrations/301_create_multi_agent_tables.sql");
if (!fs.existsSync(sqlFilePath)) { console.log("⚠️ 멀티 에이전트 마이그레이션 파일 없음"); return; }
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 멀티 에이전트 워크스페이스 마이그레이션 완료!");
} catch (error) {
console.error("❌ 멀티 에이전트 마이그레이션 실패:", error);
if (error instanceof Error && error.message.includes("already exists")) {
console.log("ℹ️ 멀티 에이전트 테이블이 이미 존재합니다.");
}
}
}
export async function runMenuRenameMigration() {
try {
const sqlFilePath = path.join(__dirname, "../../db/migrations/302_rename_menu_datasource.sql");
if (!fs.existsSync(sqlFilePath)) return;
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 메뉴명 변경 마이그레이션 완료 (외부 커넥션 관리 → 데이터 소스)");
} catch (error) {
// 이미 변경되었거나 menu_info 테이블 구조가 다르면 무시
console.log("ℹ️ 메뉴명 마이그레이션 스킵");
}
}
export async function runKnowledgeLibraryMigration() {
try {
const sqlFilePath = path.join(__dirname, "../../db/migrations/303_create_knowledge_library.sql");
if (!fs.existsSync(sqlFilePath)) return;
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 지식 파일 라이브러리 테이블 생성 완료");
} catch (error) {
if (error instanceof Error && error.message.includes("already exists")) {
console.log("ℹ️ 지식 파일 테이블 이미 존재");
}
}
}
export async function runPipelineDeviceMigration() {
try {
const sqlFilePath = path.join(__dirname, "../../db/migrations/304_create_pipeline_device_tables.sql");
if (!fs.existsSync(sqlFilePath)) return;
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 파이프라인 장비 연결 테이블 생성 완료");
} catch (error) {
if (error instanceof Error && error.message.includes("already exists")) {
console.log("ℹ️ 파이프라인 장비 테이블 이미 존재");
}
}
}
export async function runOpenClawMigration() {
try {
console.log("🔄 OpenClaw AI 에이전트 마이그레이션 시작...");
const sqlFilePath = path.join(
__dirname,
"../../db/migrations/300_create_openclaw_tables.sql"
);
if (!fs.existsSync(sqlFilePath)) {
console.log("⚠️ OpenClaw 마이그레이션 파일이 없습니다:", sqlFilePath);
return;
}
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ OpenClaw AI 에이전트 마이그레이션 완료!");
} catch (error) {
console.error("❌ OpenClaw 마이그레이션 실패:", error);
if (error instanceof Error && error.message.includes("already exists")) {
console.log("️ OpenClaw 테이블이 이미 존재합니다.");
}
}
}
@@ -0,0 +1,57 @@
import { Request, Response, NextFunction } from "express";
import { AiAgentApiKeyService } from "../services/aiAgentApiKeyService";
import { AiAgentApiKey } from "../types/aiAgent";
import { logger } from "../utils/logger";
export interface AiApiKeyRequest extends Request {
apiKey?: AiAgentApiKey;
apiKeyUserId?: string;
}
/**
* 외부 서비스 API 키 인증 미들웨어
* sk-pipe-xxx 형태의 키를 검증하고 rate limit 체크
*/
export const aiApiKeyAuth = async (
req: AiApiKeyRequest,
res: Response,
next: NextFunction
): Promise<void> => {
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
res.status(401).json({ error: { message: "API key required", type: "auth_error" } });
return;
}
// Pipeline API 키 (sk-pipe-*)
if (token.startsWith("sk-pipe-")) {
const apiKey = await AiAgentApiKeyService.validateKey(token);
if (!apiKey) {
res.status(401).json({ error: { message: "Invalid API key", type: "auth_error" } });
return;
}
// 월간 토큰 제한 체크
if (apiKey.monthly_token_limit > 0 && apiKey.total_tokens >= apiKey.monthly_token_limit) {
res.status(429).json({ error: { message: "Monthly token limit exceeded", type: "rate_limit_error" } });
return;
}
req.apiKey = apiKey;
req.apiKeyUserId = apiKey.user_id;
next();
return;
}
// JWT 토큰도 허용 (Pipeline 내부 사용자)
try {
const { JwtUtils } = await import("../utils/jwtUtils");
const userInfo = JwtUtils.verifyToken(token);
(req as any).user = userInfo;
next();
} catch {
res.status(401).json({ error: { message: "Invalid authentication", type: "auth_error" } });
}
};
@@ -0,0 +1,57 @@
import { Router, Response } from "express";
import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware";
import { AiAgentGroupService } from "../services/aiAgentGroupService";
const router = Router();
router.use(authenticateToken);
// 그룹 CRUD
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
const groups = await AiAgentGroupService.list(req.user?.companyCode);
res.json({ success: true, data: groups });
});
router.get("/connectors", async (req: AuthenticatedRequest, res: Response) => {
const connectors = await AiAgentGroupService.getAvailableConnectors();
res.json({ success: true, data: connectors });
});
router.get("/:id", async (req: AuthenticatedRequest, res: Response) => {
const group = await AiAgentGroupService.getById(parseInt(req.params.id));
if (!group) return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
res.json({ success: true, data: group });
});
router.post("/", async (req: AuthenticatedRequest, res: Response) => {
const group = await AiAgentGroupService.create(req.body, req.user!.userId);
res.status(201).json({ success: true, data: group, message: "멀티 에이전트 그룹이 생성되었습니다." });
});
router.put("/:id", async (req: AuthenticatedRequest, res: Response) => {
const group = await AiAgentGroupService.update(parseInt(req.params.id), req.body);
if (!group) return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
res.json({ success: true, data: group, message: "그룹이 수정되었습니다." });
});
router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => {
await AiAgentGroupService.delete(parseInt(req.params.id));
res.json({ success: true, message: "그룹이 삭제되었습니다." });
});
// 멤버 관리
router.post("/:id/members", async (req: AuthenticatedRequest, res: Response) => {
const member = await AiAgentGroupService.addMember(parseInt(req.params.id), req.body);
res.status(201).json({ success: true, data: member, message: "멤버가 추가되었습니다." });
});
router.put("/members/:memberId", async (req: AuthenticatedRequest, res: Response) => {
const member = await AiAgentGroupService.updateMember(parseInt(req.params.memberId), req.body);
res.json({ success: true, data: member, message: "멤버가 수정되었습니다." });
});
router.delete("/members/:memberId", async (req: AuthenticatedRequest, res: Response) => {
await AiAgentGroupService.removeMember(parseInt(req.params.memberId));
res.json({ success: true, message: "멤버가 제거되었습니다." });
});
export default router;
+44
View File
@@ -0,0 +1,44 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
AiAgentController,
AiAgentApiKeyController,
AiAgentProviderController,
AiAgentConversationController,
AiAgentUsageController,
} from "../controllers/aiAgentController";
const router = Router();
// 모든 라우트 인증 필요
router.use(authenticateToken);
// ===== 에이전트 CRUD =====
router.get("/", AiAgentController.list);
router.get("/:id", AiAgentController.getById);
router.post("/", AiAgentController.create);
router.put("/:id", AiAgentController.update);
router.delete("/:id", AiAgentController.delete);
// ===== API 키 관리 =====
router.get("/keys/list", AiAgentApiKeyController.list);
router.post("/keys", AiAgentApiKeyController.create);
router.delete("/keys/:id", AiAgentApiKeyController.revoke);
// ===== LLM 프로바이더 관리 =====
router.get("/providers/list", AiAgentProviderController.list);
router.post("/providers", AiAgentProviderController.create);
router.put("/providers/:id", AiAgentProviderController.update);
router.delete("/providers/:id", AiAgentProviderController.delete);
// ===== 대화 모니터링 =====
router.get("/conversations/list", AiAgentConversationController.list);
router.get("/conversations/:id", AiAgentConversationController.getById);
router.delete("/conversations/:id", AiAgentConversationController.delete);
// ===== 사용량 =====
router.get("/usage/summary", AiAgentUsageController.summary);
router.get("/usage/logs", AiAgentUsageController.logs);
router.get("/usage/daily", AiAgentUsageController.daily);
export default router;
@@ -0,0 +1,28 @@
import { Router, Response } from "express";
import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware";
import { AiSchedulerService } from "../services/aiSchedulerService";
const router = Router();
router.use(authenticateToken);
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
const schedules = await AiSchedulerService.list();
res.json({ success: true, data: schedules });
});
router.post("/", async (req: AuthenticatedRequest, res: Response) => {
const schedule = await AiSchedulerService.create(req.body, req.user!.userId);
res.status(201).json({ success: true, data: schedule, message: "스케줄이 생성되었습니다." });
});
router.put("/:id", async (req: AuthenticatedRequest, res: Response) => {
const schedule = await AiSchedulerService.update(parseInt(req.params.id), req.body);
res.json({ success: true, data: schedule, message: "스케줄이 수정되었습니다." });
});
router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => {
await AiSchedulerService.delete(parseInt(req.params.id));
res.json({ success: true, message: "스케줄이 삭제되었습니다." });
});
export default router;
@@ -86,6 +86,12 @@ router.get("/batch-configs/:id/recent-logs", authenticateToken, BatchManagementC
*/
router.put("/batch-configs/:id", authenticateToken, BatchManagementController.updateBatchConfig);
/**
* DELETE /api/batch-management/batch-configs/:id
* 배치 설정 삭제
*/
router.delete("/batch-configs/:id", authenticateToken, BatchManagementController.deleteBatchConfig);
/**
* POST /api/batch-management/batch-configs/:id/execute
* 배치 수동 실행
+110
View File
@@ -0,0 +1,110 @@
import { Router, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { authenticateToken } from "../middleware/authMiddleware";
import { query, queryOne } from "../database/db";
import { logger } from "../utils/logger";
const router = Router();
/**
* GET /api/ai-knowledge
* 지식 파일 라이브러리 목록 조회
*/
router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { category, search } = req.query;
let sql = "SELECT id, name, file_name, category, description, file_size, company_code, created_by, created_at, updated_at FROM ai_knowledge_files WHERE 1=1";
const params: any[] = [];
let idx = 1;
if (category && category !== "all") {
sql += ` AND category = $${idx++}`;
params.push(category);
}
if (search) {
sql += ` AND (name ILIKE $${idx} OR description ILIKE $${idx} OR file_name ILIKE $${idx})`;
params.push(`%${search}%`);
idx++;
}
sql += " ORDER BY category, name";
const files = await query(sql, params);
res.json({ success: true, data: files });
});
/**
* GET /api/ai-knowledge/:id
* 지식 파일 내용 포함 상세 조회
*/
router.get("/:id", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const file = await queryOne("SELECT * FROM ai_knowledge_files WHERE id = $1", [req.params.id]);
if (!file) return res.status(404).json({ success: false, message: "파일을 찾을 수 없습니다." });
res.json({ success: true, data: file });
});
/**
* POST /api/ai-knowledge
* 지식 파일 업로드 (라이브러리에 등록)
*/
router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { name, file_name, category, description, content } = req.body;
if (!name || !content || !category) {
return res.status(400).json({ success: false, message: "name, content, category는 필수입니다." });
}
const result = await query(
`INSERT INTO ai_knowledge_files (name, file_name, category, description, content, file_size, company_code, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, name, file_name, category, description, file_size, created_at`,
[name, file_name || name, category, description || null, content, Buffer.byteLength(content, "utf8"), req.user?.companyCode || null, req.user?.userId]
);
logger.info(`지식 파일 등록: ${name} (${category})`);
res.status(201).json({ success: true, data: result[0] });
});
/**
* PUT /api/ai-knowledge/:id
* 지식 파일 수정
*/
router.put("/:id", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { name, category, description, content } = req.body;
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (name !== undefined) { sets.push(`name = $${idx++}`); params.push(name); }
if (category !== undefined) { sets.push(`category = $${idx++}`); params.push(category); }
if (description !== undefined) { sets.push(`description = $${idx++}`); params.push(description); }
if (content !== undefined) {
sets.push(`content = $${idx++}`); params.push(content);
sets.push(`file_size = $${idx++}`); params.push(Buffer.byteLength(content, "utf8"));
}
if (sets.length === 0) return res.json({ success: true });
sets.push("updated_at = NOW()");
params.push(req.params.id);
await query(`UPDATE ai_knowledge_files SET ${sets.join(", ")} WHERE id = $${idx}`, params);
res.json({ success: true, message: "수정 완료" });
});
/**
* DELETE /api/ai-knowledge/:id
* 지식 파일 삭제
*/
router.delete("/:id", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
await query("DELETE FROM ai_knowledge_files WHERE id = $1", [req.params.id]);
res.json({ success: true, message: "삭제 완료" });
});
/**
* GET /api/ai-knowledge/categories/list
* 카테고리 목록 (파일 수 포함)
*/
router.get("/categories/list", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const cats = await query(
"SELECT category, COUNT(*) as count FROM ai_knowledge_files GROUP BY category ORDER BY category"
);
res.json({ success: true, data: cats });
});
export default router;
@@ -0,0 +1,205 @@
import { Router, Request, Response } from "express";
import { aiApiKeyAuth, AiApiKeyRequest } from "../middleware/aiApiKeyAuthMiddleware";
import { AiAgentUsageService } from "../services/aiAgentUsageService";
import { AiAgentApiKeyService } from "../services/aiAgentApiKeyService";
import { MultiAgentExecutionEngine } from "../services/multiAgentExecutionEngine";
import { LlmClient } from "../services/llmClient";
import { logger } from "../utils/logger";
import { query } from "../database/db";
const router = Router();
// 모든 라우트에 API 키 인증 적용
router.use(aiApiKeyAuth);
/**
* POST /api/ai/v1/chat/completions
* OpenAI 호환 채팅 엔드포인트 → DB 프로바이더로 직접 호출
*/
router.post("/chat/completions", async (req: AiApiKeyRequest, res: Response) => {
const startTime = Date.now();
try {
// 스트리밍 요청
if (req.body.stream) {
const stream = await LlmClient.chatCompletionStream({
model: req.body.model,
messages: req.body.messages,
max_tokens: req.body.max_tokens,
temperature: req.body.temperature,
});
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
stream.pipe(res);
return;
}
// 비스트리밍 요청
const result = await LlmClient.chatCompletion({
model: req.body.model,
messages: req.body.messages,
max_tokens: req.body.max_tokens,
temperature: req.body.temperature,
});
// 사용량 추적
const usage = result.usage;
if (usage) {
const elapsed = Date.now() - startTime;
await AiAgentUsageService.log({
user_id: req.apiKeyUserId || (req as any).user?.userId,
api_key_id: req.apiKey?.id,
provider_name: result.model?.split("-")[0] || "unknown",
model_name: result.model || req.body.model,
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0,
total_tokens: usage.total_tokens || 0,
response_time_ms: elapsed,
success: true,
request_path: "/v1/chat/completions",
ip_address: req.ip,
});
if (req.apiKey) {
await AiAgentApiKeyService.addTokenUsage(req.apiKey.id, usage.total_tokens || 0);
}
}
res.json(result);
} catch (error: any) {
const elapsed = Date.now() - startTime;
await AiAgentUsageService.log({
user_id: req.apiKeyUserId || (req as any).user?.userId,
api_key_id: req.apiKey?.id,
model_name: req.body.model,
response_time_ms: elapsed,
success: false,
error_message: error.message,
request_path: "/v1/chat/completions",
ip_address: req.ip,
});
// LLM 프로바이더 에러 응답 전달
const status = error.response?.status || 500;
res.status(status).json(
error.response?.data || { error: { message: error.message, type: "server_error" } }
);
}
});
/**
* POST /api/ai/v1/groups/:groupId
* 멀티 에이전트 그룹 실행
*/
router.post("/groups/:groupId", async (req: AiApiKeyRequest, res: Response) => {
const groupId = parseInt(req.params.groupId);
const { message } = req.body;
if (!message) {
return res.status(400).json({ error: { message: "message is required", type: "invalid_request" } });
}
// group_id(문자열)로도 조회 가능
let actualGroupId = groupId;
if (isNaN(groupId)) {
const group = await query<any>(
"SELECT id FROM ai_agent_groups WHERE group_id = $1 AND status = 'active'",
[req.params.groupId]
).catch(() => []);
if (group.length === 0) {
return res.status(404).json({ error: { message: "Group not found", type: "not_found" } });
}
actualGroupId = group[0].id;
}
try {
const result = await MultiAgentExecutionEngine.execute(actualGroupId, message, {
userId: req.apiKeyUserId || (req as any).user?.userId,
apiKeyId: req.apiKey?.id,
});
res.json({
success: true,
data: {
group: result.groupName,
execution_mode: result.executionMode,
total_tokens: result.totalTokens,
duration_ms: result.totalDurationMs,
steps: result.steps.map((s) => ({
order: s.executionOrder,
role: s.roleName,
agent: s.agentName,
model: s.modelName,
response: s.response,
tokens: s.tokensUsed,
duration_ms: s.durationMs,
})),
summary: result.finalSummary,
},
});
} catch (e: any) {
res.status(500).json({
error: { message: e.message, type: "execution_error" },
});
}
});
/**
* GET /api/ai/v1/groups
* 사용 가능한 멀티 에이전트 그룹 목록
*/
router.get("/groups", async (req: AiApiKeyRequest, res: Response) => {
const groups = await query<any>(
`SELECT g.id, g.group_id, g.name, g.description, g.execution_mode,
(SELECT COUNT(*) FROM ai_agent_group_members WHERE group_id = g.id) as member_count
FROM ai_agent_groups g WHERE g.status = 'active' ORDER BY g.name`
).catch(() => []);
res.json({ success: true, data: groups });
});
/**
* GET /api/ai/v1/models
* 사용 가능한 모델 목록 (DB 프로바이더 기반)
*/
router.get("/models", async (req: AiApiKeyRequest, res: Response) => {
try {
const models = await LlmClient.listModels();
res.json(models);
} catch {
res.json({
object: "list",
data: [
{ id: "claude-sonnet-4-20250514", object: "model", owned_by: "anthropic" },
{ id: "gpt-4o", object: "model", owned_by: "openai" },
{ id: "gpt-4o-mini", object: "model", owned_by: "openai" },
],
});
}
});
/**
* GET /api/ai/v1/health
* AI 엔진 상태 확인
*/
router.get("/health", async (req: Request, res: Response) => {
try {
const models = await LlmClient.listModels();
res.json({
status: "running",
engine: "pipeline-native",
providers: models.data?.length || 0,
});
} catch {
res.json({
status: "no_providers",
engine: "pipeline-native",
providers: 0,
});
}
});
export default router;
@@ -0,0 +1,147 @@
import { Router, Response } from "express";
import { PipelineDeviceConnectionService } from "../services/pipelineDeviceConnectionService";
import { PROTOCOL_OPTIONS, PROTOCOL_DEFAULTS, TAG_DATA_TYPE_OPTIONS, ADDRESS_TYPE_OPTIONS } from "../types/pipelineDeviceTypes";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
const router = Router();
// 모든 라우트 인증 필요
router.use(authenticateToken);
// ===== 프로토콜 목록 (정적 경로 우선) =====
router.get("/protocols", async (req: AuthenticatedRequest, res: Response) => {
res.json({
success: true,
data: { protocols: PROTOCOL_OPTIONS, defaults: PROTOCOL_DEFAULTS, tagDataTypes: TAG_DATA_TYPE_OPTIONS, addressTypes: ADDRESS_TYPE_OPTIONS },
});
});
// ===== 태그 수정/삭제 (정적 경로 우선) =====
router.put("/tags/:tagId", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.updateTagMapping(parseInt(req.params.tagId), req.body);
res.status(result.success ? 200 : 400).json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.delete("/tags/:tagId", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.deleteTagMapping(parseInt(req.params.tagId));
res.json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
// ===== 연결 CRUD =====
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
try {
const userCompanyCode = req.user?.companyCode;
let companyCodeFilter: string | undefined;
if (userCompanyCode === "*") {
companyCodeFilter = req.query.company_code as string;
} else {
companyCodeFilter = userCompanyCode;
}
const filter = {
protocol: req.query.protocol as string,
is_active: req.query.is_active as string,
company_code: companyCodeFilter,
search: req.query.search as string,
status: req.query.status as string,
};
Object.keys(filter).forEach((key) => {
if (!filter[key as keyof typeof filter]) delete filter[key as keyof typeof filter];
});
const result = await PipelineDeviceConnectionService.getConnections(filter, userCompanyCode);
res.json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get("/:id", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.getConnectionById(parseInt(req.params.id));
res.status(result.success ? 200 : 404).json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post("/", async (req: AuthenticatedRequest, res: Response) => {
try {
const data = {
...req.body,
company_code: req.body.company_code || req.user?.companyCode,
created_by: req.user?.userId,
};
const result = await PipelineDeviceConnectionService.createConnection(data);
res.status(result.success ? 201 : 400).json(result);
} catch (e: any) {
if (e.message?.includes("duplicate") || e.code === "23505") {
res.status(409).json({ success: false, message: "동일한 연결명이 이미 존재합니다." });
} else {
res.status(500).json({ success: false, message: e.message });
}
}
});
router.put("/:id", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.updateConnection(parseInt(req.params.id), req.body);
res.status(result.success ? 200 : 404).json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.deleteConnection(parseInt(req.params.id));
res.json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
// ===== 연결 테스트 =====
router.post("/:id/test", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.testConnection(parseInt(req.params.id));
res.json({ success: result.success, data: result });
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
// ===== 태그 매핑 =====
router.get("/:id/tags", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.getTagMappings(parseInt(req.params.id));
res.json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post("/:id/tags", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.createTagMapping(parseInt(req.params.id), req.body);
res.status(result.success ? 201 : 400).json(result);
} catch (e: any) {
if (e.message?.includes("duplicate") || e.code === "23505") {
res.status(409).json({ success: false, message: "동일한 태그명이 이미 존재합니다." });
} else {
res.status(500).json({ success: false, message: e.message });
}
}
});
export default router;
@@ -0,0 +1,89 @@
import crypto from "crypto";
import { query, queryOne } from "../database/db";
import { AiAgentApiKey, CreateApiKeyRequest } from "../types/aiAgent";
import { logger } from "../utils/logger";
export class AiAgentApiKeyService {
private static generateKey(): { plainKey: string; hash: string; prefix: string } {
const randomBytes = crypto.randomBytes(32).toString("hex");
const plainKey = `sk-pipe-${randomBytes}`;
const hash = crypto.createHash("sha256").update(plainKey).digest("hex");
const prefix = plainKey.substring(0, 16);
return { plainKey, hash, prefix };
}
static async list(userId: string, isAdmin: boolean): Promise<AiAgentApiKey[]> {
if (isAdmin) {
return query<AiAgentApiKey>(
"SELECT * FROM ai_agent_api_keys ORDER BY created_at DESC"
);
}
return query<AiAgentApiKey>(
"SELECT * FROM ai_agent_api_keys WHERE user_id = $1 ORDER BY created_at DESC",
[userId]
);
}
static async create(data: CreateApiKeyRequest, userId: string, companyCode?: string): Promise<{ key: AiAgentApiKey; plainKey: string }> {
const { plainKey, hash, prefix } = this.generateKey();
const result = await query<AiAgentApiKey>(
`INSERT INTO ai_agent_api_keys (name, key_hash, key_prefix, user_id, company_code, agent_id, permissions, rate_limit, monthly_token_limit, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10)
RETURNING *`,
[
data.name,
hash,
prefix,
userId,
companyCode || null,
data.agent_id || null,
JSON.stringify(data.permissions || ["chat"]),
data.rate_limit || 60,
data.monthly_token_limit || 1000000,
data.expires_at || null,
]
);
logger.info(`API 키 생성: ${prefix}... (by ${userId})`);
return { key: result[0], plainKey };
}
static async revoke(id: number, userId: string): Promise<boolean> {
await query(
"DELETE FROM ai_agent_api_keys WHERE id = $1 AND (user_id = $2 OR $2 = 'wace')",
[id, userId]
);
logger.info(`API 키 삭제: id=${id} (by ${userId})`);
return true;
}
static async validateKey(plainKey: string): Promise<AiAgentApiKey | null> {
const hash = crypto.createHash("sha256").update(plainKey).digest("hex");
const key = await queryOne<AiAgentApiKey>(
"SELECT * FROM ai_agent_api_keys WHERE key_hash = $1 AND status = 'active'",
[hash]
);
if (!key) return null;
if (key.expires_at && new Date(key.expires_at) < new Date()) {
return null;
}
// 마지막 사용 시간 업데이트
await query(
"UPDATE ai_agent_api_keys SET last_used_at = NOW(), usage_count = usage_count + 1 WHERE id = $1",
[key.id]
);
return key;
}
static async addTokenUsage(keyId: number, tokens: number): Promise<void> {
await query(
"UPDATE ai_agent_api_keys SET total_tokens = total_tokens + $1 WHERE id = $2",
[tokens, keyId]
);
}
}
@@ -0,0 +1,87 @@
import crypto from "crypto";
import { query, queryOne } from "../database/db";
import { AiConversation, AiMessage } from "../types/aiAgent";
export class AiAgentConversationService {
static async list(page: number = 1, limit: number = 20, agentId?: number): Promise<{ conversations: AiConversation[]; total: number }> {
const offset = (page - 1) * limit;
let where = "1=1";
const params: any[] = [limit, offset];
if (agentId) {
where += " AND agent_id = $3";
params.push(agentId);
}
const totalResult = await queryOne<{ cnt: string }>(
`SELECT COUNT(*) as cnt FROM ai_agent_conversations WHERE ${where}`,
agentId ? [agentId] : []
);
const conversations = await query<AiConversation>(
`SELECT c.*, a.name as agent_name,
COALESCE(a.name, c.metadata->>'group_name') as display_name
FROM ai_agent_conversations c
LEFT JOIN ai_agents a ON c.agent_id = a.id
WHERE ${where}
ORDER BY c.updated_at DESC LIMIT $1 OFFSET $2`,
params
);
return { conversations, total: parseInt(totalResult?.cnt || "0") };
}
static async getById(id: number): Promise<{ conversation: AiConversation | null; messages: AiMessage[] }> {
const conversation = await queryOne<AiConversation>(
`SELECT c.*, a.name as agent_name
FROM ai_agent_conversations c
LEFT JOIN ai_agents a ON c.agent_id = a.id
WHERE c.id = $1`,
[id]
);
const messages = conversation
? await query<AiMessage>(
"SELECT * FROM ai_agent_messages WHERE conversation_id = $1 ORDER BY created_at",
[id]
)
: [];
return { conversation, messages };
}
static async createConversation(agentId?: number, userId?: string, apiKeyId?: number): Promise<AiConversation> {
const conversationId = `conv-${crypto.randomUUID()}`;
const result = await query<AiConversation>(
`INSERT INTO ai_agent_conversations (conversation_id, agent_id, user_id, api_key_id)
VALUES ($1, $2, $3, $4) RETURNING *`,
[conversationId, agentId || null, userId || null, apiKeyId || null]
);
return result[0];
}
static async addMessage(conversationId: number, role: string, content: string, tokenCount: number = 0, toolCalls?: any): Promise<AiMessage> {
const result = await query<AiMessage>(
`INSERT INTO ai_agent_messages (conversation_id, role, content, token_count, tool_calls)
VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING *`,
[conversationId, role, content, tokenCount, toolCalls ? JSON.stringify(toolCalls) : null]
);
// 대화 통계 업데이트
await query(
`UPDATE ai_agent_conversations
SET message_count = message_count + 1, total_tokens = total_tokens + $1, updated_at = NOW()
WHERE id = $2`,
[tokenCount, conversationId]
);
return result[0];
}
static async delete(id: number): Promise<boolean> {
await query("DELETE FROM ai_agent_conversations WHERE id = $1", [id]);
return true;
}
}
@@ -0,0 +1,185 @@
import { query, queryOne, transaction } from "../database/db";
import { logger } from "../utils/logger";
export interface AgentGroup {
id: number;
group_id: string;
name: string;
description?: string;
status: string;
company_code?: string;
created_by: string;
created_at: string;
updated_at: string;
members?: GroupMember[];
}
export interface GroupMember {
id: number;
group_id: number;
agent_id: number;
role_name: string;
connectors: ConnectorRef[];
execution_order: number;
config: Record<string, any>;
// JOIN 필드
agent_name?: string;
agent_model?: string;
}
export interface ConnectorRef {
type: "database" | "rest_api" | "crawler" | "file" | "plc";
connection_id?: number;
config_id?: number;
path?: string;
name: string;
}
export class AiAgentGroupService {
static async list(companyCode?: string): Promise<AgentGroup[]> {
const groups = await query<AgentGroup>(
`SELECT g.*,
(SELECT COUNT(*) FROM ai_agent_group_members WHERE group_id = g.id) as member_count
FROM ai_agent_groups g
WHERE g.status != 'archived'
${companyCode ? "AND (g.company_code = $1 OR g.company_code IS NULL)" : ""}
ORDER BY g.created_at DESC`,
companyCode ? [companyCode] : []
);
return groups;
}
static async getById(id: number): Promise<AgentGroup | null> {
const group = await queryOne<AgentGroup>(
"SELECT * FROM ai_agent_groups WHERE id = $1",
[id]
);
if (!group) return null;
const members = await query<GroupMember>(
`SELECT m.*, a.name as agent_name, a.model as agent_model
FROM ai_agent_group_members m
LEFT JOIN ai_agents a ON m.agent_id = a.id
WHERE m.group_id = $1
ORDER BY m.execution_order`,
[id]
);
group.members = members;
return group;
}
static async create(data: { name: string; description?: string; company_code?: string; execution_mode?: string }, userId: string): Promise<AgentGroup> {
const groupId = `group-${Date.now().toString(36)}`;
const result = await query<AgentGroup>(
`INSERT INTO ai_agent_groups (group_id, name, description, company_code, created_by, execution_mode)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[groupId, data.name, data.description || null, data.company_code || null, userId, data.execution_mode || "mixed"]
);
logger.info(`멀티 에이전트 그룹 생성: ${data.name} (by ${userId})`);
return result[0];
}
static async update(id: number, data: { name?: string; description?: string; status?: string; execution_mode?: string }): Promise<AgentGroup | null> {
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); }
if (data.execution_mode !== undefined) { sets.push(`execution_mode = $${idx++}`); params.push(data.execution_mode); }
if (sets.length === 0) return this.getById(id);
sets.push(`updated_at = NOW()`);
params.push(id);
const result = await query<AgentGroup>(
`UPDATE ai_agent_groups SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
params
);
return result[0] || null;
}
static async delete(id: number): Promise<boolean> {
await query("UPDATE ai_agent_groups SET status = 'archived', updated_at = NOW() WHERE id = $1", [id]);
return true;
}
// ===== 멤버 관리 =====
static async addMember(groupId: number, data: {
agent_id: number;
role_name: string;
connectors?: ConnectorRef[];
execution_order?: number;
}): Promise<GroupMember> {
const result = await query<GroupMember>(
`INSERT INTO ai_agent_group_members (group_id, agent_id, role_name, connectors, execution_order)
VALUES ($1, $2, $3, $4::jsonb, $5) RETURNING *`,
[groupId, data.agent_id, data.role_name, JSON.stringify(data.connectors || []), data.execution_order || 1]
);
await query("UPDATE ai_agent_groups SET updated_at = NOW() WHERE id = $1", [groupId]);
return result[0];
}
static async updateMember(memberId: number, data: {
role_name?: string;
connectors?: ConnectorRef[];
execution_order?: number;
}): Promise<GroupMember | null> {
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (data.role_name !== undefined) { sets.push(`role_name = $${idx++}`); params.push(data.role_name); }
if (data.connectors !== undefined) { sets.push(`connectors = $${idx++}::jsonb`); params.push(JSON.stringify(data.connectors)); }
if (data.execution_order !== undefined) { sets.push(`execution_order = $${idx++}`); params.push(data.execution_order); }
if (sets.length === 0) return null;
params.push(memberId);
const result = await query<GroupMember>(
`UPDATE ai_agent_group_members SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
params
);
return result[0] || null;
}
static async removeMember(memberId: number): Promise<boolean> {
await query("DELETE FROM ai_agent_group_members WHERE id = $1", [memberId]);
return true;
}
// ===== 사용 가능한 커넥터 목록 =====
static async getAvailableConnectors(): Promise<any[]> {
// DB 연결
const dbConnections = await query(
"SELECT id as connection_id, connection_name as name, db_type, host, port, database_name, 'database' as type FROM external_db_connections WHERE is_active = 'Y' ORDER BY connection_name"
).catch(() => []);
// REST API 연결
const restConnections = await query(
"SELECT id as connection_id, connection_name as name, base_url, auth_type, 'rest_api' as type FROM external_rest_api_connections WHERE is_active = 'Y' ORDER BY connection_name"
).catch(() => []);
// 크롤링 설정
const crawlConfigs = await query(
"SELECT id as connection_id, name, url, 'crawler' as type FROM crawl_configs WHERE is_active = true ORDER BY name"
).catch(() => []);
// PLC / 장비 연결
const plcConnections = await query(
`SELECT id as connection_id, connection_name as name, host, port, protocol, status,
(SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = pipeline_device_connections.id AND is_active = 'Y') as tag_count,
'plc' as type
FROM pipeline_device_connections WHERE is_active = 'Y' ORDER BY connection_name`
).catch(() => []);
return [
...dbConnections.map((c: any) => ({ ...c, type: "database" })),
...restConnections.map((c: any) => ({ ...c, type: "rest_api" })),
...crawlConfigs.map((c: any) => ({ ...c, type: "crawler" })),
...plcConnections.map((c: any) => ({ ...c, type: "plc" })),
];
}
}
@@ -0,0 +1,95 @@
import { query, queryOne } from "../database/db";
import { AiLlmProvider, CreateProviderRequest } from "../types/aiAgent";
import { EncryptUtil } from "../utils/encryptUtil";
import { logger } from "../utils/logger";
export class AiAgentProviderService {
static async list(): Promise<Omit<AiLlmProvider, "api_key_encrypted">[]> {
const providers = await query<AiLlmProvider>(
"SELECT * FROM ai_llm_providers ORDER BY priority, name"
);
// API 키는 마스킹해서 반환
return providers.map(({ api_key_encrypted, ...rest }) => ({
...rest,
api_key_masked: api_key_encrypted ? "****" + api_key_encrypted.slice(-4) : "",
})) as any;
}
static async getById(id: number): Promise<AiLlmProvider | null> {
return queryOne<AiLlmProvider>(
"SELECT * FROM ai_llm_providers WHERE id = $1",
[id]
);
}
static async create(data: CreateProviderRequest): Promise<AiLlmProvider> {
const encrypted = EncryptUtil.encrypt(data.api_key);
const result = await query<AiLlmProvider>(
`INSERT INTO ai_llm_providers (name, display_name, api_key_encrypted, model_name, endpoint, priority, max_tokens, temperature, cost_per_1k_input, cost_per_1k_output)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
data.name,
data.display_name,
encrypted,
data.model_name,
data.endpoint || null,
data.priority || 1,
data.max_tokens || 4096,
data.temperature || 0.7,
data.cost_per_1k_input || 0,
data.cost_per_1k_output || 0,
]
);
logger.info(`LLM 프로바이더 추가: ${data.name} (${data.model_name})`);
return result[0];
}
static async update(id: number, data: Partial<CreateProviderRequest> & { is_active?: boolean }): Promise<AiLlmProvider | null> {
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (data.display_name !== undefined) { sets.push(`display_name = $${idx++}`); params.push(data.display_name); }
if (data.api_key !== undefined) { sets.push(`api_key_encrypted = $${idx++}`); params.push(EncryptUtil.encrypt(data.api_key)); }
if (data.model_name !== undefined) { sets.push(`model_name = $${idx++}`); params.push(data.model_name); }
if (data.endpoint !== undefined) { sets.push(`endpoint = $${idx++}`); params.push(data.endpoint); }
if (data.priority !== undefined) { sets.push(`priority = $${idx++}`); params.push(data.priority); }
if (data.max_tokens !== undefined) { sets.push(`max_tokens = $${idx++}`); params.push(data.max_tokens); }
if (data.temperature !== undefined) { sets.push(`temperature = $${idx++}`); params.push(data.temperature); }
if (data.cost_per_1k_input !== undefined) { sets.push(`cost_per_1k_input = $${idx++}`); params.push(data.cost_per_1k_input); }
if (data.cost_per_1k_output !== undefined) { sets.push(`cost_per_1k_output = $${idx++}`); params.push(data.cost_per_1k_output); }
if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); }
if (sets.length === 0) return this.getById(id);
sets.push(`updated_at = NOW()`);
params.push(id);
const result = await query<AiLlmProvider>(
`UPDATE ai_llm_providers SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
params
);
return result[0] || null;
}
static async delete(id: number): Promise<boolean> {
await query("DELETE FROM ai_llm_providers WHERE id = $1", [id]);
return true;
}
static async getActiveProviders(): Promise<AiLlmProvider[]> {
return query<AiLlmProvider>(
"SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority"
);
}
static async getDecryptedKey(id: number): Promise<string | null> {
const provider = await this.getById(id);
if (!provider) return null;
return EncryptUtil.decrypt(provider.api_key_encrypted);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { query, queryOne, transaction } from "../database/db";
import { AiAgent, CreateAgentRequest, UpdateAgentRequest } from "../types/aiAgent";
import { logger } from "../utils/logger";
export class AiAgentService {
static async list(filters?: { status?: string; company_code?: string; search?: string }): Promise<AiAgent[]> {
let sql = "SELECT * FROM ai_agents WHERE 1=1";
const params: any[] = [];
let idx = 1;
if (filters?.status) {
sql += ` AND status = $${idx++}`;
params.push(filters.status);
}
if (filters?.company_code) {
sql += ` AND (company_code = $${idx++} OR company_code IS NULL)`;
params.push(filters.company_code);
}
if (filters?.search) {
sql += ` AND (name ILIKE $${idx} OR agent_id ILIKE $${idx} OR description ILIKE $${idx})`;
params.push(`%${filters.search}%`);
idx++;
}
sql += " ORDER BY created_at DESC";
return query<AiAgent>(sql, params);
}
static async getById(id: number): Promise<AiAgent | null> {
return queryOne<AiAgent>("SELECT * FROM ai_agents WHERE id = $1", [id]);
}
static async getByAgentId(agentId: string): Promise<AiAgent | null> {
return queryOne<AiAgent>("SELECT * FROM ai_agents WHERE agent_id = $1", [agentId]);
}
static async create(data: CreateAgentRequest, userId: string): Promise<AiAgent> {
const existing = await this.getByAgentId(data.agent_id);
if (existing) {
throw new Error("이미 존재하는 에이전트 ID입니다.");
}
const result = await query<AiAgent>(
`INSERT INTO ai_agents (agent_id, name, description, model, system_prompt, tools, config, company_code, created_by)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9)
RETURNING *`,
[
data.agent_id,
data.name,
data.description || null,
data.model || "claude-sonnet-4-20250514",
data.system_prompt || null,
JSON.stringify(data.tools || []),
JSON.stringify(data.config || {}),
data.company_code || null,
userId,
]
);
logger.info(`에이전트 생성: ${data.agent_id} (by ${userId})`);
return result[0];
}
static async update(id: number, data: UpdateAgentRequest): Promise<AiAgent | null> {
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
if (data.model !== undefined) { sets.push(`model = $${idx++}`); params.push(data.model); }
if (data.system_prompt !== undefined) { sets.push(`system_prompt = $${idx++}`); params.push(data.system_prompt); }
if (data.tools !== undefined) { sets.push(`tools = $${idx++}::jsonb`); params.push(JSON.stringify(data.tools)); }
if (data.config !== undefined) { sets.push(`config = $${idx++}::jsonb`); params.push(JSON.stringify(data.config)); }
if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); }
if (sets.length === 0) return this.getById(id);
sets.push(`updated_at = NOW()`);
params.push(id);
const result = await query<AiAgent>(
`UPDATE ai_agents SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
params
);
return result[0] || null;
}
static async delete(id: number): Promise<boolean> {
const result = await query(
"UPDATE ai_agents SET status = 'archived', updated_at = NOW() WHERE id = $1",
[id]
);
return true;
}
static async getActiveAgents(): Promise<AiAgent[]> {
return query<AiAgent>("SELECT * FROM ai_agents WHERE status = 'active' ORDER BY name");
}
}
@@ -0,0 +1,88 @@
import { query, queryOne } from "../database/db";
import { AiUsageLog, UsageSummary } from "../types/aiAgent";
import { logger } from "../utils/logger";
export class AiAgentUsageService {
static async log(data: Partial<AiUsageLog>): Promise<void> {
await query(
`INSERT INTO ai_agent_usage_logs (user_id, api_key_id, agent_id, conversation_id, provider_name, model_name, prompt_tokens, completion_tokens, total_tokens, cost_usd, response_time_ms, success, error_message, request_path, ip_address)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
[
data.user_id || null,
data.api_key_id || null,
data.agent_id || null,
data.conversation_id || null,
data.provider_name || null,
data.model_name || null,
data.prompt_tokens || 0,
data.completion_tokens || 0,
data.total_tokens || 0,
data.cost_usd || 0,
data.response_time_ms || null,
data.success !== false,
data.error_message || null,
data.request_path || null,
data.ip_address || null,
]
);
}
static async getSummary(): Promise<UsageSummary> {
const todayResult = await queryOne<{ tokens: string; requests: string; cost: string }>(
`SELECT COALESCE(SUM(total_tokens), 0) as tokens, COUNT(*) as requests, COALESCE(SUM(cost_usd), 0) as cost
FROM ai_agent_usage_logs WHERE created_at >= CURRENT_DATE`
);
const monthResult = await queryOne<{ tokens: string; requests: string; cost: string }>(
`SELECT COALESCE(SUM(total_tokens), 0) as tokens, COUNT(*) as requests, COALESCE(SUM(cost_usd), 0) as cost
FROM ai_agent_usage_logs WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE)`
);
const agentCount = await queryOne<{ cnt: string }>(
"SELECT COUNT(*) as cnt FROM ai_agents WHERE status = 'active'"
);
const keyCount = await queryOne<{ cnt: string }>(
"SELECT COUNT(*) as cnt FROM ai_agent_api_keys WHERE status = 'active'"
);
return {
today_tokens: parseInt(todayResult?.tokens || "0"),
today_requests: parseInt(todayResult?.requests || "0"),
today_cost: parseFloat(todayResult?.cost || "0"),
month_tokens: parseInt(monthResult?.tokens || "0"),
month_requests: parseInt(monthResult?.requests || "0"),
month_cost: parseFloat(monthResult?.cost || "0"),
active_agents: parseInt(agentCount?.cnt || "0"),
active_keys: parseInt(keyCount?.cnt || "0"),
};
}
static async getLogs(page: number = 1, limit: number = 20): Promise<{ logs: AiUsageLog[]; total: number }> {
const offset = (page - 1) * limit;
const totalResult = await queryOne<{ cnt: string }>(
"SELECT COUNT(*) as cnt FROM ai_agent_usage_logs"
);
const logs = await query<AiUsageLog>(
"SELECT * FROM ai_agent_usage_logs ORDER BY created_at DESC LIMIT $1 OFFSET $2",
[limit, offset]
);
return { logs, total: parseInt(totalResult?.cnt || "0") };
}
static async getDailyUsage(days: number = 30): Promise<any[]> {
return query(
`SELECT DATE(created_at) as date,
SUM(total_tokens) as tokens,
COUNT(*) as requests,
SUM(cost_usd) as cost
FROM ai_agent_usage_logs
WHERE created_at >= CURRENT_DATE - INTERVAL '${days} days'
GROUP BY DATE(created_at)
ORDER BY date`
);
}
}
@@ -0,0 +1,72 @@
import { query, queryOne } from "../database/db";
import { logger } from "../utils/logger";
export class AiAnalysisLogService {
/**
* 분석 결과 저장
*/
static async save(data: {
group_id?: number;
agent_id?: number;
schedule_id?: number;
execution_type?: string;
input_message: string;
analysis_result: string;
prediction?: any;
tokens_used?: number;
duration_ms?: number;
metadata?: any;
}): Promise<any> {
const result = await query(
`INSERT INTO ai_analysis_logs (group_id, agent_id, schedule_id, execution_type, input_message, analysis_result, prediction, tokens_used, duration_ms, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10::jsonb) RETURNING *`,
[
data.group_id || null,
data.agent_id || null,
data.schedule_id || null,
data.execution_type || "manual",
data.input_message,
data.analysis_result,
data.prediction ? JSON.stringify(data.prediction) : null,
data.tokens_used || 0,
data.duration_ms || 0,
JSON.stringify(data.metadata || {}),
]
);
return result[0];
}
/**
* 최근 이력 조회 (에이전트가 참고할 수 있도록)
*/
static async getRecentLogs(groupId: number, days: number = 30, limit: number = 20): Promise<any[]> {
return query(
`SELECT id, execution_type, input_message, analysis_result, prediction, actual_result, accuracy_score, created_at
FROM ai_analysis_logs
WHERE group_id = $1 AND created_at >= NOW() - INTERVAL '${days} days'
ORDER BY created_at DESC LIMIT $2`,
[groupId, limit]
);
}
/**
* 예측 정확도 업데이트 (실제 결과 입력 시)
*/
static async updateActualResult(logId: number, actualResult: any, accuracyScore: number): Promise<void> {
await query(
"UPDATE ai_analysis_logs SET actual_result = $1::jsonb, accuracy_score = $2 WHERE id = $3",
[JSON.stringify(actualResult), accuracyScore, logId]
);
}
/**
* 평균 예측 정확도 조회
*/
static async getAverageAccuracy(groupId: number): Promise<number> {
const result = await queryOne<{ avg: string }>(
"SELECT AVG(accuracy_score)::text as avg FROM ai_analysis_logs WHERE group_id = $1 AND accuracy_score IS NOT NULL",
[groupId]
);
return parseFloat(result?.avg || "0");
}
}
@@ -0,0 +1,185 @@
import cron from "node-cron";
import { query, queryOne } from "../database/db";
import { MultiAgentExecutionEngine } from "./multiAgentExecutionEngine";
import { logger } from "../utils/logger";
import axios from "axios";
interface ScheduleJob {
id: number;
cronTask: cron.ScheduledTask;
}
const activeJobs = new Map<number, ScheduleJob>();
export class AiSchedulerService {
/**
* 스케줄 CRUD
*/
static async list(): Promise<any[]> {
return query(
`SELECT s.*, g.name as group_name
FROM ai_agent_schedules s
LEFT JOIN ai_agent_groups g ON s.group_id = g.id
ORDER BY s.created_at DESC`
);
}
static async create(data: {
name: string;
group_id: number;
cron_expression: string;
input_message: string;
notification?: any;
}, userId: string): Promise<any> {
// cron 표현식 유효성 체크
if (!cron.validate(data.cron_expression)) {
throw new Error("유효하지 않은 cron 표현식입니다.");
}
const result = await query(
`INSERT INTO ai_agent_schedules (name, group_id, cron_expression, input_message, notification, created_by)
VALUES ($1, $2, $3, $4, $5::jsonb, $6) RETURNING *`,
[data.name, data.group_id, data.cron_expression, data.input_message, JSON.stringify(data.notification || {}), userId]
);
const schedule = result[0];
this.registerJob(schedule);
logger.info(`AI 스케줄 생성: ${data.name} (${data.cron_expression})`);
return schedule;
}
static async update(id: number, data: any): Promise<any> {
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
if (data.cron_expression !== undefined) {
if (!cron.validate(data.cron_expression)) throw new Error("유효하지 않은 cron 표현식입니다.");
sets.push(`cron_expression = $${idx++}`); params.push(data.cron_expression);
}
if (data.input_message !== undefined) { sets.push(`input_message = $${idx++}`); params.push(data.input_message); }
if (data.notification !== undefined) { sets.push(`notification = $${idx++}::jsonb`); params.push(JSON.stringify(data.notification)); }
if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); }
if (sets.length === 0) return null;
sets.push(`updated_at = NOW()`);
params.push(id);
const result = await query(`UPDATE ai_agent_schedules SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, params);
const schedule = result[0];
// 재등록
this.unregisterJob(id);
if (schedule?.is_active) this.registerJob(schedule);
return schedule;
}
static async delete(id: number): Promise<void> {
this.unregisterJob(id);
await query("DELETE FROM ai_agent_schedules WHERE id = $1", [id]);
}
/**
* 스케줄 작업 등록
*/
private static registerJob(schedule: any): void {
if (!schedule.is_active) return;
const task = cron.schedule(schedule.cron_expression, async () => {
logger.info(`AI 스케줄 실행: ${schedule.name} (ID: ${schedule.id})`);
try {
const result = await MultiAgentExecutionEngine.execute(
schedule.group_id,
schedule.input_message,
{ userId: schedule.created_by }
);
// 실행 기록 업데이트
await query(
"UPDATE ai_agent_schedules SET last_run_at = NOW(), run_count = run_count + 1 WHERE id = $1",
[schedule.id]
);
// 알림 발송
await this.sendNotification(schedule, result);
logger.info(`AI 스케줄 완료: ${schedule.name} - ${result.totalTokens} tokens`);
} catch (e: any) {
logger.error(`AI 스케줄 실패: ${schedule.name} - ${e.message}`);
}
});
activeJobs.set(schedule.id, { id: schedule.id, cronTask: task });
}
private static unregisterJob(id: number): void {
const job = activeJobs.get(id);
if (job) {
job.cronTask.stop();
activeJobs.delete(id);
}
}
/**
* 알림 발송
*/
private static async sendNotification(schedule: any, result: any): Promise<void> {
const notification = schedule.notification || {};
// 시스템 공지
if (notification.system_notice) {
try {
await query(
`INSERT INTO system_notice (title, content, type, is_active, created_by, created_at)
VALUES ($1, $2, 'info', true, 'AI_SCHEDULER', NOW())`,
[
`[AI] ${schedule.name} 실행 결과`,
result.finalSummary.substring(0, 2000),
]
);
} catch (e) { logger.warn("시스템 공지 저장 실패:", e); }
}
// 웹훅 (슬랙 등)
if (notification.webhook) {
try {
await axios.post(notification.webhook, {
text: `🤖 [${schedule.name}] 실행 완료\n\n${result.finalSummary.substring(0, 1000)}`,
}, { timeout: 10000 });
} catch (e) { logger.warn("웹훅 발송 실패:", e); }
}
// 이메일
if (notification.email && notification.email.length > 0) {
try {
// 기존 메일 발송 서비스 활용
const { mailSendSimpleService } = await import("./mailSendSimpleService");
for (const to of notification.email) {
await mailSendSimpleService.sendMail({
to,
subject: `[AI 분석] ${schedule.name} 실행 결과`,
html: `<h3>${schedule.name} 분석 결과</h3><pre>${result.finalSummary.substring(0, 3000)}</pre>`,
}).catch(() => {});
}
} catch (e) { logger.warn("이메일 발송 실패:", e); }
}
}
/**
* 서버 시작 시 활성 스케줄 모두 등록
*/
static async initializeSchedules(): Promise<void> {
try {
const schedules = await query("SELECT * FROM ai_agent_schedules WHERE is_active = true");
for (const schedule of schedules) {
this.registerJob(schedule);
}
logger.info(`AI 스케줄러 초기화: ${schedules.length}개 활성 스케줄`);
} catch (e) {
logger.warn("AI 스케줄러 초기화 실패:", e);
}
}
}
@@ -132,7 +132,7 @@ export class BatchSchedulerService {
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id}, type: ${config.execution_type || "mapping"})`);
// 상세 조회 (매핑 또는 노드플로우 정보가 없을 수 있음)
if (!config.execution_type || config.execution_type === "mapping") {
if (!config.execution_type || config.execution_type === "mapping" || config.execution_type === "rest_api_sync") {
if (!config.batch_mappings || config.batch_mappings.length === 0) {
const fullConfig = await BatchService.getBatchConfigById(config.id);
if (fullConfig.success && fullConfig.data) {
@@ -171,6 +171,15 @@ export class BatchSchedulerService {
if (config.execution_type === "node_flow") {
result = await this.executeNodeFlow(config);
} else if (config.execution_type === "ai_agent") {
result = await this.executeAiAgent(config);
} else if (config.execution_type === "rest_api_sync") {
// REST API 동기화 (mapping과 동일 로직이지만 타입 구분)
result = await this.executeBatchMappings(config);
} else if (config.execution_type === "device_collection") {
result = await this.executeDeviceCollection(config);
} else if (config.execution_type === "crawling") {
result = await this.executeCrawling(config);
} else {
result = await this.executeBatchMappings(config);
}
@@ -248,6 +257,137 @@ export class BatchSchedulerService {
};
}
/**
* AI 멀티 에이전트 실행 - MultiAgentExecutionEngine에 위임
*/
private static async executeAiAgent(config: any) {
const { MultiAgentExecutionEngine } = await import("./multiAgentExecutionEngine");
const { AiAnalysisLogService } = await import("./aiAnalysisLogService");
const groupId = config.node_flow_context?.ai_group_id || config.ai_group_id;
const inputMessage = config.node_flow_context?.ai_input_message || config.description || "분석을 실행해주세요";
if (!groupId) {
throw new Error("AI 에이전트 그룹 ID가 설정되지 않았습니다.");
}
logger.info(`AI 에이전트 실행: groupId=${groupId}, batch=${config.batch_name}`);
const result = await MultiAgentExecutionEngine.execute(groupId, inputMessage, {
userId: config.created_by || "batch_scheduler",
});
// 알림 발송 (notification 설정이 있으면)
const notification = config.node_flow_context?.notification;
if (notification) {
// 시스템 공지
if (notification.system_notice) {
try {
const { query: dbQuery } = await import("../database/db");
await dbQuery(
`INSERT INTO system_notice (title, content, type, is_active, created_by, created_at)
VALUES ($1, $2, 'info', true, 'AI_BATCH', NOW())`,
[`[AI] ${config.batch_name} 실행 결과`, result.finalSummary.substring(0, 2000)]
);
} catch { /* ignore */ }
}
// 웹훅
if (notification.webhook) {
try {
const axios = (await import("axios")).default;
await axios.post(notification.webhook, {
text: `🤖 [${config.batch_name}] 실행 완료\n${result.finalSummary.substring(0, 1000)}`,
}, { timeout: 10000 });
} catch { /* ignore */ }
}
}
return {
totalRecords: result.steps.length,
successRecords: result.steps.filter((s) => !s.response.startsWith("[실행 실패]")).length,
failedRecords: result.steps.filter((s) => s.response.startsWith("[실행 실패]")).length,
};
}
/**
* 장비 데이터 수집 실행 — 실제 PLC 통신
*/
private static async executeDeviceCollection(config: any) {
const context = config.node_flow_context || {};
const connectionId = context.device_connection_id;
if (!connectionId) {
throw new Error("장비 연결 ID가 설정되지 않았습니다.");
}
// DeviceCollectorService로 실제 PLC 데이터 수집
const { collectDevice } = await import("./collector/deviceCollectorService");
const result = await collectDevice(connectionId);
const tagCount = Object.keys(result.tags).length;
const successCount = Object.values(result.tags).filter(v => v !== null).length;
const failedCount = tagCount - successCount;
logger.info(
`장비 데이터 수집 완료: ${result.connectionName} (${result.protocol}) - ` +
`${successCount}/${tagCount}개 태그 | PLC: ${result.plcState}`
);
// 대상 테이블에 수집 결과 저장 (설정된 경우)
const targetTable = context.target_table;
if (targetTable) {
try {
await query(
`INSERT INTO ${targetTable} (connection_id, connection_name, collected_at, plc_state, tag_values, error_message)
VALUES ($1, $2, NOW(), $3, $4::jsonb, $5)`,
[connectionId, result.connectionName, result.plcState, JSON.stringify(result.tags), result.errorMessage]
);
} catch (err) {
logger.warn(`수집 결과 저장 실패 (${targetTable}): ${(err as Error).message}`);
}
}
return { totalRecords: tagCount, successRecords: successCount, failedRecords: failedCount };
}
/**
* 크롤링 실행
*/
private static async executeCrawling(config: any) {
const context = config.node_flow_context || {};
const configId = context.crawl_config_id;
if (!configId) {
throw new Error("크롤링 설정 ID가 지정되지 않았습니다.");
}
const crawlConfig = await query<any>("SELECT * FROM crawl_configs WHERE id = $1", [configId]);
if (!crawlConfig.length) throw new Error("크롤링 설정을 찾을 수 없습니다.");
const cfg = crawlConfig[0];
logger.info(`크롤링 실행: ${cfg.name} (${cfg.url})`);
// 간단한 HTTP GET으로 데이터 수집
try {
const axios = (await import("axios")).default;
const response = await axios.get(cfg.url, { timeout: 30000 });
const targetTable = context.target_table;
if (targetTable) {
// 결과를 지정된 테이블에 저장
await query(
`INSERT INTO ${targetTable} (url, content, status_code, crawled_at) VALUES ($1, $2, $3, NOW())`,
[cfg.url, typeof response.data === "string" ? response.data : JSON.stringify(response.data), response.status]
).catch(() => {});
}
return { totalRecords: 1, successRecords: 1, failedRecords: 0 };
} catch (e: any) {
logger.warn(`크롤링 실패: ${cfg.url} - ${e.message}`);
return { totalRecords: 1, successRecords: 0, failedRecords: 1 };
}
}
/**
* 배치 매핑 실행 (수동 실행과 동일한 로직)
*/
@@ -0,0 +1,324 @@
/**
* Device Collector Service
* - pipeline_device_connections + pipeline_tag_mappings 설정 기반
* - 프로토콜별 PLC 읽기 (XGT, Modbus 등)
* - 읽은 데이터 → MQTT 발행 + DB 저장
* - 오프라인 버퍼 (발행 실패 시 재시도)
*
* Python data-collector의 EdgeAgent + CollectorManager 포팅
*/
import { query } from "../../database/db";
import { logger } from "../../utils/logger";
import { XgtClient, getXgtClient, closeAllXgtConnections } from "./protocols/xgtClient";
import { ModbusClient } from "./protocols/modbusClient";
import type { XgtTagConfig, XgtReadResult } from "./protocols/xgtClient";
import type { ModbusTagConfig, ModbusReadResult } from "./protocols/modbusClient";
// ─── 타입 ──────────────────────────────────────────
interface DeviceConnection {
id: number;
connection_name: string;
protocol: string;
host: string;
port: number;
protocol_config: Record<string, unknown>;
polling_interval_ms: number;
timeout_ms: number;
retry_count: number;
status: string;
company_code: string;
}
interface TagMapping {
id: number;
connection_id: number;
tag_name: string;
tag_display_name: string | null;
tag_unit: string | null;
tag_data_type: string;
address: string;
address_type: string | null;
scale_factor: number;
offset_value: number;
min_value: number | null;
max_value: number | null;
}
export interface CollectedData {
connectionId: number;
connectionName: string;
protocol: string;
companyCode: string;
timestamp: string;
plcState: "connected" | "disconnected" | "error";
errorMessage: string | null;
tags: Record<string, number | boolean | string | null>;
}
// ─── 폴링 타이머 관리 ─────────────────────────────
const pollingTimers = new Map<number, NodeJS.Timeout>();
const clientCache = new Map<number, XgtClient | ModbusClient>();
// ─── 오프라인 버퍼 (메모리 기반, 추후 SQLite 확장 가능) ───
const retryQueue: CollectedData[] = [];
const MAX_RETRY_QUEUE = 10000;
// ─── MQTT 발행 (옵션) ─────────────────────────────
let mqttClient: { publish: (topic: string, message: string) => void } | null = null;
let mqttConfig: { brokerUrl: string; topic: string } | null = null;
export function setMqttPublisher(config: { brokerUrl: string; topic: string }, client: { publish: (topic: string, message: string) => void }) {
mqttConfig = config;
mqttClient = client;
logger.info(`[Collector] MQTT 퍼블리셔 설정: ${config.brokerUrl}${config.topic}`);
}
// ─── 태그 매핑 → 프로토콜 태그 변환 ────────────────
function toXgtTags(tags: TagMapping[]): XgtTagConfig[] {
return tags.map(t => ({
tagName: t.tag_name,
address: t.address,
dataType: mapDataType(t.tag_data_type),
scaleFactor: t.scale_factor ?? 1,
offsetValue: t.offset_value ?? 0,
}));
}
function toModbusTags(tags: TagMapping[]): ModbusTagConfig[] {
return tags.map(t => ({
tagName: t.tag_name,
address: t.address,
dataType: t.tag_data_type as ModbusTagConfig["dataType"],
scaleFactor: t.scale_factor ?? 1,
offsetValue: t.offset_value ?? 0,
}));
}
function mapDataType(dt: string): "BOOL" | "INT16" | "UINT16" | "INT32" | "UINT32" | "FLOAT32" {
switch (dt.toUpperCase()) {
case "BOOLEAN": return "BOOL";
case "INT16": return "INT16";
case "INT32": return "INT32";
case "FLOAT32": return "FLOAT32";
case "FLOAT64": return "FLOAT32"; // Node.js에서는 FLOAT32로 처리
default: return "INT16";
}
}
// ─── 단일 디바이스 수집 실행 ──────────────────────
export async function collectDevice(connectionId: number): Promise<CollectedData> {
// DB에서 연결 + 태그 조회
const connections = await query<DeviceConnection>(
"SELECT * FROM pipeline_device_connections WHERE id = $1",
[connectionId]
);
if (!connections.length) throw new Error(`연결 ID ${connectionId}를 찾을 수 없습니다.`);
const device = connections[0];
const tags = await query<TagMapping>(
"SELECT * FROM pipeline_tag_mappings WHERE connection_id = $1 AND is_active = 'Y' ORDER BY tag_name",
[connectionId]
);
if (!tags.length) throw new Error(`태그가 없습니다 (connection_id=${connectionId})`);
const result: CollectedData = {
connectionId: device.id,
connectionName: device.connection_name,
protocol: device.protocol,
companyCode: device.company_code || "",
timestamp: new Date().toISOString(),
plcState: "disconnected",
errorMessage: null,
tags: {},
};
try {
switch (device.protocol) {
case "PLC_ETHERNET": {
// LS XGT FEnet
const xgtPort = device.port || 2004;
const client = getXgtClient(device.host, xgtPort, device.timeout_ms || 3000);
if (!client.isConnected()) await client.connect();
clientCache.set(device.id, client);
const xgtTags = toXgtTags(tags);
const readings = await client.readTags(xgtTags);
for (const r of readings) {
result.tags[r.tagName] = r.quality === "good" ? r.value : null;
}
result.plcState = "connected";
break;
}
case "MODBUS_TCP": {
const unitId = (device.protocol_config?.unit_id as number) || 1;
let client = clientCache.get(device.id) as ModbusClient;
if (!client || !client.isConnected()) {
client = new ModbusClient(device.host, device.port || 502, unitId, device.timeout_ms || 3000);
await client.connect();
clientCache.set(device.id, client);
}
const modbusTags = toModbusTags(tags);
const readings = await client.readTags(modbusTags);
for (const r of readings) {
result.tags[r.tagName] = r.quality === "good" ? r.value : null;
}
result.plcState = "connected";
break;
}
default:
throw new Error(`지원하지 않는 프로토콜: ${device.protocol}`);
}
// 연결 상태 업데이트
await query(
"UPDATE pipeline_device_connections SET status = 'active', last_test_date = NOW(), last_test_result = 'success', last_test_message = $1 WHERE id = $2",
[`수집 성공: ${Object.keys(result.tags).length}개 태그`, device.id]
).catch(() => {});
} catch (err) {
result.plcState = "error";
result.errorMessage = (err as Error).message;
logger.error(`[Collector] 수집 실패 (${device.connection_name}): ${result.errorMessage}`);
await query(
"UPDATE pipeline_device_connections SET status = 'error', last_test_date = NOW(), last_test_result = 'failure', last_test_message = $1 WHERE id = $2",
[result.errorMessage, device.id]
).catch(() => {});
}
return result;
}
// ─── 수집 결과 발행 ───────────────────────────────
async function publishData(data: CollectedData): Promise<void> {
// 1. MQTT 발행
if (mqttClient && mqttConfig) {
try {
const topic = `${mqttConfig.topic}/${data.companyCode}/${data.connectionId}`;
mqttClient.publish(topic, JSON.stringify(data));
} catch (err) {
logger.warn(`[Collector] MQTT 발행 실패 — 재시도 큐에 추가`);
if (retryQueue.length < MAX_RETRY_QUEUE) retryQueue.push(data);
}
}
// 2. DB 저장 (collected_data 테이블이 있으면)
try {
await query(
`INSERT INTO pipeline_collected_data (connection_id, collected_at, plc_state, tag_values, error_message)
VALUES ($1, $2, $3, $4::jsonb, $5)
ON CONFLICT DO NOTHING`,
[data.connectionId, data.timestamp, data.plcState, JSON.stringify(data.tags), data.errorMessage]
);
} catch {
// 테이블이 없을 수 있음 — 무시
}
}
// ─── 폴링 시작/중지 ──────────────────────────────
export async function startPolling(connectionId: number): Promise<void> {
if (pollingTimers.has(connectionId)) {
logger.warn(`[Collector] 이미 폴링 중: connection_id=${connectionId}`);
return;
}
const connections = await query<DeviceConnection>(
"SELECT * FROM pipeline_device_connections WHERE id = $1 AND is_active = 'Y'",
[connectionId]
);
if (!connections.length) throw new Error(`활성 연결을 찾을 수 없습니다: ${connectionId}`);
const device = connections[0];
const interval = device.polling_interval_ms || 1000;
logger.info(`[Collector] 폴링 시작: ${device.connection_name} (${device.protocol}, ${interval}ms 간격)`);
// 즉시 한 번 실행
const data = await collectDevice(connectionId);
await publishData(data);
// 주기적 폴링
const timer = setInterval(async () => {
try {
const collected = await collectDevice(connectionId);
await publishData(collected);
} catch (err) {
logger.error(`[Collector] 폴링 에러 (${device.connection_name}): ${(err as Error).message}`);
}
}, interval);
pollingTimers.set(connectionId, timer);
}
export function stopPolling(connectionId: number): void {
const timer = pollingTimers.get(connectionId);
if (timer) {
clearInterval(timer);
pollingTimers.delete(connectionId);
logger.info(`[Collector] 폴링 중지: connection_id=${connectionId}`);
}
// 클라이언트 연결도 정리
const client = clientCache.get(connectionId);
if (client) {
client.disconnect();
clientCache.delete(connectionId);
}
}
export function stopAllPolling(): void {
for (const [id] of pollingTimers) {
stopPolling(id);
}
closeAllXgtConnections();
logger.info("[Collector] 모든 폴링 중지");
}
// ─── 활성 연결 전체 폴링 시작 ────────────────────
export async function startAllActivePolling(): Promise<number> {
const connections = await query<DeviceConnection>(
"SELECT * FROM pipeline_device_connections WHERE is_active = 'Y' AND status != 'error' ORDER BY id"
);
let started = 0;
for (const conn of connections) {
try {
await startPolling(conn.id);
started++;
} catch (err) {
logger.error(`[Collector] 폴링 시작 실패 (${conn.connection_name}): ${(err as Error).message}`);
}
}
logger.info(`[Collector] 전체 폴링 시작: ${started}/${connections.length}개 연결`);
return started;
}
// ─── 상태 조회 ────────────────────────────────────
export function getPollingStatus(): { connectionId: number; active: boolean }[] {
const result: { connectionId: number; active: boolean }[] = [];
for (const [id] of pollingTimers) {
result.push({ connectionId: id, active: true });
}
return result;
}
export function getRetryQueueSize(): number {
return retryQueue.length;
}
@@ -0,0 +1,283 @@
/**
* Modbus TCP Client
* - 순수 TCP 소켓 기반 (외부 의존성 없음)
* - Python data-collector의 modbus_collector.py 포팅
*
* Modbus TCP 프레임:
* [0-1] Transaction ID
* [2-3] Protocol ID (0x0000)
* [4-5] Length (Unit ID + PDU)
* [6] Unit ID (slave)
* [7] Function Code (0x03=Holding, 0x04=Input)
* [8-9] Start Address
* [10-11] Quantity
*/
import net from "net";
import { logger } from "../../../utils/logger";
// ─── 타입 ──────────────────────────────────────────
export interface ModbusReadResult {
tagName: string;
address: string;
rawValue: number;
value: number | boolean;
quality: "good" | "bad";
timestamp: Date;
}
export interface ModbusTagConfig {
tagName: string;
address: string; // 예: HR100, IR200 (Holding Register 100, Input Register 200)
dataType: "UINT16" | "INT16" | "UINT32" | "INT32" | "FLOAT32" | "FLOAT64" | "BOOLEAN";
byteOrder?: "BIG_ENDIAN" | "LITTLE_ENDIAN" | "BIG_ENDIAN_SWAP" | "LITTLE_ENDIAN_SWAP";
scaleFactor?: number;
offsetValue?: number;
}
// ─── 주소 파싱 ────────────────────────────────────
function parseModbusAddress(address: string): { functionCode: number; register: number } {
const match = address.match(/^(HR|IR|CO|DI)(\d+)$/i);
if (!match) {
// 숫자만 오면 Holding Register로 간주
const numMatch = address.match(/^(\d+)$/);
if (numMatch) return { functionCode: 0x03, register: parseInt(numMatch[1], 10) };
throw new Error(`잘못된 Modbus 주소: ${address}`);
}
const [, prefix, numStr] = match;
const register = parseInt(numStr, 10);
switch (prefix.toUpperCase()) {
case "HR": return { functionCode: 0x03, register }; // Holding Register (FC03)
case "IR": return { functionCode: 0x04, register }; // Input Register (FC04)
case "CO": return { functionCode: 0x01, register }; // Coil (FC01)
case "DI": return { functionCode: 0x02, register }; // Discrete Input (FC02)
default: return { functionCode: 0x03, register };
}
}
// ─── 레지스터 수 계산 ─────────────────────────────
function getRegisterCount(dataType: string): number {
switch (dataType) {
case "BOOLEAN":
case "UINT16":
case "INT16":
return 1;
case "UINT32":
case "INT32":
case "FLOAT32":
return 2;
case "FLOAT64":
return 4;
default:
return 1;
}
}
// ─── Modbus TCP 프레임 빌더 ───────────────────────
function buildReadRequest(transactionId: number, unitId: number, functionCode: number, startAddr: number, quantity: number): Buffer {
const buf = Buffer.alloc(12);
// MBAP Header
buf.writeUInt16BE(transactionId, 0); // Transaction ID
buf.writeUInt16BE(0x0000, 2); // Protocol ID
buf.writeUInt16BE(6, 4); // Length (Unit + FC + Addr + Qty)
buf.writeUInt8(unitId, 6); // Unit ID
// PDU
buf.writeUInt8(functionCode, 7); // Function Code
buf.writeUInt16BE(startAddr, 8); // Start Address
buf.writeUInt16BE(quantity, 10); // Quantity
return buf;
}
// ─── 응답 파싱 ────────────────────────────────────
function parseReadResponse(response: Buffer, expectedTxId: number): number[] {
if (response.length < 9) throw new Error("응답이 너무 짧음");
const txId = response.readUInt16BE(0);
if (txId !== expectedTxId) throw new Error(`Transaction ID 불일치: ${txId} != ${expectedTxId}`);
const fc = response.readUInt8(7);
// Error response (FC | 0x80)
if (fc & 0x80) {
const errorCode = response.readUInt8(8);
throw new Error(`Modbus 에러 FC=0x${fc.toString(16)} Code=${errorCode}`);
}
const byteCount = response.readUInt8(8);
const registers: number[] = [];
for (let i = 0; i < byteCount / 2; i++) {
if (9 + i * 2 + 2 <= response.length) {
registers.push(response.readUInt16BE(9 + i * 2));
}
}
return registers;
}
// ─── 데이터 타입 변환 ─────────────────────────────
function convertValue(registers: number[], dataType: string, byteOrder: string = "BIG_ENDIAN"): number | boolean {
if (registers.length === 0) return 0;
if (dataType === "BOOLEAN") return registers[0] !== 0;
if (dataType === "UINT16") return registers[0];
if (dataType === "INT16") {
const v = registers[0];
return v >= 0x8000 ? v - 0x10000 : v;
}
// 32bit: byte order 처리
if (registers.length >= 2 && (dataType === "UINT32" || dataType === "INT32" || dataType === "FLOAT32")) {
let r0 = registers[0], r1 = registers[1];
// Byte order swap
if (byteOrder === "LITTLE_ENDIAN" || byteOrder === "LITTLE_ENDIAN_SWAP") {
[r0, r1] = [r1, r0];
}
const buf = Buffer.alloc(4);
if (byteOrder === "BIG_ENDIAN_SWAP" || byteOrder === "LITTLE_ENDIAN_SWAP") {
// Word swap: ABCD → CDAB
buf.writeUInt16BE(r1, 0);
buf.writeUInt16BE(r0, 2);
} else {
buf.writeUInt16BE(r0, 0);
buf.writeUInt16BE(r1, 2);
}
if (dataType === "FLOAT32") return buf.readFloatBE(0);
if (dataType === "UINT32") return buf.readUInt32BE(0);
if (dataType === "INT32") return buf.readInt32BE(0);
}
return registers[0];
}
// ─── Modbus TCP 클라이언트 ────────────────────────
export class ModbusClient {
private host: string;
private port: number;
private unitId: number;
private timeout: number;
private socket: net.Socket | null = null;
private connected = false;
private transactionId = 0;
constructor(host: string, port: number = 502, unitId: number = 1, timeout: number = 3000) {
this.host = host;
this.port = port;
this.unitId = unitId;
this.timeout = timeout;
}
async connect(): Promise<void> {
if (this.connected && this.socket) return;
return new Promise((resolve, reject) => {
this.socket = new net.Socket();
this.socket.setTimeout(this.timeout);
this.socket.connect(this.port, this.host, () => {
this.connected = true;
logger.info(`[Modbus] 연결 성공: ${this.host}:${this.port} (Unit ${this.unitId})`);
resolve();
});
this.socket.on("error", (err) => {
this.connected = false;
reject(new Error(`[Modbus] 연결 실패: ${err.message}`));
});
this.socket.on("timeout", () => {
this.socket?.destroy();
this.connected = false;
reject(new Error(`[Modbus] 타임아웃: ${this.timeout}ms`));
});
this.socket.on("close", () => { this.connected = false; });
});
}
disconnect(): void {
if (this.socket) {
this.socket.destroy();
this.socket = null;
this.connected = false;
}
}
isConnected(): boolean { return this.connected; }
private async rawRead(functionCode: number, startAddr: number, quantity: number): Promise<number[]> {
if (!this.socket || !this.connected) throw new Error("[Modbus] 연결되지 않음");
const txId = this.transactionId++ & 0xffff;
const frame = buildReadRequest(txId, this.unitId, functionCode, startAddr, quantity);
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
this.socket?.removeAllListeners("data");
reject(new Error("[Modbus] 읽기 타임아웃"));
}
}, this.timeout);
const onData = (data: Buffer) => {
chunks.push(data);
const response = Buffer.concat(chunks);
if (response.length >= 9 + quantity * 2) {
clearTimeout(timeout);
resolved = true;
this.socket?.removeListener("data", onData);
try {
resolve(parseReadResponse(response, txId));
} catch (e) { reject(e); }
}
};
this.socket!.on("data", onData);
this.socket!.write(frame);
});
}
async readTags(tags: ModbusTagConfig[]): Promise<ModbusReadResult[]> {
const results: ModbusReadResult[] = [];
for (const tag of tags) {
const now = new Date();
try {
const { functionCode, register } = parseModbusAddress(tag.address);
const count = getRegisterCount(tag.dataType);
const registers = await this.rawRead(functionCode, register, count);
const rawValue = registers[0] ?? 0;
let value = convertValue(registers, tag.dataType, tag.byteOrder);
if (typeof value === "number" && tag.scaleFactor !== undefined && tag.scaleFactor !== 1) {
value = value * tag.scaleFactor + (tag.offsetValue ?? 0);
}
results.push({ tagName: tag.tagName, address: tag.address, rawValue, value, quality: "good", timestamp: now });
} catch (err) {
logger.warn(`[Modbus] 태그 읽기 실패: ${tag.tagName} (${tag.address}) - ${(err as Error).message}`);
results.push({ tagName: tag.tagName, address: tag.address, rawValue: 0, value: 0, quality: "bad", timestamp: now });
}
}
return results;
}
}
@@ -0,0 +1,392 @@
/**
* LS XGT FEnet Protocol Client
* - LS Electric PLC (XGK/XGI/XGR) 통신
* - Python data-collector의 xgt_collector.py 포팅
*
* 프레임 구조 (20byte header + application data):
* [0-3] Company ID: "LSIS" (0x4C534953)
* [4-5] Reserved
* [6-7] PLC Info
* [8] CPU Info: 0xA0 (XGK)
* [9] Source: 0x33 (PC→PLC)
* [10-11] Invoke ID
* [12-13] Data Length (little-endian)
* [14] Station No (0x00)
* [15] Network No (0x00)
* [16-17] Data Length repeated
* [18-19] Reserved
*
* Command: 0x0054 = Read, 0x0058 = Write
*/
import net from "net";
import { logger } from "../../../utils/logger";
// ─── 타입 ──────────────────────────────────────────
export interface XgtReadResult {
tagName: string;
address: string;
rawValue: number;
value: number | boolean | string;
quality: "good" | "bad";
timestamp: Date;
}
export interface XgtTagConfig {
tagName: string;
address: string; // 예: D100, M0, K100
dataType: "BOOL" | "INT16" | "UINT16" | "INT32" | "UINT32" | "FLOAT32";
bitIndex?: number; // BOOL일 때 비트 위치 (0-15)
scaleFactor?: number;
offsetValue?: number;
}
// ─── XGT 메모리 영역 ──────────────────────────────
const MEMORY_TYPES: Record<string, string> = {
P: "%PW", // Input
M: "%MW", // Auxiliary relay
K: "%KW", // Keep relay
L: "%LW", // Link relay
F: "%FW", // Special relay
T: "%TW", // Timer
C: "%CW", // Counter
D: "%DW", // Data register
R: "%RW", // Retain
};
// ─── 주소 파싱 ────────────────────────────────────
function parseAddress(address: string): { memType: string; xgtAddress: string; offset: number } {
// D100, M0, K100, D100.5 (bit) 등 파싱
const match = address.match(/^([A-Z])(\d+)(?:\.(\d+))?$/);
if (!match) throw new Error(`잘못된 XGT 주소: ${address}`);
const [, memLetter, numStr] = match;
const num = parseInt(numStr, 10);
const prefix = MEMORY_TYPES[memLetter];
if (!prefix) throw new Error(`지원하지 않는 메모리 영역: ${memLetter}`);
// XGT는 %DW 뒤에 주소를 바이트 오프셋이 아닌 워드 번호로 씀
// D100 → %DW100 (워드 100번)
const xgtAddress = `${prefix}${String(num).padStart(5, "0")}`;
return { memType: memLetter, xgtAddress, offset: num };
}
// ─── XGT FEnet 프레임 빌더 ────────────────────────
function buildReadFrame(xgtAddress: string, wordCount: number, invokeId: number = 0): Buffer {
// Application data
const addrBytes = Buffer.from(xgtAddress, "ascii");
const addrLen = addrBytes.length;
// App data: command(2) + dataType(2) + reserved(2) + blockCount(2) + addrLen(2) + addr + readCount(2)
const appDataLen = 2 + 2 + 2 + 2 + 2 + addrLen + 2;
const appData = Buffer.alloc(appDataLen);
let offset = 0;
// Command: 0x0054 = Read request
appData.writeUInt16LE(0x0054, offset); offset += 2;
// Data type: 0x0014 = Word (continuous)
appData.writeUInt16LE(0x0014, offset); offset += 2;
// Reserved
appData.writeUInt16LE(0x0000, offset); offset += 2;
// Block count: 1
appData.writeUInt16LE(0x0001, offset); offset += 2;
// Address string length
appData.writeUInt16LE(addrLen, offset); offset += 2;
// Address string
addrBytes.copy(appData, offset); offset += addrLen;
// Read count (words)
appData.writeUInt16LE(wordCount, offset); offset += 2;
// Header (20 bytes)
const header = Buffer.alloc(20);
// Company ID: "LSIS"
header.write("LSIS", 0, 4, "ascii");
// Reserved (4-5)
header.writeUInt16LE(0x0000, 4);
// PLC Info (6-7)
header.writeUInt16LE(0x0000, 6);
// CPU Info: XGK
header.writeUInt8(0xa0, 8);
// Source: 0x33 (PC → PLC)
header.writeUInt8(0x33, 9);
// Invoke ID
header.writeUInt16LE(invokeId & 0xffff, 10);
// Data length
header.writeUInt16LE(appDataLen, 12);
// Station No
header.writeUInt8(0x00, 14);
// Network No
header.writeUInt8(0x00, 15);
// Data length (repeated)
header.writeUInt16LE(appDataLen, 16);
// Reserved
header.writeUInt16LE(0x0000, 18);
return Buffer.concat([header, appData]);
}
// ─── 응답 파싱 ────────────────────────────────────
function parseReadResponse(response: Buffer): number[] {
if (response.length < 20) throw new Error("응답이 너무 짧음");
// Header 확인
const companyId = response.toString("ascii", 0, 4);
if (companyId !== "LSIS") throw new Error(`잘못된 응답 헤더: ${companyId}`);
// Data length
const dataLen = response.readUInt16LE(12);
if (response.length < 20 + dataLen) throw new Error("응답 데이터 불완전");
// Application data 시작: offset 20
// Response: command(2) + dataType(2) + reserved(2) + errorState(2) + blockCount(2) + dataLen(2) + data...
const appOffset = 20;
// Error state 확인
const errorState = response.readUInt16LE(appOffset + 6);
if (errorState !== 0) throw new Error(`PLC 에러 코드: 0x${errorState.toString(16)}`);
// Block count
const blockCount = response.readUInt16LE(appOffset + 8);
if (blockCount === 0) return [];
// Data length (bytes)
const wordDataLen = response.readUInt16LE(appOffset + 10);
const wordCount = wordDataLen / 2;
// Word 데이터 읽기
const words: number[] = [];
const dataStart = appOffset + 12;
for (let i = 0; i < wordCount; i++) {
if (dataStart + i * 2 + 2 <= response.length) {
words.push(response.readUInt16LE(dataStart + i * 2));
}
}
return words;
}
// ─── 데이터 타입 변환 ─────────────────────────────
function convertValue(words: number[], dataType: string, bitIndex?: number): number | boolean {
if (words.length === 0) return 0;
switch (dataType) {
case "BOOL": {
const bit = bitIndex ?? 0;
return Boolean((words[0] >> bit) & 1);
}
case "UINT16":
return words[0];
case "INT16": {
const v = words[0];
return v >= 0x8000 ? v - 0x10000 : v;
}
case "UINT32": {
if (words.length < 2) return words[0];
return (words[1] << 16) | words[0]; // little-endian
}
case "INT32": {
if (words.length < 2) return words[0];
const v = (words[1] << 16) | words[0];
return v >= 0x80000000 ? v - 0x100000000 : v;
}
case "FLOAT32": {
if (words.length < 2) return 0;
const buf = Buffer.alloc(4);
buf.writeUInt16LE(words[0], 0);
buf.writeUInt16LE(words[1], 2);
return buf.readFloatLE(0);
}
default:
return words[0];
}
}
// ─── 워드 수 계산 ─────────────────────────────────
function getWordCount(dataType: string): number {
switch (dataType) {
case "BOOL":
case "UINT16":
case "INT16":
return 1;
case "UINT32":
case "INT32":
case "FLOAT32":
return 2;
case "FLOAT64":
return 4;
default:
return 1;
}
}
// ─── XGT 클라이언트 클래스 ────────────────────────
export class XgtClient {
private host: string;
private port: number;
private timeout: number;
private socket: net.Socket | null = null;
private connected = false;
private invokeId = 0;
constructor(host: string, port: number = 2004, timeout: number = 3000) {
this.host = host;
this.port = port;
this.timeout = timeout;
}
async connect(): Promise<void> {
if (this.connected && this.socket) return;
return new Promise((resolve, reject) => {
this.socket = new net.Socket();
this.socket.setTimeout(this.timeout);
this.socket.connect(this.port, this.host, () => {
this.connected = true;
logger.info(`[XGT] PLC 연결 성공: ${this.host}:${this.port}`);
resolve();
});
this.socket.on("error", (err) => {
this.connected = false;
reject(new Error(`[XGT] 연결 실패: ${err.message}`));
});
this.socket.on("timeout", () => {
this.socket?.destroy();
this.connected = false;
reject(new Error(`[XGT] 연결 타임아웃: ${this.timeout}ms`));
});
this.socket.on("close", () => {
this.connected = false;
});
});
}
disconnect(): void {
if (this.socket) {
this.socket.destroy();
this.socket = null;
this.connected = false;
}
}
isConnected(): boolean {
return this.connected;
}
// 단일 주소 읽기
private async rawRead(xgtAddress: string, wordCount: number): Promise<number[]> {
if (!this.socket || !this.connected) throw new Error("[XGT] 연결되지 않음");
const frame = buildReadFrame(xgtAddress, wordCount, this.invokeId++);
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
this.socket?.removeAllListeners("data");
reject(new Error(`[XGT] 읽기 타임아웃: ${xgtAddress}`));
}
}, this.timeout);
const onData = (data: Buffer) => {
chunks.push(data);
const response = Buffer.concat(chunks);
// 헤더(20) + 최소 응답 데이터(12) 이상 받았는지 확인
if (response.length >= 32) {
clearTimeout(timeout);
resolved = true;
this.socket?.removeListener("data", onData);
try {
const words = parseReadResponse(response);
resolve(words);
} catch (e) {
reject(e);
}
}
};
this.socket!.on("data", onData);
this.socket!.write(frame);
});
}
// 태그 배열 읽기 (배치 최적화)
async readTags(tags: XgtTagConfig[]): Promise<XgtReadResult[]> {
const results: XgtReadResult[] = [];
// 메모리 영역별 그루핑
const groups = new Map<string, XgtTagConfig[]>();
for (const tag of tags) {
const { memType } = parseAddress(tag.address);
const key = memType;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(tag);
}
// 그룹별 읽기
for (const [, groupTags] of groups) {
for (const tag of groupTags) {
const now = new Date();
try {
const { xgtAddress } = parseAddress(tag.address);
const wordCount = getWordCount(tag.dataType);
const words = await this.rawRead(xgtAddress, wordCount);
const rawValue = words[0] ?? 0;
let value = convertValue(words, tag.dataType, tag.bitIndex);
// 스케일링 적용
if (typeof value === "number" && tag.scaleFactor !== undefined && tag.scaleFactor !== 1) {
value = value * tag.scaleFactor + (tag.offsetValue ?? 0);
}
results.push({ tagName: tag.tagName, address: tag.address, rawValue, value, quality: "good", timestamp: now });
} catch (err) {
logger.warn(`[XGT] 태그 읽기 실패: ${tag.tagName} (${tag.address}) - ${(err as Error).message}`);
results.push({ tagName: tag.tagName, address: tag.address, rawValue: 0, value: 0, quality: "bad", timestamp: now });
}
}
}
return results;
}
// 단일 태그 읽기 (간편 API)
async readTag(tag: XgtTagConfig): Promise<XgtReadResult> {
const results = await this.readTags([tag]);
return results[0];
}
}
// ─── 커넥션 풀 (같은 IP:Port 재사용) ──────────────
const connectionPool = new Map<string, XgtClient>();
export function getXgtClient(host: string, port: number = 2004, timeout: number = 3000): XgtClient {
const key = `${host}:${port}`;
if (!connectionPool.has(key)) {
connectionPool.set(key, new XgtClient(host, port, timeout));
}
return connectionPool.get(key)!;
}
export function closeAllXgtConnections(): void {
for (const [key, client] of connectionPool) {
client.disconnect();
connectionPool.delete(key);
}
}
+390
View File
@@ -0,0 +1,390 @@
/**
* 자체 LLM 클라이언트
* DB에 등록된 프로바이더 설정(API 키, 엔드포인트)을 읽어 직접 호출
*
* 지원 프로바이더:
* - anthropic → Anthropic Messages API
* - openai → OpenAI Chat Completions API
* - google → Gemini OpenAI-compatible API
* - deepseek → DeepSeek (OpenAI-compatible)
* - ollama → Ollama (OpenAI-compatible, 로컬)
*/
import axios, { AxiosResponse } from "axios";
import { Readable } from "stream";
import { query } from "../database/db";
import { AiLlmProvider } from "../types/aiAgent";
import { EncryptUtil } from "../utils/encryptUtil";
import { logger } from "../utils/logger";
// ── 타입 ──────────────────────────────────────────
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
/** OpenAI 호환 응답 형식 (내부 표준) */
export interface ChatCompletionResponse {
id: string;
object: "chat.completion";
model: string;
choices: Array<{
index: number;
message: { role: "assistant"; content: string };
finish_reason: string;
}>;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface LlmRequestParams {
model: string;
messages: ChatMessage[];
max_tokens?: number;
temperature?: number;
stream?: boolean;
/** 특정 프로바이더 ID를 직접 지정 (모델명 자동매칭 대신) */
provider_id?: number;
}
// ── 프로바이더별 기본 엔드포인트 ──────────────────
const DEFAULT_ENDPOINTS: Record<string, string> = {
anthropic: "https://api.anthropic.com",
openai: "https://api.openai.com",
google: "https://generativelanguage.googleapis.com/v1beta/openai", // OpenAI-compatible
deepseek: "https://api.deepseek.com",
ollama: "http://localhost:11434",
};
// 모델명 → 프로바이더 매핑 (프리픽스)
const MODEL_PROVIDER_MAP: Array<[RegExp, string]> = [
[/^claude-/, "anthropic"],
[/^gpt-|^o[1-9]|^o3/, "openai"],
[/^gemini-/, "google"],
[/^deepseek-/, "deepseek"],
[/^llama|^mistral|^codellama|^phi|^qwen/, "ollama"],
];
// ── 메인 클라이언트 ──────────────────────────────
export class LlmClient {
/**
* 프로바이더를 DB에서 resolve
* 1) provider_id 직접 지정 → 해당 프로바이더
* 2) 모델명으로 프로바이더 이름 추론 → DB에서 해당 프로바이더 조회
* 3) 매칭 실패 → 우선순위 가장 높은 활성 프로바이더
*/
static async resolveProvider(
model: string,
providerId?: number
): Promise<{ provider: AiLlmProvider; apiKey: string }> {
let provider: AiLlmProvider | undefined;
if (providerId) {
const rows = await query<AiLlmProvider>(
"SELECT * FROM ai_llm_providers WHERE id = $1 AND is_active = true",
[providerId]
);
provider = rows[0];
}
if (!provider) {
// 모델명으로 프로바이더 이름 추론
let providerName: string | null = null;
for (const [re, name] of MODEL_PROVIDER_MAP) {
if (re.test(model)) { providerName = name; break; }
}
if (providerName) {
const rows = await query<AiLlmProvider>(
"SELECT * FROM ai_llm_providers WHERE name = $1 AND is_active = true ORDER BY priority LIMIT 1",
[providerName]
);
provider = rows[0];
}
}
if (!provider) {
// 폴백: 우선순위 가장 높은 활성 프로바이더
const rows = await query<AiLlmProvider>(
"SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority LIMIT 1"
);
provider = rows[0];
}
if (!provider) {
throw new Error("활성화된 LLM 프로바이더가 없습니다. 관리자 설정에서 프로바이더를 등록해주세요.");
}
const apiKey = EncryptUtil.decrypt(provider.api_key_encrypted);
return { provider, apiKey };
}
/**
* 채팅 완성 (비스트리밍)
*/
static async chatCompletion(params: LlmRequestParams): Promise<ChatCompletionResponse> {
const { provider, apiKey } = await this.resolveProvider(params.model, params.provider_id);
const model = params.model || provider.model_name;
logger.info(`[LLM] ${provider.name}/${model} 호출 (messages: ${params.messages.length})`);
if (provider.name === "anthropic") {
return this.callAnthropic(provider, apiKey, model, params);
}
// OpenAI / Google / DeepSeek / Ollama → 모두 OpenAI-compatible
return this.callOpenAICompatible(provider, apiKey, model, params);
}
/**
* 채팅 완성 (스트리밍) → Readable stream 반환 (SSE 형식)
*/
static async chatCompletionStream(params: LlmRequestParams): Promise<Readable> {
const { provider, apiKey } = await this.resolveProvider(params.model, params.provider_id);
const model = params.model || provider.model_name;
logger.info(`[LLM] ${provider.name}/${model} 스트리밍 호출`);
if (provider.name === "anthropic") {
return this.streamAnthropic(provider, apiKey, model, params);
}
return this.streamOpenAICompatible(provider, apiKey, model, params);
}
/**
* 사용 가능한 모델 목록 (DB 기반)
*/
static async listModels(): Promise<any> {
const providers = await query<AiLlmProvider>(
"SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority"
);
const models = providers.map((p) => ({
id: p.model_name,
object: "model",
owned_by: p.name,
display_name: p.display_name,
}));
return { object: "list", data: models };
}
// ── Anthropic Messages API ──────────────────────
private static async callAnthropic(
provider: AiLlmProvider,
apiKey: string,
model: string,
params: LlmRequestParams
): Promise<ChatCompletionResponse> {
const baseUrl = provider.endpoint || DEFAULT_ENDPOINTS.anthropic;
// Anthropic 형식: system은 별도 파라미터
const systemMsg = params.messages.find((m) => m.role === "system");
const nonSystemMsgs = params.messages.filter((m) => m.role !== "system");
const response = await axios.post(
`${baseUrl}/v1/messages`,
{
model,
system: systemMsg?.content || undefined,
messages: nonSystemMsgs.map((m) => ({ role: m.role, content: m.content })),
max_tokens: params.max_tokens || provider.max_tokens || 4096,
temperature: params.temperature ?? provider.temperature ?? 0.7,
},
{
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
timeout: 120000,
}
);
// Anthropic → OpenAI 형식 변환
const data = response.data;
return {
id: data.id || `chatcmpl-${Date.now()}`,
object: "chat.completion",
model: data.model || model,
choices: [
{
index: 0,
message: {
role: "assistant",
content: data.content?.map((c: any) => c.text).join("") || "",
},
finish_reason: data.stop_reason === "end_turn" ? "stop" : (data.stop_reason || "stop"),
},
],
usage: {
prompt_tokens: data.usage?.input_tokens || 0,
completion_tokens: data.usage?.output_tokens || 0,
total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
},
};
}
private static async streamAnthropic(
provider: AiLlmProvider,
apiKey: string,
model: string,
params: LlmRequestParams
): Promise<Readable> {
const baseUrl = provider.endpoint || DEFAULT_ENDPOINTS.anthropic;
const systemMsg = params.messages.find((m) => m.role === "system");
const nonSystemMsgs = params.messages.filter((m) => m.role !== "system");
const response: AxiosResponse<Readable> = await axios.post(
`${baseUrl}/v1/messages`,
{
model,
system: systemMsg?.content || undefined,
messages: nonSystemMsgs.map((m) => ({ role: m.role, content: m.content })),
max_tokens: params.max_tokens || provider.max_tokens || 4096,
temperature: params.temperature ?? provider.temperature ?? 0.7,
stream: true,
},
{
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
timeout: 120000,
responseType: "stream",
}
);
// Anthropic SSE → OpenAI SSE 변환 스트림
const transform = new Readable({ read() {} });
let buffer = "";
response.data.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") {
transform.push("data: [DONE]\n\n");
return;
}
try {
const event = JSON.parse(payload);
if (event.type === "content_block_delta" && event.delta?.text) {
const openaiChunk = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion.chunk",
model,
choices: [{ index: 0, delta: { content: event.delta.text }, finish_reason: null }],
};
transform.push(`data: ${JSON.stringify(openaiChunk)}\n\n`);
} else if (event.type === "message_stop") {
const stopChunk = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion.chunk",
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
};
transform.push(`data: ${JSON.stringify(stopChunk)}\n\n`);
transform.push("data: [DONE]\n\n");
}
} catch { /* 파싱 실패 무시 */ }
}
});
response.data.on("end", () => { transform.push(null); });
response.data.on("error", (err: Error) => { transform.destroy(err); });
return transform;
}
// ── OpenAI-compatible (OpenAI, Google, DeepSeek, Ollama) ──
private static getOpenAIBaseUrl(provider: AiLlmProvider): string {
if (provider.endpoint) return provider.endpoint;
return DEFAULT_ENDPOINTS[provider.name] || DEFAULT_ENDPOINTS.openai;
}
private static getOpenAIChatUrl(provider: AiLlmProvider): string {
const base = this.getOpenAIBaseUrl(provider);
// Google Gemini OpenAI-compatible 엔드포인트
if (provider.name === "google") {
return `${base}/chat/completions`;
}
// Ollama, DeepSeek, OpenAI → /v1/chat/completions
return `${base}/v1/chat/completions`;
}
private static async callOpenAICompatible(
provider: AiLlmProvider,
apiKey: string,
model: string,
params: LlmRequestParams
): Promise<ChatCompletionResponse> {
const url = this.getOpenAIChatUrl(provider);
const response = await axios.post(
url,
{
model,
messages: params.messages,
max_tokens: params.max_tokens || provider.max_tokens || 4096,
temperature: params.temperature ?? provider.temperature ?? 0.7,
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
timeout: 120000,
}
);
return response.data;
}
private static async streamOpenAICompatible(
provider: AiLlmProvider,
apiKey: string,
model: string,
params: LlmRequestParams
): Promise<Readable> {
const url = this.getOpenAIChatUrl(provider);
const response: AxiosResponse<Readable> = await axios.post(
url,
{
model,
messages: params.messages,
max_tokens: params.max_tokens || provider.max_tokens || 4096,
temperature: params.temperature ?? provider.temperature ?? 0.7,
stream: true,
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
timeout: 120000,
responseType: "stream",
}
);
// OpenAI-compatible → 그대로 전달
return response.data;
}
}
@@ -0,0 +1,478 @@
import { query, queryOne } from "../database/db";
import { AiAgentGroupService, GroupMember, ConnectorRef } from "./aiAgentGroupService";
import { AiAgentUsageService } from "./aiAgentUsageService";
import { AiAgentConversationService } from "./aiAgentConversationService";
import { AiAnalysisLogService } from "./aiAnalysisLogService";
import { LlmClient } from "./llmClient";
import { logger } from "../utils/logger";
interface ExecutionResult {
memberId: number;
roleName: string;
agentName: string;
modelName: string;
executionOrder: number;
response: string;
tokensUsed: number;
durationMs: number;
connectorResults?: any[];
}
interface GroupExecutionResult {
groupId: number;
groupName: string;
executionMode: string;
steps: ExecutionResult[];
finalSummary: string;
totalTokens: number;
totalDurationMs: number;
}
/**
* 멀티 에이전트 실행 엔진
* - sequential: 1→2→3 순차 실행, 이전 결과를 다음에 전달
* - parallel: 전체 동시 실행, 결과 취합
* - mixed: execution_order 같으면 병렬, 다르면 순차
*/
export class MultiAgentExecutionEngine {
/**
* 멀티 에이전트 그룹 실행
*/
static async execute(
groupId: number,
userMessage: string,
options?: { userId?: string; apiKeyId?: number }
): Promise<GroupExecutionResult> {
const group = await AiAgentGroupService.getById(groupId);
if (!group) throw new Error("멀티 에이전트 그룹을 찾을 수 없습니다.");
if (!group.members || group.members.length === 0) throw new Error("그룹에 에이전트가 없습니다.");
const executionMode = (group as any).execution_mode || "mixed";
const startTime = Date.now();
let allResults: ExecutionResult[] = [];
logger.info(`멀티 에이전트 실행 시작: ${group.name} (${executionMode}) - "${userMessage.substring(0, 50)}..."`);
// 과거 분석 이력 조회 (에이전트 컨텍스트에 추가)
let historyContext = "";
try {
const recentLogs = await AiAnalysisLogService.getRecentLogs(groupId, 30, 5);
if (recentLogs.length > 0) {
historyContext = "\n[과거 분석 이력 (최근 5건)]:\n" +
recentLogs.map((log: any) =>
`- ${new Date(log.created_at).toLocaleDateString("ko")}: ${log.analysis_result.substring(0, 200)}...`
).join("\n");
}
const accuracy = await AiAnalysisLogService.getAverageAccuracy(groupId);
if (accuracy > 0) {
historyContext += `\n평균 예측 정확도: ${accuracy.toFixed(1)}%`;
}
} catch { /* 이력 없으면 무시 */ }
// 이력 컨텍스트를 메시지에 추가
const enrichedMessage = historyContext
? `${userMessage}\n\n${historyContext}`
: userMessage;
if (executionMode === "parallel") {
allResults = await this.executeParallel(group.members, enrichedMessage, "");
} else if (executionMode === "sequential") {
allResults = await this.executeSequential(group.members, enrichedMessage);
} else {
allResults = await this.executeMixed(group.members, enrichedMessage);
}
// 최종 요약 생성
const finalSummary = this.buildFinalSummary(allResults, userMessage);
const totalTokens = allResults.reduce((sum, r) => sum + r.tokensUsed, 0);
const totalDuration = Date.now() - startTime;
// 대화 기록 저장 (에이전트 간 대화 모니터링용)
try {
const conv = await AiAgentConversationService.createConversation(
undefined,
options?.userId,
options?.apiKeyId
);
// 대화 제목 설정
await query(
"UPDATE ai_agent_conversations SET title = $1, metadata = $2 WHERE id = $3",
[
`[${group.name}] ${userMessage.substring(0, 100)}`,
JSON.stringify({ group_id: groupId, group_name: group.name, execution_mode: executionMode }),
conv.id,
]
);
// 사용자 메시지 저장
await AiAgentConversationService.addMessage(conv.id, "user", userMessage, 0);
// 각 에이전트 스텝별 응답 저장
for (const step of allResults) {
await AiAgentConversationService.addMessage(
conv.id,
"assistant",
`[${step.roleName} - ${step.agentName}]\n${step.response}`,
step.tokensUsed,
{ role_name: step.roleName, agent_name: step.agentName, model_name: step.modelName, execution_order: step.executionOrder, duration_ms: step.durationMs }
);
}
logger.info(`멀티 에이전트 대화 저장 완료: conv_id=${conv.id}`);
} catch (e) {
logger.warn("멀티 에이전트 대화 저장 실패:", e);
}
// 분석 이력 저장 (예측 진화용)
await AiAnalysisLogService.save({
group_id: groupId,
execution_type: options?.apiKeyId ? "api" : "manual",
input_message: userMessage,
analysis_result: finalSummary,
tokens_used: totalTokens,
duration_ms: totalDuration,
}).catch((e) => logger.warn("분석 이력 저장 실패:", e));
// 사용량 로깅
await AiAgentUsageService.log({
user_id: options?.userId,
api_key_id: options?.apiKeyId,
total_tokens: totalTokens,
response_time_ms: totalDuration,
success: true,
request_path: `/groups/${groupId}`,
});
logger.info(`멀티 에이전트 실행 완료: ${group.name} - ${totalTokens} tokens, ${totalDuration}ms`);
return {
groupId: group.id,
groupName: group.name,
executionMode,
steps: allResults,
finalSummary,
totalTokens,
totalDurationMs: totalDuration,
};
}
/**
* 순차 실행: 1→2→3, 이전 결과를 다음에 전달
*/
private static async executeSequential(
members: GroupMember[],
userMessage: string
): Promise<ExecutionResult[]> {
const sorted = [...members].sort((a, b) => a.execution_order - b.execution_order);
const results: ExecutionResult[] = [];
let previousContext = "";
for (const member of sorted) {
const result = await this.executeSingleAgent(member, userMessage, previousContext);
results.push(result);
previousContext += `\n[${member.role_name} 결과]:\n${result.response}\n`;
}
return results;
}
/**
* 병렬 실행: 동시 실행, 결과 취합
*/
private static async executeParallel(
members: GroupMember[],
userMessage: string,
previousContext: string
): Promise<ExecutionResult[]> {
const promises = members.map((member) =>
this.executeSingleAgent(member, userMessage, previousContext)
);
return Promise.all(promises);
}
/**
* 혼합 실행: execution_order 같으면 병렬, 다르면 순차
*/
private static async executeMixed(
members: GroupMember[],
userMessage: string
): Promise<ExecutionResult[]> {
// execution_order로 그룹핑
const orderGroups = new Map<number, GroupMember[]>();
for (const member of members) {
const order = member.execution_order;
if (!orderGroups.has(order)) orderGroups.set(order, []);
orderGroups.get(order)!.push(member);
}
const sortedOrders = [...orderGroups.keys()].sort((a, b) => a - b);
const allResults: ExecutionResult[] = [];
let previousContext = "";
for (const order of sortedOrders) {
const groupMembers = orderGroups.get(order)!;
if (groupMembers.length === 1) {
// 단독 → 순차
const result = await this.executeSingleAgent(groupMembers[0], userMessage, previousContext);
allResults.push(result);
previousContext += `\n[${groupMembers[0].role_name} 결과]:\n${result.response}\n`;
} else {
// 같은 order → 병렬
const parallelResults = await this.executeParallel(groupMembers, userMessage, previousContext);
allResults.push(...parallelResults);
for (const r of parallelResults) {
previousContext += `\n[${r.roleName} 결과]:\n${r.response}\n`;
}
}
}
return allResults;
}
/**
* 단일 에이전트 실행
*/
private static async executeSingleAgent(
member: GroupMember,
userMessage: string,
previousContext: string
): Promise<ExecutionResult> {
const startTime = Date.now();
// 에이전트 정보 조회
const agent = await queryOne<any>(
"SELECT * FROM ai_agents WHERE id = $1",
[member.agent_id]
);
if (!agent) {
return {
memberId: member.id,
roleName: member.role_name,
agentName: "알 수 없음",
modelName: "unknown",
executionOrder: member.execution_order,
response: "에이전트를 찾을 수 없습니다.",
tokensUsed: 0,
durationMs: Date.now() - startTime,
};
}
// 커넥터로 데이터 수집 (MCP 도구 시뮬레이션)
let connectorContext = "";
const connectorResults: any[] = [];
for (const connector of (member.connectors || [])) {
try {
const data = await this.executeConnector(connector);
connectorResults.push({ connector: connector.name, type: connector.type, data });
connectorContext += `\n[데이터 소스: ${connector.name} (${connector.type})]:\n${JSON.stringify(data).substring(0, 2000)}\n`;
} catch (e: any) {
connectorResults.push({ connector: connector.name, type: connector.type, error: e.message });
connectorContext += `\n[데이터 소스: ${connector.name}]: 조회 실패 - ${e.message}\n`;
}
}
// 지식 파일 주입 (커스텀 업로드 + 라이브러리 파일)
let knowledgeContext = "";
const knowledgeFiles = agent.config?.knowledge_files;
if (knowledgeFiles && Array.isArray(knowledgeFiles) && knowledgeFiles.length > 0) {
const resolvedFiles: Array<{ name: string; content: string }> = [];
for (const f of knowledgeFiles) {
if (f.library_id) {
// 라이브러리 파일: DB에서 최신 내용 조회
const libFile = await queryOne<any>(
"SELECT name, content FROM ai_knowledge_files WHERE id = $1",
[f.library_id]
);
if (libFile) resolvedFiles.push({ name: libFile.name, content: libFile.content });
} else if (f.content) {
// 커스텀 업로드: 저장된 내용 그대로 사용
resolvedFiles.push({ name: f.name, content: f.content });
}
}
if (resolvedFiles.length > 0) {
knowledgeContext = "\n[참고 지식 문서]:\n" +
resolvedFiles.map((f) => `--- ${f.name} ---\n${f.content.substring(0, 10000)}`).join("\n\n");
}
}
// LLM 호출
const systemPrompt = [
agent.system_prompt || "당신은 도움이 되는 AI 어시스턴트입니다.",
`\n당신의 역할: ${member.role_name}`,
knowledgeContext,
connectorContext ? `\n사용 가능한 데이터:\n${connectorContext}` : "",
previousContext ? `\n이전 에이전트들의 분석 결과:\n${previousContext}` : "",
].join("");
try {
const result = await LlmClient.chatCompletion({
model: agent.model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
max_tokens: agent.config?.max_tokens || 2000,
temperature: agent.config?.temperature || 0.7,
});
const choice = result.choices?.[0];
const usage = result.usage;
return {
memberId: member.id,
roleName: member.role_name,
agentName: agent.name,
modelName: agent.model,
executionOrder: member.execution_order,
response: choice?.message?.content || "응답 없음",
tokensUsed: usage?.total_tokens || 0,
durationMs: Date.now() - startTime,
connectorResults,
};
} catch (e: any) {
const errDetail = e.response?.data
? JSON.stringify(e.response.data, null, 2)
: e.message;
logger.warn(`에이전트 실행 실패 (${member.role_name}): ${errDetail}`);
return {
memberId: member.id,
roleName: member.role_name,
agentName: agent.name,
modelName: agent.model,
executionOrder: member.execution_order,
response: `[실행 실패] ${errDetail}`,
tokensUsed: 0,
durationMs: Date.now() - startTime,
connectorResults,
};
}
}
/**
* 커넥터 실행 (DB 쿼리, REST API 호출 등)
*/
private static async executeConnector(connector: ConnectorRef): Promise<any> {
if (connector.type === "database" && connector.connection_id) {
// 외부 DB 커넥션으로 샘플 데이터 조회
const conn = await queryOne<any>(
"SELECT * FROM external_db_connections WHERE id = $1",
[connector.connection_id]
);
if (!conn) return { error: "커넥션을 찾을 수 없습니다." };
// 커넥션 정보 반환 (실제 쿼리는 MCP 도구에서 수행)
return {
type: "database",
name: conn.connection_name,
db_type: conn.db_type,
database: conn.database_name,
status: conn.status,
info: `${conn.db_type} 데이터베이스 (${conn.host}:${conn.port}/${conn.database_name}) 연결 가능`,
};
}
if (connector.type === "rest_api" && connector.connection_id) {
const conn = await queryOne<any>(
"SELECT * FROM external_rest_api_connections WHERE id = $1",
[connector.connection_id]
);
if (!conn) return { error: "커넥션을 찾을 수 없습니다." };
return {
type: "rest_api",
name: conn.connection_name,
base_url: conn.base_url,
method: conn.method,
info: `REST API (${conn.method} ${conn.base_url}) 호출 가능`,
};
}
if (connector.type === "file" && connector.path) {
try {
const fs = require("fs");
const path = require("path");
const filePath = path.resolve(process.cwd(), connector.path);
if (!fs.existsSync(filePath)) {
return { type: "file", name: connector.name, error: "파일을 찾을 수 없습니다." };
}
const ext = path.extname(filePath).toLowerCase();
const content = fs.readFileSync(filePath, "utf8");
if (ext === ".csv") {
// CSV 파싱 (처음 50행)
const lines = content.split("\n").slice(0, 50);
return { type: "file", name: connector.name, format: "csv", rows: lines.length, preview: lines.join("\n") };
} else if (ext === ".json") {
const data = JSON.parse(content);
return { type: "file", name: connector.name, format: "json", data: JSON.stringify(data).substring(0, 3000) };
} else {
return { type: "file", name: connector.name, format: ext, preview: content.substring(0, 2000) };
}
} catch (e: any) {
return { type: "file", name: connector.name, error: e.message };
}
}
if (connector.type === "crawler" && connector.config_id) {
try {
// 기존 크롤링 서비스 활용
const crawlConfig = await queryOne<any>(
"SELECT * FROM crawl_configs WHERE id = $1",
[connector.config_id]
);
if (!crawlConfig) return { type: "crawler", name: connector.name, error: "크롤링 설정을 찾을 수 없습니다." };
return {
type: "crawler",
name: connector.name,
url: crawlConfig.url,
info: `크롤링 대상: ${crawlConfig.url} (${crawlConfig.name})`,
};
} catch (e: any) {
return { type: "crawler", name: connector.name, error: e.message };
}
}
if (connector.type === "plc" && connector.connection_id) {
const conn = await queryOne<any>(
"SELECT * FROM pipeline_device_connections WHERE id = $1",
[connector.connection_id]
);
if (!conn) return { type: "plc", name: connector.name, error: "장비 연결을 찾을 수 없습니다." };
const tags = await query<any>(
"SELECT tag_name, tag_display_name, tag_unit, tag_data_type, address FROM pipeline_tag_mappings WHERE connection_id = $1 AND is_active = 'Y' ORDER BY tag_name",
[connector.connection_id]
);
return {
type: "plc",
name: conn.connection_name,
protocol: conn.protocol,
host: conn.host,
port: conn.port,
status: conn.status,
tags: tags.map((t: any) => ({ name: t.tag_name, displayName: t.tag_display_name, unit: t.tag_unit, dataType: t.tag_data_type, address: t.address })),
info: `${conn.protocol} 장비 (${conn.host}:${conn.port}) - ${tags.length}개 태그 수집 중`,
};
} else if (connector.type === "plc") {
return { type: "plc", name: connector.name, info: "장비 연결 ID가 지정되지 않았습니다." };
}
return { type: connector.type, name: connector.name, info: "커넥터 연결 준비됨" };
}
/**
* 최종 결과 요약 생성
*/
private static buildFinalSummary(results: ExecutionResult[], originalQuestion: string): string {
const parts = results.map((r) =>
`[${r.roleName} (${r.agentName})]:\n${r.response}`
);
return `질문: ${originalQuestion}\n\n${parts.join("\n\n---\n\n")}`;
}
}
@@ -0,0 +1,141 @@
import fs from "fs";
import path from "path";
import os from "os";
import { query } from "../database/db";
import { AiAgent, AiLlmProvider } from "../types/aiAgent";
import { EncryptUtil } from "../utils/encryptUtil";
import { logger } from "../utils/logger";
const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json");
/**
* Pipeline DB → OpenClaw JSON config 동기화 서비스
* 에이전트/프로바이더 변경 시 OpenClaw config에 반영
*/
export class OpenClawSyncService {
private static readConfig(): any {
try {
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) return {};
const raw = fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8");
// JSON5 호환 (주석, trailing comma 허용)
return JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
} catch (e) {
logger.warn("OpenClaw config 읽기 실패:", e);
return {};
}
}
private static writeConfig(config: any): void {
try {
const dir = path.dirname(OPENCLAW_CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
logger.info("OpenClaw config 동기화 완료");
} catch (e) {
logger.error("OpenClaw config 쓰기 실패:", e);
}
}
/**
* 프로바이더(LLM API 키)를 OpenClaw auth profiles에 동기화
*/
static async syncProviders(): Promise<void> {
try {
const providers = await query<AiLlmProvider>(
"SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority"
);
const config = this.readConfig();
// auth profiles 구성
const authProfiles: Record<string, any> = {};
for (const p of providers) {
const decryptedKey = EncryptUtil.decrypt(p.api_key_encrypted);
const profileKey = `pipeline-${p.name}-${p.id}`;
if (p.name === "anthropic") {
authProfiles[profileKey] = {
provider: "anthropic",
apiKey: decryptedKey,
};
} else if (p.name === "openai") {
authProfiles[profileKey] = {
provider: "openai",
apiKey: decryptedKey,
};
} else if (p.name === "google") {
authProfiles[profileKey] = {
provider: "google",
apiKey: decryptedKey,
};
} else if (p.name === "deepseek") {
authProfiles[profileKey] = {
provider: "openai-compat",
apiKey: decryptedKey,
baseUrl: p.endpoint || "https://api.deepseek.com/v1",
};
} else if (p.name === "ollama") {
authProfiles[profileKey] = {
provider: "ollama",
baseUrl: p.endpoint || "http://localhost:11434",
};
}
}
config.authProfiles = authProfiles;
// 기본 모델 설정 (우선순위가 가장 높은 프로바이더)
if (providers.length > 0) {
const primary = providers[0];
const profileKey = `pipeline-${primary.name}-${primary.id}`;
config.models = config.models || {};
config.models.default = `${primary.name}:${primary.model_name}`;
config.models.authProfile = profileKey;
}
this.writeConfig(config);
logger.info(`OpenClaw 프로바이더 동기화: ${providers.length}`);
} catch (e) {
logger.error("OpenClaw 프로바이더 동기화 실패:", e);
}
}
/**
* 에이전트를 OpenClaw agents에 동기화
*/
static async syncAgents(): Promise<void> {
try {
const agents = await query<AiAgent>(
"SELECT * FROM ai_agents WHERE status = 'active' ORDER BY name"
);
const config = this.readConfig();
const clawAgents: Record<string, any> = {};
for (const agent of agents) {
clawAgents[agent.agent_id] = {
displayName: agent.name,
description: agent.description || "",
model: agent.model,
systemPrompt: agent.system_prompt || "",
tools: agent.tools || [],
...(agent.config || {}),
};
}
config.agents = clawAgents;
this.writeConfig(config);
logger.info(`OpenClaw 에이전트 동기화: ${agents.length}`);
} catch (e) {
logger.error("OpenClaw 에이전트 동기화 실패:", e);
}
}
/**
* 전체 동기화 (서버 시작 시)
*/
static async syncAll(): Promise<void> {
await this.syncProviders();
await this.syncAgents();
}
}
@@ -0,0 +1,280 @@
import { query, queryOne } from "../database/db";
import {
PipelineDeviceConnection,
PipelineDeviceConnectionFilter,
PipelineTagMapping,
DeviceConnectionTestResult,
} from "../types/pipelineDeviceTypes";
import { logger } from "../utils/logger";
import net from "net";
export class PipelineDeviceConnectionService {
// ===== 연결 CRUD =====
static async getConnections(
filter: PipelineDeviceConnectionFilter,
userCompanyCode?: string
) {
const whereConditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (userCompanyCode && userCompanyCode !== "*") {
whereConditions.push(`d.company_code = $${idx++}`);
params.push(userCompanyCode);
} else if (filter.company_code) {
whereConditions.push(`d.company_code = $${idx++}`);
params.push(filter.company_code);
}
if (filter.protocol) {
whereConditions.push(`d.protocol = $${idx++}`);
params.push(filter.protocol);
}
if (filter.is_active) {
whereConditions.push(`d.is_active = $${idx++}`);
params.push(filter.is_active);
}
if (filter.status) {
whereConditions.push(`d.status = $${idx++}`);
params.push(filter.status);
}
if (filter.search?.trim()) {
whereConditions.push(
`(d.connection_name ILIKE $${idx} OR d.description ILIKE $${idx})`
);
params.push(`%${filter.search.trim()}%`);
idx++;
}
const whereClause =
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
const connections = await query<any>(
`SELECT d.*,
(SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = d.id AND is_active = 'Y') as tag_count,
COALESCE(c.company_name, d.company_code) as company_name
FROM pipeline_device_connections d
LEFT JOIN company_mng c ON d.company_code = c.company_code
${whereClause}
ORDER BY d.is_active DESC, d.connection_name ASC`,
params
);
return { success: true, data: connections };
}
static async getConnectionById(id: number) {
const conn = await queryOne<any>(
`SELECT d.*,
(SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = d.id AND is_active = 'Y') as tag_count
FROM pipeline_device_connections d
WHERE d.id = $1`,
[id]
);
if (!conn) return { success: false, message: "연결을 찾을 수 없습니다." };
return { success: true, data: conn };
}
static async createConnection(data: Partial<PipelineDeviceConnection>) {
if (!data.connection_name || !data.protocol || !data.host || !data.port) {
return { success: false, message: "필수 필드가 누락되었습니다." };
}
const result = await query<PipelineDeviceConnection>(
`INSERT INTO pipeline_device_connections
(connection_name, description, protocol, host, port, protocol_config,
polling_interval_ms, timeout_ms, retry_count, status, company_code, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
data.connection_name,
data.description || null,
data.protocol,
data.host,
data.port,
JSON.stringify(data.protocol_config || {}),
data.polling_interval_ms || 1000,
data.timeout_ms || 5000,
data.retry_count || 3,
data.status || "active",
data.company_code || null,
data.is_active || "Y",
data.created_by || null,
]
);
return { success: true, data: result[0], message: "장비 연결이 생성되었습니다." };
}
static async updateConnection(id: number, data: Partial<PipelineDeviceConnection>) {
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (data.connection_name !== undefined) { sets.push(`connection_name = $${idx++}`); params.push(data.connection_name); }
if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
if (data.protocol !== undefined) { sets.push(`protocol = $${idx++}`); params.push(data.protocol); }
if (data.host !== undefined) { sets.push(`host = $${idx++}`); params.push(data.host); }
if (data.port !== undefined) { sets.push(`port = $${idx++}`); params.push(data.port); }
if (data.protocol_config !== undefined) { sets.push(`protocol_config = $${idx++}::jsonb`); params.push(JSON.stringify(data.protocol_config)); }
if (data.polling_interval_ms !== undefined) { sets.push(`polling_interval_ms = $${idx++}`); params.push(data.polling_interval_ms); }
if (data.timeout_ms !== undefined) { sets.push(`timeout_ms = $${idx++}`); params.push(data.timeout_ms); }
if (data.retry_count !== undefined) { sets.push(`retry_count = $${idx++}`); params.push(data.retry_count); }
if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); }
if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); }
if (sets.length === 0) return this.getConnectionById(id);
sets.push(`updated_at = NOW()`);
params.push(id);
const result = await query<PipelineDeviceConnection>(
`UPDATE pipeline_device_connections SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
params
);
if (!result[0]) return { success: false, message: "연결을 찾을 수 없습니다." };
return { success: true, data: result[0], message: "장비 연결이 수정되었습니다." };
}
static async deleteConnection(id: number) {
await query("DELETE FROM pipeline_device_connections WHERE id = $1", [id]);
return { success: true, message: "장비 연결이 삭제되었습니다." };
}
static async testConnection(id: number): Promise<DeviceConnectionTestResult> {
const connResult = await this.getConnectionById(id);
if (!connResult.success || !connResult.data) {
return { success: false, message: "연결을 찾을 수 없습니다." };
}
const conn = connResult.data;
const startTime = Date.now();
return new Promise((resolve) => {
const socket = new net.Socket();
const timeout = conn.timeout_ms || 5000;
socket.setTimeout(timeout);
socket.connect(conn.port, conn.host, async () => {
const elapsed = Date.now() - startTime;
socket.destroy();
await query(
"UPDATE pipeline_device_connections SET last_test_date = NOW(), last_test_result = 'success', last_test_message = $1 WHERE id = $2",
[`TCP 연결 성공 (${elapsed}ms)`, id]
);
resolve({
success: true,
message: `${conn.protocol} 연결 성공 (${elapsed}ms)`,
details: { response_time: elapsed, protocol: conn.protocol, host: conn.host, port: conn.port },
});
});
socket.on("error", async (err) => {
socket.destroy();
const msg = `연결 실패: ${err.message}`;
await query(
"UPDATE pipeline_device_connections SET last_test_date = NOW(), last_test_result = 'failure', last_test_message = $1, status = 'error' WHERE id = $2",
[msg, id]
).catch(() => {});
resolve({ success: false, message: msg, error: { code: (err as any).code, details: err.message } });
});
socket.on("timeout", async () => {
socket.destroy();
const msg = `연결 타임아웃 (${timeout}ms)`;
await query(
"UPDATE pipeline_device_connections SET last_test_date = NOW(), last_test_result = 'failure', last_test_message = $1 WHERE id = $2",
[msg, id]
).catch(() => {});
resolve({ success: false, message: msg, error: { code: "TIMEOUT", details: msg } });
});
});
}
// ===== 태그 매핑 CRUD =====
static async getTagMappings(connectionId: number) {
const tags = await query<PipelineTagMapping>(
"SELECT * FROM pipeline_tag_mappings WHERE connection_id = $1 ORDER BY tag_name",
[connectionId]
);
return { success: true, data: tags };
}
static async createTagMapping(connectionId: number, data: Partial<PipelineTagMapping>) {
if (!data.tag_name || !data.address) {
return { success: false, message: "태그명과 주소는 필수입니다." };
}
const result = await query<PipelineTagMapping>(
`INSERT INTO pipeline_tag_mappings
(connection_id, tag_name, tag_display_name, tag_unit, tag_data_type, address, address_type,
scale_factor, offset_value, min_value, max_value, description, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
connectionId,
data.tag_name,
data.tag_display_name || null,
data.tag_unit || null,
data.tag_data_type || "FLOAT32",
data.address,
data.address_type || null,
data.scale_factor ?? 1.0,
data.offset_value ?? 0.0,
data.min_value ?? null,
data.max_value ?? null,
data.description || null,
data.is_active || "Y",
]
);
return { success: true, data: result[0], message: "태그 매핑이 추가되었습니다." };
}
static async updateTagMapping(tagId: number, data: Partial<PipelineTagMapping>) {
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (data.tag_name !== undefined) { sets.push(`tag_name = $${idx++}`); params.push(data.tag_name); }
if (data.tag_display_name !== undefined) { sets.push(`tag_display_name = $${idx++}`); params.push(data.tag_display_name); }
if (data.tag_unit !== undefined) { sets.push(`tag_unit = $${idx++}`); params.push(data.tag_unit); }
if (data.tag_data_type !== undefined) { sets.push(`tag_data_type = $${idx++}`); params.push(data.tag_data_type); }
if (data.address !== undefined) { sets.push(`address = $${idx++}`); params.push(data.address); }
if (data.address_type !== undefined) { sets.push(`address_type = $${idx++}`); params.push(data.address_type); }
if (data.scale_factor !== undefined) { sets.push(`scale_factor = $${idx++}`); params.push(data.scale_factor); }
if (data.offset_value !== undefined) { sets.push(`offset_value = $${idx++}`); params.push(data.offset_value); }
if (data.min_value !== undefined) { sets.push(`min_value = $${idx++}`); params.push(data.min_value); }
if (data.max_value !== undefined) { sets.push(`max_value = $${idx++}`); params.push(data.max_value); }
if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); }
if (sets.length === 0) return { success: false, message: "변경할 내용이 없습니다." };
sets.push(`updated_at = NOW()`);
params.push(tagId);
const result = await query<PipelineTagMapping>(
`UPDATE pipeline_tag_mappings SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
params
);
if (!result[0]) return { success: false, message: "태그를 찾을 수 없습니다." };
return { success: true, data: result[0], message: "태그 매핑이 수정되었습니다." };
}
static async deleteTagMapping(tagId: number) {
await query("DELETE FROM pipeline_tag_mappings WHERE id = $1", [tagId]);
return { success: true, message: "태그 매핑이 삭제되었습니다." };
}
}
+153
View File
@@ -0,0 +1,153 @@
// AI 에이전트 관련 타입 정의
export interface AiAgent {
id: number;
agent_id: string;
name: string;
description?: string;
model: string;
system_prompt?: string;
tools: any[];
config: Record<string, any>;
status: "active" | "inactive" | "archived";
company_code?: string;
created_by: string;
created_at: string;
updated_at: string;
}
export interface CreateAgentRequest {
agent_id: string;
name: string;
description?: string;
model?: string;
system_prompt?: string;
tools?: any[];
config?: Record<string, any>;
company_code?: string;
}
export interface UpdateAgentRequest {
name?: string;
description?: string;
model?: string;
system_prompt?: string;
tools?: any[];
config?: Record<string, any>;
status?: "active" | "inactive" | "archived";
}
export interface AiAgentApiKey {
id: number;
name: string;
key_hash: string;
key_prefix: string;
user_id: string;
company_code?: string;
agent_id?: number;
permissions: string[];
rate_limit: number;
monthly_token_limit: number;
status: "active" | "revoked";
last_used_at?: string;
usage_count: number;
total_tokens: number;
expires_at?: string;
created_at: string;
}
export interface CreateApiKeyRequest {
name: string;
agent_id?: number;
permissions?: string[];
rate_limit?: number;
monthly_token_limit?: number;
expires_at?: string;
}
export interface AiConversation {
id: number;
conversation_id: string;
agent_id?: number;
user_id?: string;
api_key_id?: number;
title?: string;
message_count: number;
total_tokens: number;
status: string;
metadata: Record<string, any>;
created_at: string;
updated_at: string;
}
export interface AiMessage {
id: number;
conversation_id: number;
role: "system" | "user" | "assistant" | "tool";
content: string;
tool_calls?: any;
token_count: number;
created_at: string;
}
export interface AiUsageLog {
id: number;
user_id?: string;
api_key_id?: number;
agent_id?: number;
conversation_id?: number;
provider_name?: string;
model_name?: string;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
cost_usd: number;
response_time_ms?: number;
success: boolean;
error_message?: string;
request_path?: string;
ip_address?: string;
created_at: string;
}
export interface AiLlmProvider {
id: number;
name: string;
display_name: string;
api_key_encrypted: string;
model_name: string;
endpoint?: string;
priority: number;
max_tokens: number;
temperature: number;
cost_per_1k_input: number;
cost_per_1k_output: number;
is_active: boolean;
config: Record<string, any>;
created_at: string;
updated_at: string;
}
export interface CreateProviderRequest {
name: string;
display_name: string;
api_key: string;
model_name: string;
endpoint?: string;
priority?: number;
max_tokens?: number;
temperature?: number;
cost_per_1k_input?: number;
cost_per_1k_output?: number;
}
export interface UsageSummary {
today_tokens: number;
today_requests: number;
today_cost: number;
month_tokens: number;
month_requests: number;
month_cost: number;
active_agents: number;
active_keys: number;
}
@@ -0,0 +1,105 @@
// 파이프라인 장비 연결 관련 타입 정의
export interface PipelineDeviceConnection {
id?: number;
connection_name: string;
description?: string | null;
protocol: "PLC_ETHERNET" | "MODBUS_TCP" | "OPCUA" | "MQTT" | "REST_API";
host: string;
port: number;
protocol_config?: Record<string, unknown>;
polling_interval_ms?: number;
timeout_ms?: number;
retry_count?: number;
status?: "active" | "inactive" | "error";
company_code?: string;
is_active?: string;
last_test_date?: Date;
last_test_result?: string;
last_test_message?: string;
created_by?: string;
created_at?: Date;
updated_at?: Date;
// 조인 필드
tag_count?: number;
company_name?: string;
}
export interface PipelineTagMapping {
id?: number;
connection_id: number;
tag_name: string;
tag_display_name?: string | null;
tag_unit?: string | null;
tag_data_type: "INT16" | "INT32" | "FLOAT32" | "FLOAT64" | "BOOLEAN" | "STRING";
address: string;
address_type?: "WORD" | "DWORD" | "FLOAT" | "BIT" | "STRING" | null;
scale_factor?: number;
offset_value?: number;
min_value?: number | null;
max_value?: number | null;
description?: string | null;
is_active?: string;
created_at?: Date;
updated_at?: Date;
}
export interface PipelineDeviceConnectionFilter {
protocol?: string;
is_active?: string;
company_code?: string;
search?: string;
status?: string;
}
export interface DeviceConnectionTestResult {
success: boolean;
message: string;
details?: {
response_time?: number;
protocol?: string;
host?: string;
port?: number;
};
error?: {
code?: string;
details?: string;
};
}
// 프로토콜 옵션
export const PROTOCOL_OPTIONS = [
{ value: "PLC_ETHERNET", label: "PLC Ethernet (MC Protocol)" },
{ value: "MODBUS_TCP", label: "Modbus TCP" },
{ value: "OPCUA", label: "OPC-UA" },
{ value: "MQTT", label: "MQTT" },
{ value: "REST_API", label: "REST API" },
];
// 프로토콜별 기본 포트
export const PROTOCOL_DEFAULTS: Record<string, { port: number }> = {
PLC_ETHERNET: { port: 5000 },
MODBUS_TCP: { port: 502 },
OPCUA: { port: 4840 },
MQTT: { port: 1883 },
REST_API: { port: 443 },
};
// 태그 데이터 타입 옵션
export const TAG_DATA_TYPE_OPTIONS = [
{ value: "INT16", label: "INT16 (정수 16비트)" },
{ value: "INT32", label: "INT32 (정수 32비트)" },
{ value: "FLOAT32", label: "FLOAT32 (실수 32비트)" },
{ value: "FLOAT64", label: "FLOAT64 (실수 64비트)" },
{ value: "BOOLEAN", label: "BOOLEAN (불리언)" },
{ value: "STRING", label: "STRING (문자열)" },
];
// 주소 타입 옵션
export const ADDRESS_TYPE_OPTIONS = [
{ value: "WORD", label: "WORD" },
{ value: "DWORD", label: "DWORD" },
{ value: "FLOAT", label: "FLOAT" },
{ value: "BIT", label: "BIT" },
{ value: "STRING", label: "STRING" },
];
+107
View File
@@ -0,0 +1,107 @@
/**
* OpenClaw 멀티 에이전트 Gateway를 자식 프로세스로 기동
* - backend-node 서버 기동 시 함께 띄우고, 종료 시 함께 종료
* - OpenClaw이 설치되어 있지 않으면 스킵 (backend는 계속 동작)
*/
import { spawn, ChildProcess } from "child_process";
import { logger } from "./logger";
const OPENCLAW_PORT = process.env.OPENCLAW_GATEWAY_PORT || "18789";
const OPENCLAW_ENABLED = process.env.OPENCLAW_ENABLED !== "false";
let openClawProcess: ChildProcess | null = null;
/**
* OpenClaw Gateway 기동
*/
export function startOpenClaw(): void {
if (!OPENCLAW_ENABLED) {
logger.info("⏭️ OpenClaw Gateway 비활성화 (OPENCLAW_ENABLED=false)");
return;
}
try {
// openclaw CLI가 설치되어 있는지 확인
const which = require("child_process").execSync("which openclaw 2>/dev/null || where openclaw 2>nul", {
encoding: "utf8",
timeout: 5000,
}).trim();
if (!which) {
logger.info("⏭️ OpenClaw 스킵 (설치되지 않음)");
return;
}
} catch {
// npm global로 설치 안 됐으면 npx로 시도
logger.info("⏭️ OpenClaw CLI 미발견 → npx openclaw로 시도");
}
openClawProcess = spawn("npx", ["openclaw", "gateway", "--port", OPENCLAW_PORT], {
stdio: "pipe",
env: {
...process.env,
OPENCLAW_GATEWAY_PORT: OPENCLAW_PORT,
},
shell: true,
});
openClawProcess.stdout?.on("data", (data: Buffer) => {
const msg = data.toString().trim();
if (msg) logger.info(`[OpenClaw] ${msg}`);
});
openClawProcess.stderr?.on("data", (data: Buffer) => {
const msg = data.toString().trim();
if (msg) logger.warn(`[OpenClaw] ${msg}`);
});
openClawProcess.on("error", (err) => {
logger.warn(`⚠️ OpenClaw Gateway 프로세스 에러: ${err.message}`);
openClawProcess = null;
});
openClawProcess.on("exit", (code, signal) => {
openClawProcess = null;
if (code != null && code !== 0) {
logger.warn(`⚠️ OpenClaw Gateway 종료 (code=${code}, signal=${signal})`);
}
});
logger.info(`🤖 OpenClaw Gateway 기동 (포트 ${OPENCLAW_PORT})`);
}
/**
* OpenClaw Gateway 프로세스 종료
*/
export function stopOpenClaw(): void {
if (openClawProcess && openClawProcess.kill) {
openClawProcess.kill("SIGTERM");
openClawProcess = null;
logger.info("🤖 OpenClaw Gateway 프로세스 종료");
}
}
/**
* OpenClaw Gateway 상태 확인 (프로세스 또는 포트 체크)
*/
export async function isOpenClawRunning(): Promise<boolean> {
if (openClawProcess !== null && !openClawProcess.killed) return true;
// 외부에서 이미 실행 중인 경우도 체크
try {
const http = await import("http");
return new Promise((resolve) => {
const req = http.get(`http://127.0.0.1:${OPENCLAW_PORT}/healthz`, (res) => {
resolve(res.statusCode === 200);
});
req.on("error", () => resolve(false));
req.setTimeout(2000, () => { req.destroy(); resolve(false); });
});
} catch { return false; }
}
/**
* OpenClaw Gateway URL
*/
export function getOpenClawUrl(): string {
return `http://127.0.0.1:${OPENCLAW_PORT}`;
}
+2 -2
View File
@@ -32,7 +32,7 @@ services:
# ============================================
backend:
image: harbor.wace.me/speefox_vexplor/vexplor-backend:${IMAGE_TAG:-latest}
container_name: vexplor-backend
container_name: pipeline-backend
environment:
NODE_ENV: production
PORT: 3001
@@ -79,7 +79,7 @@ services:
# ============================================
frontend:
image: harbor.wace.me/speefox_vexplor/vexplor-frontend:${IMAGE_TAG:-latest}
container_name: vexplor-frontend
container_name: pipeline-front
environment:
NODE_ENV: production
PORT: 3000
+4 -4
View File
@@ -2,11 +2,11 @@ version: "3.8"
services:
# Node.js 백엔드
backend:
pipeline-backend:
build:
context: ./backend-node
dockerfile: Dockerfile.win
container_name: pms-backend-win
container_name: pipeline-backend
ports:
- "8080:8080"
environment:
@@ -24,7 +24,7 @@ services:
- /app/node_modules
- /app/dist
networks:
- pms-network
- pipeline-network
restart: unless-stopped
healthcheck:
test:
@@ -45,6 +45,6 @@ services:
start_period: 90s
networks:
pms-network:
pipeline-network:
driver: bridge
external: false
+4 -4
View File
@@ -2,11 +2,11 @@ version: "3.8"
services:
# Next.js 프론트엔드만
frontend:
pipeline-front:
build:
context: ./frontend
dockerfile: ../docker/dev/frontend.Dockerfile
container_name: pms-frontend-win
container_name: pipeline-front
ports:
- "9771:3000"
environment:
@@ -24,7 +24,7 @@ services:
- /app/node_modules
- /app/.next
networks:
- pms-network
- pipeline-network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000", "||", "exit", "1"]
@@ -34,6 +34,6 @@ services:
start_period: 60s
networks:
pms-network:
pipeline-network:
driver: bridge
external: false
+6 -6
View File
@@ -2,11 +2,11 @@ version: "3.8"
services:
# Node.js 백엔드
backend:
pipeline-backend:
build:
context: ../../backend-node
dockerfile: ../docker/deploy/backend.Dockerfile
container_name: pms-backend-prod
container_name: pipeline-backend
restart: always
environment:
NODE_ENV: production
@@ -36,19 +36,19 @@ services:
- traefik.http.services.backend.loadbalancer.server.port=3001
# Next.js 프론트엔드
frontend:
pipeline-front:
build:
context: ../../frontend
dockerfile: ../docker/deploy/frontend.Dockerfile
args:
- NEXT_PUBLIC_API_URL=https://api.vexplor.com/api
- SERVER_API_URL=http://backend:3001
container_name: pms-frontend-prod
- SERVER_API_URL=http://pipeline-backend:3001
container_name: pipeline-front
restart: always
environment:
NODE_ENV: production
NEXT_PUBLIC_API_URL: https://api.vexplor.com/api
SERVER_API_URL: "http://backend:3001"
SERVER_API_URL: "http://pipeline-backend:3001"
NEXT_TELEMETRY_DISABLED: "1"
PORT: "3000"
HOSTNAME: 0.0.0.0
+6 -15
View File
@@ -1,32 +1,23 @@
services:
# Node.js 백엔드
backend:
pipeline-backend:
build:
context: ../../backend-node
dockerfile: ../docker/dev/backend.Dockerfile
container_name: pms-backend-mac
container_name: pipeline-backend
env_file:
- ../../backend-node/.env
ports:
- "8080:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- NODE_ENV=development
- PORT=8080
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
- CORS_ORIGIN=http://localhost:9771
- CORS_CREDENTIALS=true
- LOG_LEVEL=debug
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- KMA_API_KEY=${KMA_API_KEY}
- ITS_API_KEY=${ITS_API_KEY}
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
volumes:
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
- /app/node_modules
networks:
- pms-network
- pipeline-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
@@ -36,5 +27,5 @@ services:
start_period: 60s
networks:
pms-network:
pipeline-network:
driver: bridge
+11 -9
View File
@@ -1,25 +1,27 @@
services:
# Next.js 프론트엔드만
frontend:
pipeline-front:
build:
context: ../../frontend
dockerfile: ../docker/dev/frontend.Dockerfile
container_name: pms-frontend-mac
container_name: pipeline-front
ports:
- "9771:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
- SERVER_API_URL=http://pms-backend-mac:8080
- SERVER_API_URL=http://pipeline-backend:8080
- NODE_OPTIONS=--max-old-space-size=8192
- NEXT_TELEMETRY_DISABLED=1
volumes:
- ../../frontend:/app
- /app/node_modules
- /app/.next
- WATCHPACK_POLLING=true
- WATCHPACK_POLLING_INTERVAL=3000
# volumes:
# - ../../frontend:/app # 소스 마운트 (Docker for Mac에서 컴파일 느림 → 비활성화)
# - /app/node_modules
# - /app/.next
networks:
- pms-network
- pipeline-network
restart: unless-stopped
networks:
pms-network:
pipeline-network:
driver: bridge
+4 -4
View File
@@ -1,14 +1,14 @@
services:
# Node.js 백엔드
plm-backend:
pipeline-backend:
build:
context: ../../backend-node
dockerfile: ../docker/prod/backend.Dockerfile # 운영용 Dockerfile
container_name: pms-backend-prod
container_name: pipeline-backend
ports:
- "8080:8080" # 호스트:컨테이너 포트 매핑
networks:
- pms-network
- pipeline-network
environment:
- NODE_ENV=production
- PORT=8080
@@ -37,5 +37,5 @@ services:
start_period: 60s
networks:
pms-network:
pipeline-network:
external: true # 외부에서 생성된 네트워크 사용
+2 -2
View File
@@ -1,12 +1,12 @@
services:
# Next.js 프론트엔드
plm-frontend:
pipeline-front:
build:
context: ../../frontend
dockerfile: ../docker/prod/frontend.Dockerfile
args:
- NEXT_PUBLIC_API_URL=https://api.vexplor.com
container_name: plm-frontend
container_name: pipeline-front
restart: always
environment:
NODE_ENV: production
+2 -6
View File
@@ -11,16 +11,14 @@ export default function LoginPage() {
isLoading,
error,
showPassword,
isPopMode,
handleInputChange,
handleLogin,
togglePasswordVisibility,
togglePopMode,
} = useLogin();
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
<div className="w-full max-w-md space-y-6">
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f4f5f7] p-4 dark:bg-background">
<div className="w-full max-w-[380px] space-y-6">
<LoginHeader />
<LoginForm
@@ -28,11 +26,9 @@ export default function LoginPage() {
isLoading={isLoading}
error={error}
showPassword={showPassword}
isPopMode={isPopMode}
onInputChange={handleInputChange}
onSubmit={handleLogin}
onTogglePassword={togglePasswordVisibility}
onTogglePop={togglePopMode}
/>
<LoginFooter />
File diff suppressed because it is too large Load Diff
@@ -1,781 +0,0 @@
"use client";
import React, { useState, useMemo, useCallback, useEffect } from "react";
import {
Plus,
Pencil,
Trash2,
Calendar,
Upload,
Ruler,
FileText,
Loader2,
Inbox,
Save,
Settings2,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
getDesignRequestList,
createDesignRequest,
updateDesignRequest,
deleteDesignRequest,
} from "@/lib/api/design";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
// ========== 타입 ==========
interface HistoryItem {
id?: string;
step: string;
history_date: string;
user_name: string;
description: string;
}
interface DesignRequest {
id: string;
request_no: string;
source_type: string;
request_date: string;
due_date: string;
design_type: string;
priority: string;
status: string;
approval_step: string;
target_name: string;
customer: string;
req_dept: string;
requester: string;
designer: string;
order_no: string;
spec: string;
change_type: string;
drawing_no: string;
urgency: string;
reason: string;
content: string;
apply_timing: string;
review_memo: string;
project_id: string;
ecn_no: string;
created_date: string;
updated_date: string;
writer: string;
company_code: string;
history: HistoryItem[];
impact: string[];
}
// ========== 스타일 맵 ==========
const STATUS_STYLES: Record<string, string> = {
: "bg-muted text-foreground",
: "bg-muted text-foreground",
: "bg-warning/10 text-warning",
: "bg-info/10 text-info",
: "bg-primary/10 text-primary",
: "bg-success/10 text-success",
: "bg-destructive/10 text-destructive",
: "bg-muted text-muted-foreground",
};
const TYPE_STYLES: Record<string, string> = {
: "bg-info/10 text-info",
: "bg-success/10 text-success",
: "bg-warning/10 text-warning",
};
const PRIORITY_STYLES: Record<string, string> = {
: "bg-destructive/10 text-destructive",
: "bg-warning/10 text-warning",
: "bg-muted text-foreground",
: "bg-success/10 text-success",
};
const STATUS_PROGRESS: Record<string, number> = {
신규접수: 0,
접수대기: 0,
검토중: 20,
설계진행: 50,
설계검토: 80,
출도완료: 100,
반려: 0,
종료: 100,
};
function getProgressColor(p: number) {
if (p >= 100) return "bg-success";
if (p >= 60) return "bg-warning";
if (p >= 20) return "bg-info";
return "bg-muted";
}
function getProgressTextColor(p: number) {
if (p >= 100) return "text-success";
if (p >= 60) return "text-warning";
if (p >= 20) return "text-info";
return "text-muted-foreground";
}
const INITIAL_FORM = {
request_no: "",
request_date: "",
due_date: "",
design_type: "",
priority: "보통",
target_name: "",
customer: "",
req_dept: "",
requester: "",
designer: "",
order_no: "",
spec: "",
drawing_no: "",
content: "",
};
// ========== Grid Columns ==========
const DR_GRID_COLUMNS = [
{ key: "request_no", label: "의뢰번호" },
{ key: "design_type", label: "유형" },
{ key: "status", label: "상태" },
{ key: "priority", label: "우선순위" },
{ key: "target_name", label: "설비/제품명" },
{ key: "customer", label: "고객명" },
{ key: "designer", label: "설계담당" },
{ key: "due_date", label: "납기" },
{ key: "progress", label: "진행률" },
];
// ========== 메인 컴포넌트 ==========
export default function DesignRequestPage() {
const ts = useTableSettings("c16-design-request", "dsn_design_request", DR_GRID_COLUMNS);
const [requests, setRequests] = useState<DesignRequest[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
// 검색 필터 (DynamicSearchFilter)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(INITIAL_FORM);
const today = useMemo(() => new Date(), []);
// 데이터 조회
const fetchRequests = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { source_type: "dr" };
const res = await getDesignRequestList(params);
if (res.success && res.data) {
setRequests(res.data);
} else {
setRequests([]);
}
} catch {
setRequests([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
// 클라이언트 사이드 필터링 (DynamicSearchFilter)
const filteredRequests = useMemo(() => {
if (searchFilters.length === 0) return requests;
return requests.filter((item) => {
for (const f of searchFilters) {
const val = item[f.columnName as keyof DesignRequest];
const strVal = val !== undefined && val !== null ? (Array.isArray(val) ? val.join(",") : String(val)) : "";
if (f.operator === "contains") {
if (!strVal.toLowerCase().includes(f.value.toLowerCase())) return false;
} else if (f.operator === "equals") {
if (strVal !== f.value) return false;
} else if (f.operator === "in") {
const allowed = f.value.split("|");
if (!allowed.includes(strVal)) return false;
} else if (f.operator === "between") {
const [from, to] = f.value.split("|");
if (from && strVal < from) return false;
if (to && strVal > to) return false;
}
}
return true;
});
}, [requests, searchFilters]);
const selectedItem = useMemo(() => {
if (!selectedId) return null;
return requests.find((r) => r.id === selectedId) || null;
}, [selectedId, requests]);
const statusCounts = useMemo(() => {
return {
접수대기: requests.filter((r) => r.status === "접수대기" || r.status === "신규접수").length,
설계진행: requests.filter((r) => r.status === "설계진행").length,
출도완료: requests.filter((r) => r.status === "출도완료").length,
};
}, [requests]);
// 채번: 기존 데이터 기반으로 다음 번호 생성
const generateNextNo = useCallback(() => {
const year = new Date().getFullYear();
const existing = requests.filter((r) => r.request_no?.startsWith(`DR-${year}-`));
const maxNum = existing.reduce((max, r) => {
const parts = r.request_no?.split("-");
const num = parts?.length >= 3 ? parseInt(parts[2]) : 0;
return num > max ? num : max;
}, 0);
return `DR-${year}-${String(maxNum + 1).padStart(4, "0")}`;
}, [requests]);
const handleOpenRegister = useCallback(() => {
setIsEditMode(false);
setEditingId(null);
setForm({
...INITIAL_FORM,
request_no: generateNextNo(),
request_date: new Date().toISOString().split("T")[0],
});
setModalOpen(true);
}, [generateNextNo]);
const handleOpenEdit = useCallback(() => {
if (!selectedItem) return;
setIsEditMode(true);
setEditingId(selectedItem.id);
setForm({
request_no: selectedItem.request_no || "",
request_date: selectedItem.request_date || "",
due_date: selectedItem.due_date || "",
design_type: selectedItem.design_type || "",
priority: selectedItem.priority || "보통",
target_name: selectedItem.target_name || "",
customer: selectedItem.customer || "",
req_dept: selectedItem.req_dept || "",
requester: selectedItem.requester || "",
designer: selectedItem.designer || "",
order_no: selectedItem.order_no || "",
spec: selectedItem.spec || "",
drawing_no: selectedItem.drawing_no || "",
content: selectedItem.content || "",
});
setDetailOpen(false);
setModalOpen(true);
}, [selectedItem]);
const handleSave = useCallback(async () => {
if (!form.target_name.trim()) { toast.error("설비/제품명을 입력해 주세요."); return; }
if (!form.design_type) { toast.error("의뢰 유형을 선택해 주세요."); return; }
if (!form.due_date) { toast.error("납기를 입력해 주세요."); return; }
if (!form.spec.trim()) { toast.error("요구사양을 입력해 주세요."); return; }
setSaving(true);
try {
const payload = {
request_no: form.request_no,
source_type: "dr",
request_date: form.request_date,
due_date: form.due_date,
design_type: form.design_type,
priority: form.priority,
target_name: form.target_name,
customer: form.customer,
req_dept: form.req_dept,
requester: form.requester,
designer: form.designer,
order_no: form.order_no,
spec: form.spec,
drawing_no: form.drawing_no,
content: form.content,
};
let res;
if (isEditMode && editingId) {
res = await updateDesignRequest(editingId, payload);
} else {
res = await createDesignRequest({
...payload,
status: "신규접수",
history: [{
step: "신규접수",
history_date: form.request_date || new Date().toISOString().split("T")[0],
user_name: form.requester || "시스템",
description: `${form.req_dept || ""}에서 설계의뢰 등록`,
}],
});
}
if (res.success) {
toast.success(isEditMode ? "수정되었어요." : "등록되었어요.");
setModalOpen(false);
await fetchRequests();
if (isEditMode && editingId) {
setSelectedId(editingId);
} else if (res.data?.id) {
setSelectedId(res.data.id);
}
} else {
toast.error(res.message || "저장에 실패했어요.");
}
} catch (err: any) {
toast.error("저장에 실패했어요.");
} finally {
setSaving(false);
}
}, [form, isEditMode, editingId, fetchRequests]);
const handleDelete = useCallback(async () => {
if (!selectedId || !selectedItem) return;
const displayNo = selectedItem.request_no || selectedId;
if (!confirm(`${displayNo} 설계의뢰를 삭제할까요?`)) return;
try {
const res = await deleteDesignRequest(selectedId);
if (res.success) {
toast.success("삭제되었어요.");
setSelectedId(null);
setDetailOpen(false);
await fetchRequests();
} else {
toast.error(res.message || "삭제에 실패했어요.");
}
} catch (err: any) {
toast.error("삭제에 실패했어요.");
}
}, [selectedId, selectedItem, fetchRequests]);
const getDueDateInfo = useCallback(
(dueDate: string) => {
if (!dueDate) return { text: "-", color: "text-muted-foreground" };
const due = new Date(dueDate);
const diff = Math.ceil((due.getTime() - today.getTime()) / 86400000);
if (diff < 0) return { text: `${Math.abs(diff)}일 초과`, color: "text-destructive" };
if (diff === 0) return { text: "오늘", color: "text-warning" };
if (diff <= 7) return { text: `${diff}일 남음`, color: "text-warning" };
return { text: `${diff}일 남음`, color: "text-success" };
},
[today]
);
const getProgress = useCallback((status: string) => {
return STATUS_PROGRESS[status] ?? 0;
}, []);
const handleRowClick = useCallback((id: string) => {
setSelectedId(id);
setDetailOpen(true);
}, []);
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 필터 */}
<div className="shrink-0">
<DynamicSearchFilter
tableName="dsn_design_request"
filterId="c16-design-request"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={filteredRequests.length}
/>
</div>
{/* 현황 카드 */}
<div className="grid grid-cols-3 gap-3 shrink-0">
<div className="rounded-lg border bg-card px-3 py-2 text-left">
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-info">{statusCounts.}</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-left">
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-warning">{statusCounts.}</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-left">
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-success">{statusCounts.}</div>
</div>
</div>
{/* 액션 바 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold"> </h2>
<Badge variant="secondary" className="font-mono">{filteredRequests.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleOpenRegister}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<div className="mx-1 h-6 w-px bg-border" />
<Button variant="outline" size="sm" disabled={!selectedId} onClick={handleOpenEdit}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 테이블 영역 */}
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
<EDataTable<DesignRequest>
columns={ts.visibleColumns.map((col): EDataTableColumn<DesignRequest> => ({
key: col.key,
label: col.label,
width: col.key === "request_no" ? "w-[100px]" : col.key === "design_type" ? "w-[70px]" : col.key === "status" ? "w-[70px]" : col.key === "priority" ? "w-[60px]" : col.key === "customer" ? "w-[90px]" : col.key === "designer" ? "w-[70px]" : col.key === "due_date" ? "w-[85px]" : col.key === "progress" ? "w-[65px]" : undefined,
align: (col.key === "design_type" || col.key === "status" || col.key === "priority" || col.key === "progress") ? "center" : undefined,
render: col.key === "request_no"
? (val: any) => <span className="text-[11px] font-semibold text-primary">{val || "-"}</span>
: col.key === "design_type"
? (val: any) => val ? <Badge className={cn("text-[9px]", TYPE_STYLES[val])}>{val}</Badge> : <span>-</span>
: col.key === "status"
? (val: any) => <Badge className={cn("text-[9px]", STATUS_STYLES[val])}>{val}</Badge>
: col.key === "priority"
? (val: any) => <Badge className={cn("text-[9px]", PRIORITY_STYLES[val])}>{val}</Badge>
: col.key === "progress"
? (_val: any, row: DesignRequest) => {
const progress = STATUS_PROGRESS[row.status] ?? 0;
return (
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
</div>
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
</div>
);
}
: undefined,
}))}
data={ts.groupData(filteredRequests)}
loading={loading}
emptyMessage="등록된 설계의뢰가 없어요"
selectedId={selectedId}
onSelect={(id) => setSelectedId(id)}
onRowClick={(row) => handleRowClick(row.id)}
draggableColumns={false}
/>
</div>
{/* 상세 정보 다이얼로그 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-[900px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{selectedItem ? `설계의뢰 상세 — ${selectedItem.request_no}` : "설계의뢰 상세"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
{selectedItem && (
<div className="space-y-4">
{/* 기본 정보 */}
<div>
<div className="mb-2 text-xs font-bold">
<FileText className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-lg border bg-muted/10 p-3">
<InfoRow label="의뢰번호" value={<span className="font-semibold text-primary">{selectedItem.request_no || "-"}</span>} />
<InfoRow label="상태" value={<Badge className={cn("text-[10px]", STATUS_STYLES[selectedItem.status])}>{selectedItem.status}</Badge>} />
<InfoRow label="유형" value={selectedItem.design_type ? <Badge className={cn("text-[10px]", TYPE_STYLES[selectedItem.design_type])}>{selectedItem.design_type}</Badge> : "-"} />
<InfoRow label="우선순위" value={<Badge className={cn("text-[10px]", PRIORITY_STYLES[selectedItem.priority])}>{selectedItem.priority}</Badge>} />
<InfoRow label="설비/제품명" value={selectedItem.target_name || "-"} />
<InfoRow label="고객명" value={selectedItem.customer || "-"} />
<InfoRow label="의뢰부서 / 의뢰자" value={`${selectedItem.req_dept || "-"} / ${selectedItem.requester || "-"}`} />
<InfoRow label="설계담당" value={selectedItem.designer || "미배정"} />
<InfoRow label="의뢰일자" value={selectedItem.request_date || "-"} />
<InfoRow
label="납기"
value={
selectedItem.due_date ? (
<span>
{selectedItem.due_date}{" "}
<span className={cn("text-[11px]", getDueDateInfo(selectedItem.due_date).color)}>
({getDueDateInfo(selectedItem.due_date).text})
</span>
</span>
) : "-"
}
/>
<InfoRow label="수주번호" value={selectedItem.order_no || "-"} />
<InfoRow
label="진행률"
value={
(() => {
const progress = getProgress(selectedItem.status);
return (
<div className="flex items-center gap-2">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", getProgressColor(progress))} style={{ width: `${progress}%` }} />
</div>
<span className={cn("text-xs font-bold", getProgressTextColor(progress))}>{progress}%</span>
</div>
);
})()
}
/>
</div>
</div>
{/* 요구사양 */}
<div>
<div className="mb-2 text-xs font-bold">
<FileText className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="rounded-lg border bg-muted/10 p-3">
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed">{selectedItem.spec || "-"}</pre>
{selectedItem.drawing_no && (
<div className="mt-2 text-xs">
<span className="text-muted-foreground"> : </span>
<span className="text-primary">{selectedItem.drawing_no}</span>
</div>
)}
{selectedItem.content && (
<div className="mt-1 text-xs">
<span className="text-muted-foreground">: </span>{selectedItem.content}
</div>
)}
</div>
</div>
{/* 진행 이력 */}
{selectedItem.history && selectedItem.history.length > 0 && (
<div>
<div className="mb-2 text-xs font-bold">
<Calendar className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="space-y-0">
{selectedItem.history.map((h, idx) => {
const isLast = idx === selectedItem.history.length - 1;
const isDone = h.step === "출도완료" || h.step === "종료";
return (
<div key={h.id || idx} className="flex gap-3">
<div className="flex flex-col items-center">
<div
className={cn(
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full border-2",
isLast && !isDone
? "border-primary bg-primary"
: isDone || !isLast
? "border-success bg-success"
: "border-muted-foreground bg-muted-foreground"
)}
/>
{!isLast && <div className="w-px flex-1 bg-border" />}
</div>
<div className="pb-3">
<Badge className={cn("text-[9px]", STATUS_STYLES[h.step])}>{h.step}</Badge>
<div className="mt-0.5 text-xs">{h.description}</div>
<div className="text-[10px] text-muted-foreground">{h.history_date} · {h.user_name}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter>
{selectedItem && (
<>
<Button variant="outline" size="sm" onClick={handleOpenEdit}>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive" onClick={handleDelete}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* 등록/수정 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-5xl w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{isEditMode ? "설계의뢰 수정" : "설계의뢰 등록"}</DialogTitle>
<DialogDescription>{isEditMode ? "설계의뢰 정보를 수정해요." : "새 설계의뢰를 등록해요."}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col md:flex-row gap-6 p-6">
{/* 좌측: 기본 정보 */}
<div className="md:w-[420px] shrink-0 space-y-4">
<h3 className="text-sm font-semibold pb-2 border-b"> </h3>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={form.request_no} readOnly className="h-9 bg-muted cursor-not-allowed" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input type="date" value={form.request_date} onChange={(e) => setForm((p) => ({ ...p, request_date: e.target.value }))} className="h-9" />
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input type="date" value={form.due_date} onChange={(e) => setForm((p) => ({ ...p, due_date: e.target.value }))} className="h-9" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Select value={form.design_type} onValueChange={(v) => setForm((p) => ({ ...p, design_type: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["신규설계", "유사설계", "개조설계"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Select value={form.priority} onValueChange={(v) => setForm((p) => ({ ...p, priority: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">/ <span className="text-destructive">*</span></Label>
<Input value={form.target_name} onChange={(e) => setForm((p) => ({ ...p, target_name: e.target.value }))} placeholder="설비 또는 제품명 입력" className="h-9" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={form.req_dept} onValueChange={(v) => setForm((p) => ({ ...p, req_dept: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["영업팀", "기획팀", "생산팀", "품질팀"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={form.requester} onChange={(e) => setForm((p) => ({ ...p, requester: e.target.value }))} placeholder="의뢰자명" className="h-9" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={form.customer} onChange={(e) => setForm((p) => ({ ...p, customer: e.target.value }))} placeholder="고객/거래처명" className="h-9" />
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="관련 수주번호" className="h-9" />
</div>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={form.designer} onValueChange={(v) => setForm((p) => ({ ...p, designer: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["이설계", "박도면", "최기구", "김전장"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
{/* 우측: 상세 내용 */}
<div className="flex min-w-0 flex-1 flex-col gap-4">
<h3 className="text-sm font-semibold pb-2 border-b"> </h3>
<div className="flex-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Textarea
value={form.spec}
onChange={(e) => setForm((p) => ({ ...p, spec: e.target.value }))}
placeholder={"고객 요구사양 또는 설비 사양을 상세히 기술해주세요\n\n예시:\n- 작업 대상: SUS304 Φ20 파이프\n- 가공 방식: 자동 절단 + 면취\n- 생산 속도: 60EA/분\n- 치수 공차: ±0.1mm"}
className="min-h-[180px]"
/>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> </Label>
<Input value={form.drawing_no} onChange={(e) => setForm((p) => ({ ...p, drawing_no: e.target.value }))} placeholder="유사 설비명 또는 참조 도면번호" className="h-9" />
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Textarea value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} placeholder="기타 참고 사항" className="min-h-[70px]" rows={3} />
</div>
<div>
<h3 className="text-sm font-semibold pb-2 border-b mb-2"></h3>
<div className="cursor-pointer rounded-lg border-2 border-dashed p-5 text-center transition-colors hover:border-primary hover:bg-accent/50">
<Upload className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="mt-1.5 text-sm text-muted-foreground"> (, , )</div>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)} disabled={saving}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}
// ========== 정보 행 서브컴포넌트 ==========
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-1">
<span className="min-w-[80px] shrink-0 text-[11px] text-muted-foreground">{label}</span>
<span className="text-xs font-medium">{value}</span>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,950 +0,0 @@
"use client";
/**
*
*
* 좌측: 설비 (equipment_mng)
* 우측: ( / / )
*
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Inbox, ClipboardCheck, Package, Copy, Info, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { ImageUpload } from "@/components/common/ImageUpload";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const EQUIP_TABLE = "equipment_mng";
const INSPECTION_TABLE = "equipment_inspection_item";
const CONSUMABLE_TABLE = "equipment_consumable";
const GRID_COLUMNS_CONFIG = [
{ key: "equipment_code", label: "설비코드" },
{ key: "equipment_name", label: "설비명" },
{ key: "equipment_type", label: "설비유형" },
{ key: "manufacturer", label: "제조사" },
{ key: "installation_location", label: "설치장소" },
{ key: "operation_status", label: "가동상태" },
];
export default function EquipmentInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측
const [equipments, setEquipments] = useState<any[]>([]);
const [equipLoading, setEquipLoading] = useState(false);
const [equipCount, setEquipCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
// 우측 탭
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
const [inspections, setInspections] = useState<any[]>([]);
const [inspectionLoading, setInspectionLoading] = useState(false);
const [consumables, setConsumables] = useState<any[]>([]);
const [consumableLoading, setConsumableLoading] = useState(false);
// 카테고리
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 설비 등록/수정 모달
const [equipModalOpen, setEquipModalOpen] = useState(false);
const [equipEditMode, setEquipEditMode] = useState(false);
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 기본정보 탭 편집 폼
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
const [infoSaving, setInfoSaving] = useState(false);
// 점검항목 추가/수정 모달
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [inspectionEditMode, setInspectionEditMode] = useState(false);
// 소모품 추가/수정 모달
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableEditMode, setConsumableEditMode] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySourceEquip, setCopySourceEquip] = useState("");
const [copyItems, setCopyItems] = useState<any[]>([]);
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
const [copyLoading, setCopyLoading] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
// 테이블 설정
const ts = useTableSettings("c16-equipment-info", EQUIP_TABLE, GRID_COLUMNS_CONFIG);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["equipment_type", "operation_status"]) {
try {
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["inspection_cycle", "inspection_method"]) {
try {
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCatOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return catOptions[col]?.find((o) => o.code === code)?.label || code;
};
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
setEquipLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
useEffect(() => { fetchEquipments(); }, [fetchEquipments]);
const selectedEquip = equipments.find((e) => e.id === selectedEquipId);
// 기본정보 탭 폼 초기화 (설비 선택 변경 시)
useEffect(() => {
if (selectedEquip) setInfoForm({ ...selectedEquip });
else setInfoForm({});
}, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps
// 기본정보 저장
const handleInfoSave = async () => {
if (!infoForm.id) return;
setInfoSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm;
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("저장되었습니다.");
fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); }
finally { setInfoSaving(false); }
};
// 우측: 점검항목 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetchData = async () => {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
setInspections(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setInspections([]); } finally { setInspectionLoading(false); }
};
fetchData();
}, [selectedEquip?.equipment_code]);
// 우측: 소모품 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetchData = async () => {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
};
fetchData();
}, [selectedEquip?.equipment_code]);
// 새로고침 헬퍼
const refreshRight = () => {
const eid = selectedEquipId;
setSelectedEquipId(null);
setTimeout(() => setSelectedEquipId(eid), 50);
};
// 설비 등록/수정
const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); };
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
const handleEquipSave = async () => {
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm;
if (equipEditMode && id) {
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, { id: crypto.randomUUID(), ...fields });
toast.success("등록되었습니다.");
}
setEquipModalOpen(false); fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
const handleEquipDelete = async () => {
if (!selectedEquipId) return;
const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] });
toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
if (!inspectionForm.inspection_cycle) { toast.error("점검주기는 필수입니다."); return; }
if (!inspectionForm.inspection_method) { toast.error("점검방법은 필수입니다."); return; }
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (isNumeric && !inspectionForm.unit) { toast.error("측정단위가 필수입니다."); return; }
// 기준값/오차범위 → 하한치/상한치 자동 계산
const saveData = { ...inspectionForm };
if (isNumeric && saveData.standard_value) {
const std = Number(saveData.standard_value) || 0;
const tol = Number(saveData.tolerance) || 0;
saveData.lower_limit = String(std - tol);
saveData.upper_limit = String(std + tol);
}
if (!isNumeric) {
saveData.unit = "";
saveData.standard_value = "";
saveData.tolerance = "";
saveData.lower_limit = "";
saveData.upper_limit = "";
}
setSaving(true);
try {
if (inspectionEditMode) {
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
originalData: { id: saveData.id }, updatedData: { ...saveData, equipment_code: selectedEquip?.equipment_code },
});
toast.success("수정되었습니다.");
setInspectionModalOpen(false);
} else {
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
id: crypto.randomUUID(), ...saveData, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다.");
if (inspectionContinuous) {
setInspectionForm({});
} else {
setInspectionModalOpen(false);
}
}
refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 소모품 품목 로드
const loadConsumableItems = async () => {
try {
const flatten = (vals: any[]): any[] => {
const r: any[] = [];
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
return r;
};
const [typeRes, divRes] = await Promise.all([
apiClient.get(`/table-categories/item_info/type/values`),
apiClient.get(`/table-categories/item_info/division/values`),
]);
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
const filters: any[] = [];
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
));
const allItems = new Map<string, any>();
for (const res of results) {
const rows = res.data?.data?.data || res.data?.data?.rows || [];
for (const row of rows) allItems.set(row.id, row);
}
setConsumableItemOptions(Array.from(allItems.values()));
} catch { setConsumableItemOptions([]); }
};
const handleConsumableSave = async () => {
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
setSaving(true);
try {
if (consumableEditMode) {
await apiClient.put(`/table-management/tables/${CONSUMABLE_TABLE}/edit`, {
originalData: { id: consumableForm.id }, updatedData: { ...consumableForm, equipment_code: selectedEquip?.equipment_code },
});
toast.success("수정되었습니다.");
setConsumableModalOpen(false);
} else {
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
id: crypto.randomUUID(), ...consumableForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다.");
if (consumableContinuous) {
setConsumableForm({});
} else {
setConsumableModalOpen(false);
}
}
refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 점검항목 복사
const loadCopyItems = async (equipCode: string) => {
setCopySourceEquip(equipCode);
setCopyChecked(new Set());
if (!equipCode) { setCopyItems([]); return; }
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
setCopyItems(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setCopyItems([]); } finally { setCopyLoading(false); }
};
const handleCopyApply = async () => {
const selected = copyItems.filter((i) => copyChecked.has(i.id));
if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; }
setSaving(true);
try {
for (const item of selected) {
const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item;
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...fields, equipment_code: selectedEquip?.equipment_code,
});
}
toast.success(`${selected.length}개 점검항목이 복사되었습니다.`);
setCopyModalOpen(false); refreshRight();
} catch { toast.error("복사 실패"); } finally { setSaving(false); }
};
// 엑셀
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
// 셀렉트 렌더링 헬퍼
const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 브레드크럼 */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
<span></span>
<span className="text-muted-foreground/50">/</span>
<span className="text-foreground font-medium"></span>
</div>
{/* 검색 바 */}
<DynamicSearchFilter
tableName={EQUIP_TABLE}
filterId="c16-equipment-info"
onFilterChange={setSearchFilters}
dataCount={equipCount}
externalFilterConfig={ts.filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
try {
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
else toast.error("테이블 구조 분석 실패");
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
}}>
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 설비 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/50 shrink-0">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{equipCount}</span>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<div className="h-4 w-px bg-border" />
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
<div className="h-4 w-px bg-border" />
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
<Settings2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<EDataTable
columns={mainTableColumns}
data={ts.groupData(equipments)}
loading={equipLoading}
emptyMessage="등록된 설비가 없어요"
selectedId={selectedEquipId}
onSelect={(id) => setSelectedEquipId(id)}
onRowDoubleClick={() => openEquipEdit()}
showPagination={true}
draggableColumns={false}
columnOrderKey="c16-equipment-info-main"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 탭 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-2 border-b bg-muted/50 shrink-0">
<div className="flex items-center gap-1">
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
<button key={tab} onClick={() => setRightTab(tab)}
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
<Icon className="w-3.5 h-3.5" />{label}
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
</button>
))}
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
</div>
<div className="flex gap-1.5">
{rightTab === "inspection" && (
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{!selectedEquipId ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 m-3 border-2 border-dashed rounded-lg text-center">
<Inbox className="w-12 h-12 text-muted-foreground/40" />
<div>
<p className="text-sm font-semibold text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
</div>
) : rightTab === "info" ? (
<div className="p-4 overflow-auto">
<div className="flex justify-end mb-3">
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input type="date" value={infoForm.introduction_date || ""} onChange={(e) => setInfoForm((p) => ({ ...p, introduction_date: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
</div>
</div>
</div>
) : rightTab === "inspection" ? (
<div className="flex-1 overflow-auto">
{inspectionLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : inspections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ClipboardCheck className="w-8 h-8 mb-2 opacity-40" />
<p className="text-sm"> </p>
</div>
) : (
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</thead>
<TableBody>
{inspections.map((item) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-primary/5" onDoubleClick={() => {
const std = item.standard_value || "";
const tol = item.tolerance || "";
setInspectionForm({ ...item, standard_value: std, tolerance: tol });
setInspectionEditMode(true);
setInspectionModalOpen(true);
}}>
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
<TableCell className="text-[13px]">{item.lower_limit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.upper_limit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.inspection_content || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
) : (
<div className="flex-1 overflow-auto">
{consumableLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : consumables.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Package className="w-8 h-8 mb-2 opacity-40" />
<p className="text-sm"> </p>
</div>
) : (
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</thead>
<TableBody>
{consumables.map((item) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-primary/5" onDoubleClick={() => {
setConsumableForm({ ...item });
setConsumableEditMode(true);
loadConsumableItems();
setConsumableModalOpen(true);
}}>
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.specification || "-"}</TableCell>
<TableCell className="text-[13px]">{item.manufacturer || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 설비 등록/수정 모달 */}
<Dialog open={equipModalOpen} onOpenChange={setEquipModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{equipEditMode ? "설비 수정" : "설비 등록"}</DialogTitle>
<DialogDescription>{equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input type="date" value={equipForm.introduction_date || ""} onChange={(e) => setEquipForm((p) => ({ ...p, introduction_date: e.target.value }))} className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEquipModalOpen(false)}></Button>
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 점검항목 추가 모달 */}
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>{inspectionEditMode ? "점검항목 수정" : "점검항목 추가"}</DialogTitle><DialogDescription>{selectedEquip?.equipment_name} {inspectionEditMode ? "수정" : "추가"}.</DialogDescription></DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="예: 온도점검, 진동점검" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => {
const label = resolve("inspection_method", v);
const isNum = ["숫자", "치수검사"].includes(label) || ["숫자", "치수검사"].includes(v);
if (!isNum) {
setInspectionForm((p) => ({ ...p, inspection_method: v, unit: "", standard_value: "", tolerance: "", lower_limit: "", upper_limit: "" }));
} else {
setInspectionForm((p) => ({ ...p, inspection_method: v }));
}
}, "점검방법")}</div>
{(() => {
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (!isNumeric) return null;
return (
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, mm, V" className="h-9" /></div>
);
})()}
</div>
{(() => {
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (!isNumeric) return null;
return (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.standard_value || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, standard_value: e.target.value }))} placeholder="기준값 입력" className="h-9" type="number" /></div>
<div className="space-y-1.5"><Label className="text-sm">±</Label>
<Input value={inspectionForm.tolerance || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, tolerance: e.target.value }))} placeholder="허용 오차범위" className="h-9" type="number" /></div>
</div>
);
})()}
<div className="space-y-1.5"><Label className="text-sm"></Label>
<textarea
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={inspectionForm.inspection_content || ""}
onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))}
placeholder="점검 항목 및 내용 입력"
/></div>
<div className="space-y-1.5"><Label className="text-sm"> ()</Label>
<textarea
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={inspectionForm.checklist || ""}
onChange={(e) => setInspectionForm((p) => ({ ...p, checklist: e.target.value }))}
placeholder="점검 체크리스트 입력 (줄바꿈으로 구분)"
/></div>
</div>
<DialogFooter className="flex items-center justify-between sm:justify-between">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={inspectionContinuous} onCheckedChange={(c) => setInspectionContinuous(!!c)} />
</label>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 소모품 추가 모달 */}
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>{consumableEditMode ? "소모품 수정" : "소모품 추가"}</DialogTitle><DialogDescription>{selectedEquip?.equipment_name} {consumableEditMode ? "수정" : "추가"}.</DialogDescription></DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5 col-span-2"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{consumableItemOptions.length > 0 ? (
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
setConsumableForm((p) => ({
...p,
consumable_name: v,
specification: item?.size || p.specification || "",
unit: item?.unit || p.unit || "",
manufacturer: item?.manufacturer || p.manufacturer || "",
}));
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
<SelectContent>
{consumableItemOptions.map((item) => (
<SelectItem key={item.id} value={item.item_name || item.item_number}>
{item.item_name}{item.size ? ` (${item.size})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div>
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
placeholder="소모품명 직접 입력" className="h-9" />
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
)}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
</div>
<DialogFooter className="flex items-center justify-between sm:justify-between">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={consumableContinuous} onCheckedChange={(c) => setConsumableContinuous(!!c)} />
</label>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 점검항목 복사 모달 */}
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden">
<DialogHeader><DialogTitle> </DialogTitle>
<DialogDescription> {selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-3 flex-1 overflow-y-auto">
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
<SelectContent>
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-auto max-h-[300px]">
{copyLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : copyItems.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없어요" : "설비를 선택해주세요"}</div>
) : (
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
</TableHead>
<TableHead></TableHead><TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[60px]"></TableHead>
</TableRow>
</thead>
<TableBody>
{copyItems.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
<TableCell className="text-sm">{item.inspection_item}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
<TableCell className="text-[13px]">{item.lower_limit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.upper_limit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{copyChecked.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCopyModalOpen(false)}></Button>
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 (멀티테이블) */}
{excelChainConfig && (
<MultiTableExcelUploadModal open={excelUploadOpen}
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
)}
{/* 테이블 설정 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -1,351 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { RefreshCw, Loader2, Inbox, Wrench, Download } from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { exportToExcel } from "@/lib/utils/excelExport";
// ─── 타입 ─────────────────────────────────────────────────
interface InspectionRecord {
id: string;
equipment_code: string;
inspection_item_objid: string;
inspection_date: string;
status: string;
inspector: string;
remark: string;
created_date: string;
}
interface InspectionItem {
id: string;
equipment_code: string;
inspection_item: string;
inspection_cycle: string;
inspection_content: string;
inspection_method: string;
lower_limit: string;
upper_limit: string;
unit: string;
}
interface EquipmentInfo {
id: string;
equipment_code: string;
equipment_name: string;
}
const RECORD_TABLE = "equipment_inspection_record";
// ─── 메인 컴포넌트 ───────────────────────────────────────
export default function EquipmentInspectionRecordPage() {
const [records, setRecords] = useState<InspectionRecord[]>([]);
const [inspectionItems, setInspectionItems] = useState<Map<string, InspectionItem>>(new Map());
const [equipments, setEquipments] = useState<Map<string, EquipmentInfo>>(new Map());
const [categoryMap, setCategoryMap] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filterValues, setFilterValues] = useState<FilterValue[]>([]);
// ─── 데이터 조회 ────────────────────────────────────────
const fetchData = useCallback(async () => {
setLoading(true);
try {
const search: Record<string, any> = {};
filterValues.forEach((f) => {
if (f.value) search[f.column] = f.value;
});
const [recordRes, itemRes, equipRes] = await Promise.all([
apiClient.post(`/table-management/tables/${RECORD_TABLE}/data`, {
page: 1,
size: 1000,
autoFilter: true,
search,
}),
apiClient.post(`/table-management/tables/equipment_inspection_item/data`, {
page: 1,
size: 1000,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
const rRows: InspectionRecord[] = recordRes.data?.data?.data ?? recordRes.data?.data?.rows ?? [];
setRecords(rRows);
const iRows: InspectionItem[] = itemRes.data?.data?.data ?? itemRes.data?.data?.rows ?? [];
const iMap = new Map<string, InspectionItem>();
iRows.forEach((i) => iMap.set(i.id, i));
setInspectionItems(iMap);
const eRows: EquipmentInfo[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
const eMap = new Map<string, EquipmentInfo>();
eRows.forEach((e) => {
eMap.set(e.equipment_code, e);
eMap.set(e.id, e);
});
setEquipments(eMap);
// 카테고리 코드→라벨 매핑 (점검주기, 점검방법)
try {
const catCols = ["inspection_cycle", "inspection_method"];
const catResults = await Promise.all(
catCols.map((col) =>
apiClient.get(`/table-categories/equipment_inspection_item/${col}/values`).catch(() => ({ data: [] })),
),
);
const cMap: Record<string, Record<string, string>> = {};
catCols.forEach((col, idx) => {
const vals: { code: string; label: string }[] = catResults[idx].data?.data ?? catResults[idx].data ?? [];
cMap[col] = {};
vals.forEach((v: any) => { cMap[col][v.valueCode || v.code] = v.valueLabel || v.label; });
});
setCategoryMap(cMap);
} catch {
// 카테고리 조회 실패 무시
}
} catch (err) {
console.error("점검기록 조회 실패:", err);
} finally {
setLoading(false);
}
}, [filterValues]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ─── 선택된 레코드 ─────────────────────────────────────
const selectedRecord = useMemo(() => records.find((r) => r.id === selectedId), [records, selectedId]);
const selectedItem = useMemo(
() => (selectedRecord ? inspectionItems.get(selectedRecord.inspection_item_objid) : undefined),
[selectedRecord, inspectionItems],
);
// ─── 설비명 조회 ───────────────────────────────────────
const getEquipName = (code: string) => equipments.get(code)?.equipment_name || code || "-";
// ─── 카테고리 코드→라벨 변환 ──────────────────────────
const resolveCategory = (col: string, code: string) => {
if (!code) return "-";
return categoryMap[col]?.[code] || code;
};
// ─── 엑셀 다운로드 ─────────────────────────────────────
const handleExcel = async () => {
const data = records.map((r) => ({
설비코드: r.equipment_code,
설비명: getEquipName(r.equipment_code),
점검항목: inspectionItems.get(r.inspection_item_objid)?.inspection_item || "-",
점검일자: fmtDate(r.inspection_date),
상태: r.status,
점검자: r.inspector,
비고: r.remark,
}));
await exportToExcel(data, "설비점검기록.xlsx", "점검기록");
};
// ─── 날짜/상태 포맷 ────────────────────────────────────
const fmtDate = (d: string) => {
if (!d) return "-";
try {
return new Date(d).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
} catch {
return d;
}
};
const statusBadge = (v: string) => {
if (!v) return <Badge variant="outline" className="text-xs">-</Badge>;
const lower = v.toLowerCase();
if (["완료", "pass", "정상", "합격", "completed"].includes(lower))
return <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-200 text-xs">{v}</Badge>;
if (["이상", "fail", "불합격", "비정상"].includes(lower))
return <Badge className="bg-red-500/10 text-red-600 border-red-200 text-xs">{v}</Badge>;
if (["점검중", "진행", "in_progress"].includes(lower))
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">{v}</Badge>;
return <Badge variant="outline" className="text-xs">{v}</Badge>;
};
// ─── 렌더링 ─────────────────────────────────────────────
return (
<div className="flex h-full flex-col">
{/* 검색 필터 */}
<div className="shrink-0">
<DynamicSearchFilter
tableName={RECORD_TABLE}
filterId="equip-inspection-record"
onFilterChange={(filters) => setFilterValues(filters)}
dataCount={records.length}
/>
</div>
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-4 py-2.5 shrink-0">
<div className="flex items-center gap-2">
<Wrench className="h-5 w-5 text-primary" />
<h2 className="text-base font-bold"></h2>
<Badge variant="secondary">{records.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleExcel}>
<Download className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
</Button>
</div>
</div>
{/* 분할 패널 */}
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
{/* 좌측: 점검기록 테이블 */}
<ResizablePanel defaultSize={60} minSize={35}>
<div className="h-full overflow-auto">
{loading && records.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin mb-3" />
<span className="text-sm"> ...</span>
</div>
) : records.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Inbox className="h-10 w-10 mb-3" />
<span className="text-sm"> </span>
</div>
) : (
<Table noWrapper className="min-w-max">
<TableHeader className="sticky top-0 z-10 bg-muted shadow-[0_1px_0_0_hsl(var(--border))]">
<TableRow className="hover:bg-muted">
<TableHead className="w-10 text-center">#</TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[70px] text-center"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((row, idx) => {
const item = inspectionItems.get(row.inspection_item_objid);
return (
<TableRow
key={row.id}
className={cn("cursor-pointer", selectedId === row.id && "bg-primary/5")}
onClick={() => setSelectedId(row.id)}
>
<TableCell className="text-center text-muted-foreground text-xs">{idx + 1}</TableCell>
<TableCell className="font-mono text-sm">{row.equipment_code || "-"}</TableCell>
<TableCell className="text-sm">{getEquipName(row.equipment_code)}</TableCell>
<TableCell className="text-sm">{item?.inspection_item || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{fmtDate(row.inspection_date)}</TableCell>
<TableCell className="text-center">{statusBadge(row.status)}</TableCell>
<TableCell className="text-sm">{row.inspector || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.remark || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 점검항목 상세 */}
<ResizablePanel defaultSize={40} minSize={25}>
{!selectedId || !selectedRecord ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<Inbox className="w-10 h-10 text-muted-foreground/40" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex flex-col h-full overflow-auto">
{/* 점검 기록 요약 */}
<div className="px-4 py-3 border-b bg-muted/50 shrink-0">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="설비" value={getEquipName(selectedRecord.equipment_code)} />
<DetailRow label="설비코드" value={selectedRecord.equipment_code} />
<DetailRow label="점검일자" value={fmtDate(selectedRecord.inspection_date)} />
<DetailRow label="점검자" value={selectedRecord.inspector || "-"} />
<DetailRow label="상태" value={selectedRecord.status || "-"} badge={statusBadge(selectedRecord.status)} />
<DetailRow label="비고" value={selectedRecord.remark || "-"} />
</div>
</div>
{/* 점검 항목 상세 */}
{selectedItem ? (
<div className="px-4 py-3">
<h3 className="text-[13px] font-bold mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<DetailRow label="점검항목" value={selectedItem.inspection_item || "-"} />
<DetailRow label="점검주기" value={resolveCategory("inspection_cycle", selectedItem.inspection_cycle)} />
<DetailRow label="점검내용" value={selectedItem.inspection_content || "-"} span2 />
<DetailRow label="점검방법" value={resolveCategory("inspection_method", selectedItem.inspection_method)} span2 />
<DetailRow label="하한치" value={selectedItem.lower_limit || "-"} />
<DetailRow label="상한치" value={selectedItem.upper_limit || "-"} />
<DetailRow label="단위" value={selectedItem.unit || "-"} />
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm"> </p>
</div>
)}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
// ─── 상세 행 ──────────────────────────────────────────────
function DetailRow({
label,
value,
badge,
span2,
}: {
label: string;
value: string;
badge?: React.ReactNode;
span2?: boolean;
}) {
return (
<div className={cn("flex flex-col gap-0.5", span2 && "col-span-2")}>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
{badge ? <div className="w-fit">{badge}</div> : <span className="text-xs">{value}</span>}
</div>
);
}
@@ -1,501 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import {
Plus, Trash2, Save, Loader2, Pencil, Cpu, Settings2, Search, Inbox,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
/* ───── 테이블명 ───── */
const DATATYPE_TABLE = "plc_data_type";
const DATATYPE_COLUMNS = [
{ key: "equipment_code", label: "설비코드" },
{ key: "data_type", label: "데이터타입" },
{ key: "unit", label: "단위" },
{ key: "tag_address", label: "태그주소" },
{ key: "collection_interval", label: "수집주기" },
{ key: "lower_limit", label: "하한값" },
{ key: "upper_limit", label: "상한값" },
{ key: "is_active", label: "사용여부" },
];
const COLLECTION_TABLE = "plc_collection_config";
const EQUIPMENT_TABLE = "equipment_mng";
/* ───── Cron 한글 변환 ───── */
const cronToKorean = (cron: string): string => {
if (!cron) return "";
const parts = cron.trim().split(/\s+/);
if (parts.length < 5) return cron;
const [min, hour] = parts;
if (min === "*" && hour === "*") return "매 분마다";
if (min !== "*" && hour === "*") return `매시 ${min}분마다`;
if (min === "0" && hour !== "*") return `매일 ${hour}시 정각`;
if (min === "*/5") return "5분마다";
if (min === "*/10") return "10분마다";
if (min === "*/30") return "30분마다";
return cron;
};
/* ───── 카테고리 flatten ───── */
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flattenCategories(v.children));
}
return result;
};
export default function PlcSettingsPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const ts = useTableSettings("c16-plc-settings", DATATYPE_TABLE, DATATYPE_COLUMNS);
const [activeTab, setActiveTab] = useState("datatype");
/* ───── PLC 데이터타입 ───── */
const [datatypes, setDatatypes] = useState<any[]>([]);
const [dtLoading, setDtLoading] = useState(false);
const [dtCount, setDtCount] = useState(0);
const [dtChecked, setDtChecked] = useState<string[]>([]);
const [dtModalOpen, setDtModalOpen] = useState(false);
const [dtEditMode, setDtEditMode] = useState(false);
const [dtForm, setDtForm] = useState<Record<string, any>>({});
const [dtSaving, setDtSaving] = useState(false);
const [dtKeyword, setDtKeyword] = useState("");
/* ───── 수집 설정 ───── */
const [configs, setConfigs] = useState<any[]>([]);
const [cfgLoading, setCfgLoading] = useState(false);
const [cfgCount, setCfgCount] = useState(0);
const [cfgChecked, setCfgChecked] = useState<string[]>([]);
const [cfgModalOpen, setCfgModalOpen] = useState(false);
const [cfgEditMode, setCfgEditMode] = useState(false);
const [cfgForm, setCfgForm] = useState<Record<string, any>>({});
const [cfgSaving, setCfgSaving] = useState(false);
const [cfgKeyword, setCfgKeyword] = useState("");
/* ───── FK + 카테고리 옵션 ───── */
const [equipOptions, setEquipOptions] = useState<{ code: string; label: string }[]>([]);
const [collectionTypeOptions, setCollectionTypeOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const load = async () => {
try {
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
} catch { /* skip */ }
try {
const catRes = await apiClient.get(`/table-categories/${COLLECTION_TABLE}/collection_type/values`);
if (catRes.data?.data?.length > 0) {
setCollectionTypeOptions(flattenCategories(catRes.data.data));
}
} catch { /* skip */ }
};
load();
}, []);
/* ═══════════════════ 데이터 조회 ═══════════════════ */
const fetchDatatypes = useCallback(async (keyword?: string) => {
setDtLoading(true);
try {
const kw = keyword !== undefined ? keyword : dtKeyword;
const filters: any[] = [];
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setDatatypes(rows);
setDtCount(rows.length);
} catch { toast.error("PLC 데이터타입 조회에 실패했어요"); }
finally { setDtLoading(false); }
}, [dtKeyword]);
const fetchConfigs = useCallback(async (keyword?: string) => {
setCfgLoading(true);
try {
const kw = keyword !== undefined ? keyword : cfgKeyword;
const filters: any[] = [];
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setConfigs(rows);
setCfgCount(rows.length);
} catch { toast.error("수집 설정 조회에 실패했어요"); }
finally { setCfgLoading(false); }
}, [cfgKeyword]);
useEffect(() => { fetchDatatypes(); fetchConfigs(); }, []);
/* ═══════════════════ 데이터타입 CRUD ═══════════════════ */
const openDtCreate = () => { setDtForm({}); setDtEditMode(false); setDtModalOpen(true); };
const openDtEdit = (row: any) => { setDtForm({ ...row }); setDtEditMode(true); setDtModalOpen(true); };
const saveDt = async () => {
if (!dtForm.equipment_code) { toast.error("설비코드는 필수 입력이에요"); return; }
setDtSaving(true);
try {
if (dtEditMode) {
await apiClient.put(`/table-management/tables/${DATATYPE_TABLE}/edit`, {
originalData: { id: dtForm.id }, updatedData: dtForm,
});
toast.success("PLC 데이터타입을 수정했어요");
} else {
await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/add`, dtForm);
toast.success("PLC 데이터타입을 등록했어요");
}
setDtModalOpen(false);
fetchDatatypes();
} catch { toast.error("저장에 실패했어요"); }
finally { setDtSaving(false); }
};
const deleteDt = async () => {
if (dtChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
const ok = await confirm("PLC 데이터타입 삭제", { description: `선택한 ${dtChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${DATATYPE_TABLE}/delete`, {
data: dtChecked.map(id => ({ id })),
});
toast.success(`${dtChecked.length}건을 삭제했어요`);
setDtChecked([]);
fetchDatatypes();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ 수집 설정 CRUD ═══════════════════ */
const openCfgCreate = () => { setCfgForm({}); setCfgEditMode(false); setCfgModalOpen(true); };
const openCfgEdit = (row: any) => { setCfgForm({ ...row }); setCfgEditMode(true); setCfgModalOpen(true); };
const saveCfg = async () => {
if (!cfgForm.config_name) { toast.error("설정명은 필수 입력이에요"); return; }
setCfgSaving(true);
try {
if (cfgEditMode) {
await apiClient.put(`/table-management/tables/${COLLECTION_TABLE}/edit`, {
originalData: { id: cfgForm.id }, updatedData: cfgForm,
});
toast.success("수집 설정을 수정했어요");
} else {
await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/add`, cfgForm);
toast.success("수집 설정을 등록했어요");
}
setCfgModalOpen(false);
fetchConfigs();
} catch { toast.error("저장에 실패했어요"); }
finally { setCfgSaving(false); }
};
const deleteCfg = async () => {
if (cfgChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
const ok = await confirm("수집 설정 삭제", { description: `선택한 ${cfgChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${COLLECTION_TABLE}/delete`, {
data: cfgChecked.map(id => ({ id })),
});
toast.success(`${cfgChecked.length}건을 삭제했어요`);
setCfgChecked([]);
fetchConfigs();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="border-b px-3">
<TabsList className="bg-transparent h-auto p-0 gap-0">
<TabsTrigger
value="datatype"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
>
<Cpu className="w-4 h-4 mr-2" />
PLC
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{dtCount}</Badge>
</TabsTrigger>
<TabsTrigger
value="collection"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
>
<Settings2 className="w-4 h-4 mr-2" />
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{cfgCount}</Badge>
</TabsTrigger>
</TabsList>
</div>
{/* ──── PLC 데이터타입 탭 ──── */}
<TabsContent value="datatype" className="p-3 mt-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 w-56 text-sm"
placeholder="설비코드 검색..."
value={dtKeyword}
onChange={(e) => setDtKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchDatatypes(dtKeyword)}
/>
</div>
<Button size="sm" variant="outline" className="h-8" onClick={() => fetchDatatypes(dtKeyword)}>
<Search className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setDtKeyword(""); fetchDatatypes(""); }}>
</Button>
<Badge variant="secondary" className="bg-primary/10 text-primary">{dtCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={openDtCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = datatypes.find(r => dtChecked.includes(r.id));
if (sel) openDtEdit(sel); else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={deleteDt}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="border rounded-lg overflow-hidden">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.key === "is_active" ? "center" : undefined,
render: col.key === "is_active"
? (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge>
: undefined,
}))}
data={ts.groupData(datatypes)}
loading={dtLoading}
emptyMessage="등록된 PLC 데이터타입이 없어요"
showCheckbox
checkedIds={dtChecked}
onCheckedChange={setDtChecked}
onRowDoubleClick={(row) => openDtEdit(row)}
showPagination={false}
draggableColumns={false}
/>
</div>
</TabsContent>
{/* ──── 수집 설정 탭 ──── */}
<TabsContent value="collection" className="p-3 mt-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 w-56 text-sm"
placeholder="설정명 검색..."
value={cfgKeyword}
onChange={(e) => setCfgKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchConfigs(cfgKeyword)}
/>
</div>
<Button size="sm" variant="outline" className="h-8" onClick={() => fetchConfigs(cfgKeyword)}>
<Search className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setCfgKeyword(""); fetchConfigs(""); }}>
</Button>
<Badge variant="secondary" className="bg-primary/10 text-primary">{cfgCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCfgCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = configs.find(r => cfgChecked.includes(r.id));
if (sel) openCfgEdit(sel); else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={deleteCfg}><Trash2 className="w-4 h-4 mr-1" /></Button>
</div>
</div>
<div className="border rounded-lg overflow-hidden">
<EDataTable
columns={[
{ key: "config_name", label: "설정명" },
{ key: "source_connection_id", label: "소스연결ID", width: "w-[110px]" },
{ key: "source_table", label: "소스테이블", width: "w-[120px]" },
{ key: "target_table", label: "대상테이블", width: "w-[120px]" },
{ key: "collection_type", label: "수집유형", width: "w-[90px]" },
{ key: "schedule_cron", label: "스케줄(Cron)", width: "w-[120px]", render: (val: any) => <span className="font-mono text-[13px]">{val}</span> },
{ key: "is_active", label: "사용여부", width: "w-[80px]", align: "center" as const, render: (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge> },
] as EDataTableColumn[]}
data={configs}
loading={cfgLoading}
emptyMessage="등록된 수집 설정이 없어요"
showCheckbox
checkedIds={cfgChecked}
onCheckedChange={setCfgChecked}
onRowDoubleClick={(row) => openCfgEdit(row)}
showPagination={false}
draggableColumns={false}
/>
</div>
</TabsContent>
</Tabs>
</div>
{/* ═══════════════════ PLC 데이터타입 모달 ═══════════════════ */}
<Dialog open={dtModalOpen} onOpenChange={setDtModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{dtEditMode ? "PLC 데이터타입 수정" : "PLC 데이터타입 등록"}</DialogTitle>
<DialogDescription>PLC </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Select value={dtForm.equipment_code || ""} onValueChange={(v) => setDtForm(p => ({ ...p, equipment_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="설비를 선택해주세요" /></SelectTrigger>
<SelectContent>
{equipOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={dtForm.data_type || ""} onChange={(e) => setDtForm(p => ({ ...p, data_type: e.target.value }))} placeholder="예: 온도, 압력" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={dtForm.unit || ""} onChange={(e) => setDtForm(p => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, bar" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={dtForm.tag_address || ""} onChange={(e) => setDtForm(p => ({ ...p, tag_address: e.target.value }))} placeholder="예: D100" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={dtForm.collection_interval || ""} onChange={(e) => setDtForm(p => ({ ...p, collection_interval: e.target.value }))} placeholder="예: 1000ms" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" type="number" value={dtForm.lower_limit ?? ""} onChange={(e) => setDtForm(p => ({ ...p, lower_limit: e.target.value }))} placeholder="하한값" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" type="number" value={dtForm.upper_limit ?? ""} onChange={(e) => setDtForm(p => ({ ...p, upper_limit: e.target.value }))} placeholder="상한값" />
</div>
<div className="space-y-1.5 col-span-2">
<div className="flex items-center gap-2">
<Checkbox checked={dtForm.is_active ?? true} onCheckedChange={(v) => setDtForm(p => ({ ...p, is_active: !!v }))} />
<Label className="text-sm"></Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDtModalOpen(false)}></Button>
<Button onClick={saveDt} disabled={dtSaving}>
{dtSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══════════════════ 수집 설정 모달 ═══════════════════ */}
<Dialog open={cfgModalOpen} onOpenChange={setCfgModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{cfgEditMode ? "수집 설정 수정" : "수집 설정 등록"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={cfgForm.config_name || ""} onChange={(e) => setCfgForm(p => ({ ...p, config_name: e.target.value }))} placeholder="설정명을 입력해주세요" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">ID</Label>
<Input className="h-9" value={cfgForm.source_connection_id || ""} onChange={(e) => setCfgForm(p => ({ ...p, source_connection_id: e.target.value }))} placeholder="소스연결ID" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
{collectionTypeOptions.length > 0 ? (
<Select value={cfgForm.collection_type || ""} onValueChange={(v) => setCfgForm(p => ({ ...p, collection_type: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
<SelectContent>
{collectionTypeOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-9" value={cfgForm.collection_type || ""} onChange={(e) => setCfgForm(p => ({ ...p, collection_type: e.target.value }))} placeholder="수집유형" />
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={cfgForm.source_table || ""} onChange={(e) => setCfgForm(p => ({ ...p, source_table: e.target.value }))} placeholder="소스테이블명" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={cfgForm.target_table || ""} onChange={(e) => setCfgForm(p => ({ ...p, target_table: e.target.value }))} placeholder="대상테이블명" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"> (Cron)</Label>
<Input className="h-9 font-mono text-sm" value={cfgForm.schedule_cron || ""} onChange={(e) => setCfgForm(p => ({ ...p, schedule_cron: e.target.value }))} placeholder="예: */5 * * * * (5분마다)" />
{cfgForm.schedule_cron && (
<p className="text-xs text-muted-foreground mt-1">{cronToKorean(cfgForm.schedule_cron)}</p>
)}
</div>
<div className="space-y-1.5 col-span-2">
<div className="flex items-center gap-2">
<Checkbox checked={cfgForm.is_active ?? true} onCheckedChange={(v) => setCfgForm(p => ({ ...p, is_active: !!v }))} />
<Label className="text-sm"></Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCfgModalOpen(false)}></Button>
<Button onClick={saveCfg} disabled={cfgSaving}>
{cfgSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}
@@ -1,424 +0,0 @@
"use client";
/**
*
*
* inventory_history +
* , ,
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Download, Loader2, Inbox, ChevronDown, ChevronRight,
ArrowDownToLine, ArrowUpFromLine, Package,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { exportToExcel } from "@/lib/utils/excelExport";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
const HISTORY_TABLE = "inventory_history";
const fmtNum = (v: any) => {
const n = Number(v);
return isNaN(n) ? "0" : n.toLocaleString();
};
const fmtDate = (v: any) => {
if (!v) return "-";
const s = String(v);
const m = s.match(/(\d{4})-(\d{2})-(\d{2})/);
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
};
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
const parseRemark = (remark: string | null | undefined): string => {
if (!remark) return "";
const trimmed = remark.trim();
if (!trimmed.startsWith("{")) return trimmed;
try {
const d = JSON.parse(trimmed);
switch (d.type) {
case "move":
return `창고이동 (${d.from_warehouse}${d.to_warehouse})`;
case "adjust":
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
case "confirm":
return `재고확인 (${d.reason || "이상없음"})`;
case "process_inbound":
return "공정입고";
default:
return d.reason || d.memo || trimmed;
}
} catch {
return trimmed;
}
};
export default function InboundOutboundPage() {
const { user } = useAuth();
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [typeFilter, setTypeFilter] = useState("all");
const [categoryFilter, setCategoryFilter] = useState("all");
const [groupBy, setGroupBy] = useState("none");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
// 품목명/단위 캐시
const [itemMap, setItemMap] = useState<Record<string, { item_name: string; unit: string }>>({});
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
const [userMap, setUserMap] = useState<Record<string, string>>({});
// ════════ 데이터 로드 ════════
const fetchData = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = [];
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
for (const f of searchFilters) {
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
}
const res = await apiClient.post(`/table-management/tables/${HISTORY_TABLE}/data`, {
page: 1, size: 1000,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "transaction_date", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setData(rows);
// 품목 정보 조회
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
if (itemCodes.length > 0) {
try {
// 단위 카테고리 코드→라벨 매핑 로드
let unitLabelMap: Record<string, string> = {};
try {
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
if (catRes.data?.success && catRes.data.data?.length > 0) {
const flatten = (vals: any[]) => {
for (const v of vals) {
unitLabelMap[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(catRes.data.data);
}
} catch { /* skip */ }
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: itemCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const map: Record<string, { item_name: string; unit: string }> = {};
for (const i of items) {
const rawUnit = i.unit || "";
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
}
setItemMap(map);
} catch { /* skip */ }
}
// 창고 정보 조회
try {
const whRes = await apiClient.post(`/table-management/tables/warehouse_info/data`, {
page: 1, size: 100, autoFilter: true,
});
const whs = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const whMap: Record<string, string> = {};
for (const w of whs) whMap[w.warehouse_code] = w.warehouse_name || w.warehouse_code;
setWarehouseMap(whMap);
} catch { /* skip */ }
// 사용자 정보 조회 (writer → user_name 변환)
const writerIds = [...new Set(rows.map((r: any) => r.writer).filter(Boolean))];
if (writerIds.length > 0) {
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
const uMap: Record<string, string> = {};
for (const u of users) uMap[u.user_id] = u.user_name || u.user_id;
setUserMap(uMap);
} catch { /* skip */ }
}
} catch {
toast.error("입출고 내역 조회 실패");
} finally {
setLoading(false);
}
}, [searchFilters, typeFilter]);
useEffect(() => { fetchData(); }, [fetchData]);
// ════════ 카테고리 목록 (remark에서 추출) ════════
const categoryOptions = useMemo(() => {
const set = new Set<string>();
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
return Array.from(set).sort();
}, [data]);
// ════════ 그룹핑 ════════
// 카테고리 필터 (클라이언트)
const filteredData = useMemo(() => {
if (categoryFilter === "all") return data;
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
}, [data, categoryFilter]);
const groupedData = useMemo(() => {
if (groupBy === "none") return filteredData;
const groups = new Map<string, any[]>();
for (const row of filteredData) {
let key: string;
switch (groupBy) {
case "transaction_type": key = row.transaction_type || "미지정"; break;
case "remark": key = parseRemark(row.remark) || "미지정"; break;
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
default: key = row[groupBy] || "미지정";
}
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(row);
}
const result: any[] = [];
groups.forEach((items, gk) => {
const totalQty = items.reduce((s, r) => s + (Number(r.quantity) || 0), 0);
result.push({ _group: true, _key: gk, _count: items.length, _totalQty: totalQty });
result.push(...items);
});
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
return result;
}, [data, groupBy, itemMap, warehouseMap]);
const toggleGroup = (key: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
};
// ════════ 체크박스 ════════
const allIds = data.map((r) => r.id);
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.has(id));
const toggleAll = (checked: boolean) => setCheckedIds(checked ? new Set(allIds) : new Set());
const toggleOne = (id: string) => {
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
// ════════ 엑셀 ════════
const handleExcel = async () => {
if (data.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
const rows = data.map((r, i) => ({
No: i + 1,
입출고구분: r.transaction_type || "",
카테고리: parseRemark(r.remark),
처리일자: fmtDate(r.transaction_date),
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
위치: r.location_code || "",
품목코드: r.item_code || "",
품목명: itemMap[r.item_code]?.item_name || "",
수량: Number(r.quantity) || 0,
단위: itemMap[r.item_code]?.unit || "",
로트번호: r.lot_number || "",
참조번호: r.reference_number || "",
담당자: r.manager_name || userMap[r.writer] || r.writer || "",
}));
const _n = new Date();
await exportToExcel(rows, `입출고관리_${_n.getFullYear()}${String(_n.getMonth() + 1).padStart(2, "0")}${String(_n.getDate()).padStart(2, "0")}.xlsx`, "입출고내역");
toast.success("다운로드 완료");
};
// ════════ 렌더 ════════
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="h-9 w-[130px] text-xs">
<SelectValue placeholder="입출고구분" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="입고"></SelectItem>
<SelectItem value="출고"></SelectItem>
</SelectContent>
</Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="h-9 w-[140px] text-xs">
<SelectValue placeholder="카테고리" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{categoryOptions.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
<div className="flex-1">
<DynamicSearchFilter
tableName={HISTORY_TABLE}
filterId="c16-inbound-outbound"
onFilterChange={setSearchFilters}
dataCount={data.length}
/>
</div>
</div>
{/* 데이터 테이블 */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
<SelectTrigger className="h-8 w-[130px] text-xs">
<SelectValue placeholder="그룹없음" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="transaction_type"></SelectItem>
<SelectItem value="remark"></SelectItem>
<SelectItem value="warehouse_code"></SelectItem>
<SelectItem value="item_code"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={handleExcel} disabled={data.length === 0}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : data.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<Inbox className="w-10 h-10 opacity-30" />
<span className="text-sm"> </span>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox checked={allChecked} onCheckedChange={(c) => toggleAll(!!c)} />
</TableHead>
<TableHead className="w-[90px] text-center text-[11px]"></TableHead>
<TableHead className="w-[100px] text-center text-[11px]"></TableHead>
<TableHead className="w-[95px] text-center text-[11px]"></TableHead>
<TableHead className="w-[100px] text-[11px]"></TableHead>
<TableHead className="w-[80px] text-center text-[11px]"></TableHead>
<TableHead className="w-[110px] text-[11px]"></TableHead>
<TableHead className="w-[160px] text-[11px]"></TableHead>
<TableHead className="w-[80px] text-right text-[11px]"></TableHead>
<TableHead className="w-[50px] text-center text-[11px]"></TableHead>
<TableHead className="w-[110px] text-[11px]"></TableHead>
<TableHead className="w-[100px] text-[11px]"></TableHead>
<TableHead className="w-[80px] text-center text-[11px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((row, idx) => {
if (row._group) {
const expanded = expandedGroups.has(row._key);
return (
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
<TableCell colSpan={8} className="py-2 px-4">
<div className="flex items-center gap-2 text-sm font-semibold">
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
{row._key}
<Badge variant="outline" className="text-[10px]">{row._count}</Badge>
</div>
</TableCell>
<TableCell className="text-right font-mono font-bold text-primary text-[13px]">
{fmtNum(row._totalQty)}
</TableCell>
<TableCell colSpan={4} />
</TableRow>
);
}
// 그룹 접힘 체크
if (groupBy !== "none") {
let gk: string;
switch (groupBy) {
case "warehouse_code": gk = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
case "item_code": gk = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
default: gk = row[groupBy] || "미지정";
}
if (!expandedGroups.has(gk)) return null;
}
const isIn = row.transaction_type === "입고";
const qty = Number(row.quantity) || 0;
const checked = checkedIds.has(row.id);
const info = itemMap[row.item_code];
return (
<TableRow key={row.id || idx} className={cn("hover:bg-accent/50", checked && "bg-primary/5")}>
<TableCell className="text-center">
<Checkbox checked={checked} onCheckedChange={() => toggleOne(row.id)} />
</TableCell>
<TableCell className="text-center">
<span className={cn(
"inline-flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full",
isIn ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"
)}>
{isIn ? <ArrowDownToLine className="w-3 h-3" /> : <ArrowUpFromLine className="w-3 h-3" />}
{row.transaction_type}
</span>
</TableCell>
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
<TableCell className="text-[12px] font-mono">{row.item_code || "-"}</TableCell>
<TableCell className="text-[13px]">{info?.item_name || "-"}</TableCell>
<TableCell className={cn("text-right font-mono font-semibold text-[13px]", isIn ? "text-emerald-600" : "text-amber-600")}>
{isIn ? "+" : ""}{fmtNum(qty)}
</TableCell>
<TableCell className="text-center text-[12px]">{info?.unit || "-"}</TableCell>
<TableCell className="text-[12px]">{row.lot_number || "-"}</TableCell>
<TableCell className="text-[12px] text-muted-foreground">{row.reference_number || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{row.manager_name || userMap[row.writer] || row.writer || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
}
@@ -1,904 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
Truck,
DollarSign,
FileText,
MapPin,
Car,
Plus,
Trash2,
Download,
Pencil,
RefreshCw,
Inbox,
Loader2,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { exportToExcel } from "@/lib/utils/excelExport";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
// ========== 타입 & 상수 ==========
type TabKey = "carrier" | "cost" | "contract" | "route" | "vehicle";
interface TabColumnDef {
key: string;
label: string;
width?: string;
align?: "left" | "center" | "right";
formatNumber?: boolean;
}
interface FormFieldDef {
key: string;
label: string;
type: "text" | "number" | "select" | "smartselect" | "date";
required?: boolean;
referenceKey?: "carrier" | "route";
categoryKey?: string;
options?: { value: string; label: string }[];
placeholder?: string;
}
interface TabConfig {
key: TabKey;
label: string;
icon: React.ReactNode;
tableName: string;
columns: TabColumnDef[];
formFields: FormFieldDef[];
defaultSortColumn: string;
}
const TAB_CONFIGS: TabConfig[] = [
{
key: "carrier",
label: "운송업체",
icon: <Truck className="h-3.5 w-3.5" />,
tableName: "carrier_mng",
defaultSortColumn: "carrier_code",
columns: [
{ key: "carrier_code", label: "업체코드", width: "120px" },
{ key: "carrier_name", label: "업체명", width: "160px" },
{ key: "carrier_type", label: "업체유형", width: "100px" },
{ key: "contact_person", label: "담당자", width: "100px" },
{ key: "contact_phone", label: "연락처", width: "130px" },
{ key: "email", label: "이메일", width: "180px" },
{ key: "address", label: "주소", width: "220px" },
{ key: "rating", label: "등급", width: "70px", align: "center" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "carrier_code", label: "업체코드", type: "text", required: true, placeholder: "업체코드를 입력해주세요" },
{ key: "carrier_name", label: "업체명", type: "text", required: true, placeholder: "업체명을 입력해주세요" },
{ key: "carrier_type", label: "업체유형", type: "select", required: true, categoryKey: "carrier_mng:carrier_type", placeholder: "업체유형을 선택해주세요" },
{ key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명" },
{ key: "contact_phone", label: "연락처", type: "text", placeholder: "예: 02-1234-5678" },
{ key: "email", label: "이메일", type: "text", placeholder: "email@example.com" },
{ key: "address", label: "주소", type: "text", placeholder: "주소를 입력해주세요" },
{ key: "rating", label: "등급", type: "select", categoryKey: "carrier_mng:rating", placeholder: "등급을 선택해주세요" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_mng:status", placeholder: "상태를 선택해주세요" },
],
},
{
key: "cost",
label: "물류비",
icon: <DollarSign className="h-3.5 w-3.5" />,
tableName: "logistics_cost_mng",
defaultSortColumn: "carrier_code",
columns: [
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "route_code", label: "구간코드", width: "120px" },
{ key: "base_fee", label: "기본요금", width: "110px", align: "right", formatNumber: true },
{ key: "unit", label: "단위", width: "70px", align: "center" },
{ key: "unit_fee", label: "단가", width: "110px", align: "right", formatNumber: true },
{ key: "min_weight", label: "최소중량", width: "100px", align: "right", formatNumber: true },
{ key: "max_weight", label: "최대중량", width: "100px", align: "right", formatNumber: true },
{ key: "delivery_days", label: "배송일수", width: "80px", align: "center" },
],
formFields: [
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
{ key: "route_code", label: "배송구간", type: "smartselect", required: true, referenceKey: "route" },
{ key: "base_fee", label: "기본요금", type: "number", placeholder: "0" },
{ key: "unit", label: "단위", type: "text", placeholder: "kg, 건 등" },
{ key: "unit_fee", label: "단가", type: "number", placeholder: "0" },
{ key: "min_weight", label: "최소중량", type: "number", placeholder: "0" },
{ key: "max_weight", label: "최대중량", type: "number", placeholder: "0" },
{ key: "delivery_days", label: "배송일수", type: "number", placeholder: "0" },
],
},
{
key: "contract",
label: "계약서",
icon: <FileText className="h-3.5 w-3.5" />,
tableName: "carrier_contract_mng",
defaultSortColumn: "contract_no",
columns: [
{ key: "contract_no", label: "계약번호", width: "130px" },
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "contract_start_date", label: "시작일", width: "110px" },
{ key: "contract_end_date", label: "종료일", width: "110px" },
{ key: "contract_amount", label: "계약금액", width: "130px", align: "right", formatNumber: true },
{ key: "contact_person", label: "담당자", width: "100px" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "contract_no", label: "계약번호", type: "text", required: true, placeholder: "계약번호를 입력해주세요" },
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
{ key: "contract_start_date", label: "시작일", type: "date", required: true },
{ key: "contract_end_date", label: "종료일", type: "date", required: true },
{ key: "contract_amount", label: "계약금액", type: "number", placeholder: "0" },
{ key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명을 입력해주세요" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_contract_mng:status", placeholder: "상태를 선택해주세요" },
],
},
{
key: "route",
label: "배송구간",
icon: <MapPin className="h-3.5 w-3.5" />,
tableName: "delivery_route_mng",
defaultSortColumn: "route_code",
columns: [
{ key: "route_code", label: "구간코드", width: "120px" },
{ key: "route_name", label: "구간명", width: "160px" },
{ key: "departure", label: "출발지", width: "120px" },
{ key: "destination", label: "도착지", width: "120px" },
{ key: "distance_km", label: "거리(km)", width: "100px", align: "right", formatNumber: true },
{ key: "avg_time_hours", label: "평균시간(h)", width: "100px", align: "right" },
{ key: "route_type", label: "구간유형", width: "100px" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "route_code", label: "구간코드", type: "text", required: true, placeholder: "구간코드를 입력해주세요" },
{ key: "departure", label: "출발지", type: "text", required: true, placeholder: "출발지" },
{ key: "destination", label: "도착지", type: "text", required: true, placeholder: "도착지" },
{ key: "distance_km", label: "거리(km)", type: "number", placeholder: "0" },
{ key: "avg_time_hours", label: "평균시간(h)", type: "number", placeholder: "0" },
{ key: "route_type", label: "구간유형", type: "select", categoryKey: "delivery_route_mng:route_type", placeholder: "유형을 선택해주세요" },
{ key: "status", label: "상태", type: "select", categoryKey: "delivery_route_mng:status", placeholder: "상태를 선택해주세요" },
],
},
{
key: "vehicle",
label: "차량",
icon: <Car className="h-3.5 w-3.5" />,
tableName: "carrier_vehicle_mng",
defaultSortColumn: "vehicle_code",
columns: [
{ key: "vehicle_code", label: "차량코드", width: "120px" },
{ key: "vehicle_number", label: "차량번호", width: "120px" },
{ key: "vehicle_type", label: "차량유형", width: "100px" },
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "load_capacity_kg", label: "적재용량(kg)", width: "120px", align: "right", formatNumber: true },
{ key: "driver_name", label: "운전자", width: "100px" },
{ key: "last_maintenance_date", label: "최종정비일", width: "110px" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "vehicle_code", label: "차량코드", type: "text", required: true, placeholder: "차량코드를 입력해주세요" },
{ key: "vehicle_number", label: "차량번호", type: "text", required: true, placeholder: "12가 3456" },
{ key: "vehicle_type", label: "차량유형", type: "select", required: true, categoryKey: "carrier_vehicle_mng:vehicle_type", placeholder: "차량유형을 선택해주세요" },
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
{ key: "load_capacity_kg", label: "적재용량(kg)", type: "number", placeholder: "0" },
{ key: "driver_name", label: "운전자", type: "text", placeholder: "운전자명" },
{ key: "last_maintenance_date", label: "최종정비일", type: "date" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_vehicle_mng:status", placeholder: "상태를 선택해주세요" },
],
},
];
// 카테고리 계층 평탄화
function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
}
walk(items);
return result;
}
// 채번 대상 필드 매핑: tableName → 코드 필드 key
const NUMBERING_FIELD_MAP: Record<string, string> = {
carrier_mng: "carrier_code",
delivery_route_mng: "route_code",
carrier_vehicle_mng: "vehicle_code",
};
// ========== 메인 컴포넌트 ==========
export default function LogisticsInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 탭 상태
const [activeTab, setActiveTab] = useState<TabKey>("carrier");
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 탭별 독립 상태
const [tabData, setTabData] = useState<Record<TabKey, any[]>>({
carrier: [], cost: [], contract: [], route: [], vehicle: [],
});
const [tabLoading, setTabLoading] = useState<Record<TabKey, boolean>>({
carrier: false, cost: false, contract: false, route: false, vehicle: false,
});
const [tabChecked, setTabChecked] = useState<Record<TabKey, string[]>>({
carrier: [], cost: [], contract: [], route: [], vehicle: [],
});
// FK 참조 데이터 (캐싱)
const [carrierOptions, setCarrierOptions] = useState<{ code: string; label: string }[]>([]);
const [routeOptions, setRouteOptions] = useState<{ code: string; label: string }[]>([]);
// 카테고리 옵션 캐시
const [categoryOptions, setCategoryOptions] = useState<Record<string, { value: string; label: string }[]>>({});
const loadedCategories = useRef(new Set<string>());
// 모달 상태
const [formOpen, setFormOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// 채번 시스템
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 테이블 설정 (탭별)
const tsCarrier = useTableSettings("c16-logistics-carrier", TAB_CONFIGS[0].tableName, TAB_CONFIGS[0].columns);
const tsCost = useTableSettings("c16-logistics-cost", TAB_CONFIGS[1].tableName, TAB_CONFIGS[1].columns);
const tsContract = useTableSettings("c16-logistics-contract", TAB_CONFIGS[2].tableName, TAB_CONFIGS[2].columns);
const tsRoute = useTableSettings("c16-logistics-route", TAB_CONFIGS[3].tableName, TAB_CONFIGS[3].columns);
const tsVehicle = useTableSettings("c16-logistics-vehicle", TAB_CONFIGS[4].tableName, TAB_CONFIGS[4].columns);
const tsMap: Record<TabKey, typeof tsCarrier> = { carrier: tsCarrier, cost: tsCost, contract: tsContract, route: tsRoute, vehicle: tsVehicle };
const activeTs = tsMap[activeTab];
const activeConfig = useMemo(
() => TAB_CONFIGS.find((c) => c.key === activeTab)!,
[activeTab]
);
// 컬럼 가시성 헬퍼
const getVisibleColumns = (tabKey: TabKey) => tsMap[tabKey].visibleColumns;
// 클라이언트 사이드 필터링
const filteredData = useMemo(() => {
const data = tabData[activeTab];
if (searchFilters.length === 0) return data;
return data.filter((row) =>
searchFilters.every((f) => {
if (!f.value) return true;
const kw = f.value.toLowerCase();
if (f.columnName) {
return String(row[f.columnName] ?? "").toLowerCase().includes(kw);
}
return Object.values(row).some((v) => String(v ?? "").toLowerCase().includes(kw));
})
);
}, [tabData, activeTab, searchFilters]);
// FK 참조 데이터 로드
const loadReferences = useCallback(async () => {
try {
const [carrierRes, routeRes] = await Promise.all([
apiClient.post("/table-management/tables/carrier_mng/data", {
page: 1, size: 500, autoFilter: true,
sort: { columnName: "carrier_code", order: "asc" },
}),
apiClient.post("/table-management/tables/delivery_route_mng/data", {
page: 1, size: 500, autoFilter: true,
sort: { columnName: "route_code", order: "asc" },
}),
]);
const carriers = carrierRes.data?.data?.data || carrierRes.data?.data?.rows || [];
setCarrierOptions(
carriers.map((r: any) => ({
code: r.carrier_code || "",
label: `${r.carrier_code} - ${r.carrier_name || ""}`,
}))
);
const routes = routeRes.data?.data?.data || routeRes.data?.data?.rows || [];
setRouteOptions(
routes.map((r: any) => ({
code: r.route_code || "",
label: `${r.route_code} - ${r.route_name || ""}`,
}))
);
} catch {
// FK 참조 로드 실패 시 무시
}
}, []);
useEffect(() => {
loadReferences();
}, [loadReferences]);
// 카테고리 옵션 로드
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
loadedCategories.current.add(tableColumn);
const [tableName, columnName] = tableColumn.split(":");
try {
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
[tableColumn]: data.length > 0 ? flattenCategories(data) : [],
}));
} catch {
setCategoryOptions((prev) => ({ ...prev, [tableColumn]: [] }));
}
}, []);
// 활성 탭의 카테고리 로드
useEffect(() => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
config.formFields.forEach((f) => {
if (f.categoryKey) loadCategoryOptions(f.categoryKey);
});
}, [activeTab, loadCategoryOptions]);
// 데이터 조회
const fetchTabData = useCallback(async (tab: TabKey) => {
const config = TAB_CONFIGS.find((c) => c.key === tab);
if (!config) return;
setTabLoading((prev) => ({ ...prev, [tab]: true }));
try {
const res = await apiClient.post(
`/table-management/tables/${config.tableName}/data`,
{
page: 1, size: 500, autoFilter: true,
sort: { columnName: config.defaultSortColumn, order: "asc" },
}
);
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setTabData((prev) => ({ ...prev, [tab]: rows }));
} catch {
toast.error("데이터를 불러오는 데 실패했어요.");
setTabData((prev) => ({ ...prev, [tab]: [] }));
} finally {
setTabLoading((prev) => ({ ...prev, [tab]: false }));
}
}, []);
// 초기 데이터 로드 (탭 전환 시)
useEffect(() => {
fetchTabData(activeTab);
}, [activeTab, fetchTabData]);
// 탭 변경
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab as TabKey);
setSearchFilters([]);
}, []);
// 등록 모달 열기
const handleOpenAdd = useCallback(async () => {
setEditMode(false);
setEditId(null);
setFormData({});
setPreviewCode(null);
setNumberingRuleId(null);
setFormOpen(true);
// 현재 탭의 채번 규칙 조회
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
const codeField = NUMBERING_FIELD_MAP[config.tableName];
if (!codeField) return; // 채번 대상이 아닌 탭
try {
const ruleRes = await apiClient.get(
`/numbering-rules/by-column/${config.tableName}/${codeField}`
);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch {
// 채번 규칙 없으면 무시 — 사용자가 직접 입력
}
}, [activeTab]);
// 수정 모달 열기
const handleOpenEdit = useCallback((row: any) => {
setEditMode(true);
setEditId(row.id ? String(row.id) : null);
setFormData({ ...row });
setFormOpen(true);
}, []);
// 저장
const handleSave = useCallback(async () => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
// 필수값 검증 (등록 모드에서 채번 대상 코드 필드는 자동 할당이므로 스킵)
const numberingCodeField = NUMBERING_FIELD_MAP[config.tableName];
for (const field of config.formFields) {
if (!editMode && numberingRuleId && field.key === numberingCodeField) continue;
if (field.required && !formData[field.key]?.toString().trim()) {
toast.error(`${field.label}은(는) 필수 입력이에요.`);
return;
}
}
try {
// 배송구간: 출발지→도착지 로 구간명 자동 생성
const saveData = { ...formData };
if (activeTab === "route" && saveData.departure && saveData.destination) {
saveData.route_name = `${saveData.departure}${saveData.destination}`;
}
if (editMode && editId) {
await apiClient.put(`/table-management/tables/${config.tableName}/edit`, {
originalData: { id: editId },
updatedData: saveData,
});
toast.success("수정이 완료되었어요.");
} else {
// 채번 규칙이 있으면 allocate로 실제 코드 할당
const codeField = NUMBERING_FIELD_MAP[config.tableName];
if (codeField && numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
saveData[codeField] = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
return;
}
}
await apiClient.post(
`/table-management/tables/${config.tableName}/add`,
{ id: crypto.randomUUID(), ...saveData }
);
toast.success("등록이 완료되었어요.");
}
setFormOpen(false);
fetchTabData(activeTab);
// FK 참조 테이블 변경 시 캐시 갱신
if (activeTab === "carrier" || activeTab === "route") {
loadReferences();
}
} catch (err: any) {
toast.error(err?.response?.data?.message || "저장에 실패했어요.");
}
}, [activeTab, editMode, editId, formData, fetchTabData, loadReferences, numberingRuleId]);
// 삭제
const handleDelete = useCallback(async () => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
const ids = tabChecked[activeTab];
if (ids.length === 0) {
toast.error("삭제할 항목을 선택해주세요.");
return;
}
const ok = await confirm(`선택한 ${ids.length}건을 삭제할까요?`, {
description: "삭제된 데이터는 복구할 수 없어요.",
variant: "destructive",
});
if (!ok) return;
try {
await apiClient.delete(
`/table-management/tables/${config.tableName}/delete`,
{ data: ids.map((id) => ({ id })) }
);
toast.success(`${ids.length}건이 삭제되었어요.`);
setTabChecked((prev) => ({ ...prev, [activeTab]: [] }));
fetchTabData(activeTab);
if (activeTab === "carrier" || activeTab === "route") {
loadReferences();
}
} catch {
toast.error("삭제에 실패했어요.");
}
}, [activeTab, tabChecked, confirm, fetchTabData, loadReferences]);
// 엑셀 다운로드 (필터된 데이터 기준)
const handleExcelDownload = useCallback(async () => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
if (filteredData.length === 0) {
toast.error("다운로드할 데이터가 없어요.");
return;
}
const exportData = filteredData.map((row) => {
const obj: Record<string, any> = {};
config.columns.forEach((col) => {
obj[col.label] = row[col.key] ?? "";
});
return obj;
});
await exportToExcel(exportData, `${config.label}.xlsx`, config.label);
toast.success("엑셀 다운로드가 완료되었어요.");
}, [activeTab, filteredData]);
// 폼 필드 변경
const updateFormField = useCallback((key: string, value: any) => {
setFormData((prev) => ({ ...prev, [key]: value }));
}, []);
// 행 체크 토글
const toggleRowCheck = useCallback((tabKey: TabKey, rowId: string) => {
setTabChecked((prev) => {
const ids = prev[tabKey];
return {
...prev,
[tabKey]: ids.includes(rowId) ? ids.filter((x) => x !== rowId) : [...ids, rowId],
};
});
}, []);
// 전체 체크 토글
const toggleAllCheck = useCallback((tabKey: TabKey, checked: boolean) => {
setTabChecked((prev) => ({
...prev,
[tabKey]: checked ? tabData[tabKey].map((r: any) => String(r.id)) : [],
}));
}, [tabData]);
// 폼 필드 렌더
const renderFormField = useCallback(
(field: FormFieldDef) => {
const value = formData[field.key] ?? "";
// 현재 탭의 채번 대상 코드 필드인지 확인
const numberingCodeField = NUMBERING_FIELD_MAP[activeConfig.tableName];
const isNumberingTarget = !editMode && numberingRuleId && field.key === numberingCodeField;
// 수정 모드에서 코드/번호 필드는 읽기전용
const isCodeField =
editMode &&
field.type === "text" &&
(field.key.endsWith("_code") || field.key.endsWith("_no"));
switch (field.type) {
case "text":
// 등록 모드 + 채번 대상 필드: readOnly로 미리보기 코드 표시
if (isNumberingTarget) {
return (
<Input
value={previewCode || ""}
readOnly
placeholder="채번 조회 중..."
className="h-9 text-sm bg-muted text-muted-foreground"
/>
);
}
return (
<Input
value={value}
onChange={(e) => updateFormField(field.key, e.target.value)}
placeholder={field.placeholder}
readOnly={isCodeField}
className={cn(
"h-9 text-sm",
isCodeField && "bg-muted text-muted-foreground"
)}
/>
);
case "number":
return (
<Input
type="number"
value={value}
onChange={(e) => updateFormField(field.key, e.target.value)}
placeholder={field.placeholder}
className="h-9 text-sm"
/>
);
case "select": {
const opts =
field.options ||
(field.categoryKey ? categoryOptions[field.categoryKey] : []) ||
[];
return (
<Select
value={String(value)}
onValueChange={(v) => updateFormField(field.key, v)}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={field.placeholder || "선택해주세요"} />
</SelectTrigger>
<SelectContent>
{opts.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
case "smartselect": {
// SmartSelect 대신 Select로 직접 구현
const opts =
field.referenceKey === "carrier" ? carrierOptions : routeOptions;
return (
<Select
value={String(value)}
onValueChange={(v) => updateFormField(field.key, v)}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={field.placeholder || "선택해주세요"} />
</SelectTrigger>
<SelectContent>
{opts.map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
case "date":
return (
<Input
type="date"
value={value ? String(value).split("T")[0] : ""}
onChange={(e) => updateFormField(field.key, e.target.value)}
className="h-9 text-sm"
/>
);
default:
return null;
}
},
[formData, editMode, carrierOptions, routeOptions, categoryOptions, updateFormField, activeConfig, numberingRuleId, previewCode]
);
// ========== 렌더링 ==========
return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
{/* 검색 필터 바 */}
<DynamicSearchFilter
tableName={activeConfig.tableName}
filterId="c16-logistics-info"
onFilterChange={setSearchFilters}
externalFilterConfig={activeTs.filterConfig}
dataCount={filteredData.length}
/>
{/* 탭 + 콘텐츠 영역 */}
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card"
>
<TabsList className="h-auto w-full shrink-0 justify-start gap-0 rounded-none border-b bg-muted/30 p-0">
{TAB_CONFIGS.map((tab) => (
<TabsTrigger
key={tab.key}
value={tab.key}
className="flex items-center gap-1.5 rounded-none border-b-2 border-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground data-[state=active]:border-primary data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
{tab.icon}
{tab.label}
<Badge
variant="outline"
className="ml-1 h-5 min-w-[22px] justify-center px-1.5 font-mono text-[10px]"
>
{tabData[tab.key]?.length || 0}
</Badge>
</TabsTrigger>
))}
</TabsList>
{TAB_CONFIGS.map((tab) => {
const displayData = tab.key === activeTab ? filteredData : tabData[tab.key];
const isAllChecked =
tabData[tab.key].length > 0 &&
tabData[tab.key].every((r: any) => tabChecked[tab.key].includes(String(r.id)));
return (
<TabsContent
key={tab.key}
value={tab.key}
className="m-0 flex flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
>
{/* 액션 바 */}
<div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold">{tab.label} </h2>
<Badge className="bg-primary/10 font-mono text-[11px] text-primary">
{tab.key === activeTab ? displayData.length : tabData[tab.key]?.length || 0}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-8 text-xs" onClick={handleOpenAdd}>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
disabled={tabChecked[tab.key].length !== 1}
onClick={() => {
const row = tabData[tab.key].find(
(r: any) => String(r.id) === tabChecked[tab.key][0]
);
if (row) handleOpenEdit(row);
}}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs text-destructive hover:bg-destructive/10"
disabled={tabChecked[tab.key].length === 0}
onClick={handleDelete}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
<div className="mx-1 h-5 w-px bg-border" />
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={handleExcelDownload}
>
<Download className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => fetchTabData(tab.key)}
>
<RefreshCw
className={cn(
"h-3.5 w-3.5",
tabLoading[tab.key] && "animate-spin"
)}
/>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => activeTs.setOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
}))}
data={tsMap[tab.key].groupData(displayData)}
rowKey={(row: any) => String(row.id)}
loading={tabLoading[tab.key]}
emptyMessage={`등록된 ${tab.label} 정보가 없어요`}
showCheckbox
checkedIds={tabChecked[tab.key]}
onCheckedChange={(ids) => setTabChecked((prev) => ({ ...prev, [tab.key]: ids }))}
onRowDoubleClick={(row) => handleOpenEdit(row)}
showPagination={false}
draggableColumns={false}
/>
</div>
</TabsContent>
);
})}
</Tabs>
{/* 등록/수정 모달 */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[680px]">
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle>
{activeConfig.label} {editMode ? "수정" : "등록"}
</DialogTitle>
<DialogDescription>
{editMode
? `${activeConfig.label} 정보를 수정해주세요.`
: `${activeConfig.label} 정보를 입력해주세요.`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-2 gap-4">
{activeConfig.formFields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{field.label}
{field.required && (
<span className="ml-0.5 text-destructive">*</span>
)}
</Label>
{renderFormField(field)}
</div>
))}
</div>
</div>
<div className="shrink-0 border-t">
<div className="flex items-center justify-end gap-2 px-6 py-3">
<Button variant="outline" onClick={() => setFormOpen(false)}>
</Button>
<Button onClick={handleSave}>
{editMode ? "수정" : "등록"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={activeTs.open}
onOpenChange={activeTs.setOpen}
tableName={activeTs.tableName}
settingsId={activeTs.settingsId}
defaultVisibleKeys={activeTs.defaultVisibleKeys}
onSave={activeTs.applySettings}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -1,761 +0,0 @@
"use client";
/**
* (Type B -)
*
* 좌측: 재고 (inventory_stock, item_info JOIN)
* 우측: 선택 (inventory_history)
*
* / ,
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Package,
Loader2,
Download,
ClipboardEdit,
History,
AlertTriangle,
RefreshCw,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { toast } from "sonner";
import { exportToExcel } from "@/lib/utils/excelExport";
const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
{ key: "safety_qty", label: "안전재고", align: "right" as const },
{ key: "unit", label: "단위" },
{ key: "status", label: "상태" },
];
const HISTORY_TABLE = "inventory_history";
const getStatusVariant = (
status: string
): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case "정상":
return "default";
case "부족":
return "destructive";
case "과잉":
return "secondary";
default:
return "outline";
}
};
const getHistoryTypeVariant = (
type: string
): "default" | "secondary" | "outline" | "destructive" => {
switch (type) {
case "입고":
return "default";
case "출고":
return "secondary";
case "조정":
return "outline";
case "입고취소":
case "이동":
return "destructive";
default:
return "outline";
}
};
export default function InventoryStatusPage() {
const { user } = useAuth();
const ts = useTableSettings("c16-inventory", STOCK_TABLE, STOCK_COLUMNS);
// 좌측: 재고 목록
const [stockItems, setStockItems] = useState<any[]>([]);
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 우측: 이동 이력
const [historyItems, setHistoryItems] = useState<any[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
// 조정 모달
const [adjustModalOpen, setAdjustModalOpen] = useState(false);
const [adjustForm, setAdjustForm] = useState<{
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
Record<string, { code: string; label: string }[]>
>({});
// 사용자 맵 (writer → 이름)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 카테고리 + 사용자 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// inventory_stock 카테고리
for (const col of ["status"]) {
try {
const res = await apiClient.get(`/table-categories/${STOCK_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
// item_info 단위 카테고리
try {
const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_10");
if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []);
} catch { /* skip */ }
setCategoryOptions(optMap);
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
const name = u.user_name || u.name || id;
if (id) map[id] = name;
}
setUserMap(map);
}).catch(() => {});
}, []);
// 재고 목록 조회
const fetchStock = useCallback(async () => {
setStockLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const [stockRes, itemRes, whRes] = await Promise.all([
apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "item_code", order: "asc" },
}),
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }),
]);
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.unit || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const data = raw.map((r: any) => {
const itemInfo = itemMap.get(r.item_code) as any;
const rawUnit = itemInfo?.unit || r.unit || "";
return {
...r,
item_name: itemInfo?.name || "",
unit: resolve("item_unit", rawUnit) || rawUnit,
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
status: resolve("status", r.status),
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
useEffect(() => {
fetchStock();
}, [fetchStock]);
// 선택된 재고
const selectedStock = stockItems.find((s) => s.id === selectedStockId);
// 이력 조회
const fetchHistory = useCallback(async () => {
if (!selectedStock?.item_code) {
setHistoryItems([]);
return;
}
setHistoryLoading(true);
try {
const historyFilters: any[] = [
{
columnName: "item_code",
operator: "equals",
value: selectedStock.item_code,
},
];
if (selectedStock.warehouse_code) {
historyFilters.push({
columnName: "warehouse_code",
operator: "equals",
value: selectedStock.warehouse_code,
});
}
const res = await apiClient.post(
`/table-management/tables/${HISTORY_TABLE}/data`,
{
page: 1,
size: 500,
dataFilter: { enabled: true, filters: historyFilters },
autoFilter: true,
sort: { columnName: "transaction_date", order: "desc" },
}
);
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setHistoryItems(raw);
} catch {
toast.error("재고 이력을 불러오지 못했어요");
} finally {
setHistoryLoading(false);
}
}, [selectedStock?.item_code, selectedStock?.warehouse_code]);
useEffect(() => {
fetchHistory();
}, [fetchHistory]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
const qty = Number(adjustForm.adjust_qty);
if (!qty || qty <= 0) {
toast.error("조정 수량을 입력해주세요");
return;
}
if (!adjustForm.reason.trim()) {
toast.error("조정 사유를 입력해주세요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
const afterQty = Number(selectedStock.current_qty || 0) + changeQty;
await apiClient.post(
`/table-management/tables/${HISTORY_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
transaction_type: "조정",
transaction_date: new Date().toISOString(),
quantity: String(changeQty),
balance_qty: String(afterQty),
remark: adjustForm.reason.trim(),
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
} finally {
setAdjustSaving(false);
}
};
// EDataTable 컬럼 정의
const stockColumns: EDataTableColumn[] = ts.visibleColumns.map((col) => {
const base: EDataTableColumn = { key: col.key, label: col.label, align: col.align };
if (col.key === "current_qty") {
return {
...base,
align: "right" as const,
render: (val: any, row: any) => (
<span className="font-mono">
<span className={cn(row._isLow && "text-destructive font-bold")}>
{Number(row.current_qty || 0).toLocaleString()}
</span>
{row._isLow && (
<AlertTriangle className="inline h-3 w-3 text-destructive ml-1" />
)}
</span>
),
};
}
if (col.key === "warehouse_code") {
return {
...base,
render: (_val: any, row: any) => row.warehouse_name || row.warehouse_code || "",
};
}
if (col.key === "safety_qty") {
return {
...base,
align: "right" as const,
formatNumber: true,
};
}
if (col.key === "status") {
return {
...base,
render: (val: any) => (
<Badge variant={getStatusVariant(val)} className="text-[10px]">
{val}
</Badge>
),
};
}
return base;
});
// 엑셀 내보내기
const handleExcelExport = () => {
if (stockItems.length === 0) {
toast.error("내보낼 데이터가 없어요");
return;
}
exportToExcel(
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
안전재고: r.safety_qty,
단위: r.unit,
상태: r.status,
})),
"재고현황"
);
};
return (
<div className="flex flex-col h-full gap-3 p-3">
{/* 검색 바 */}
<DynamicSearchFilter
tableName={STOCK_TABLE}
filterId="c16-inventory"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={stockItems.length}
extraActions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
onClick={handleExcelExport}
>
<Download className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-3.5 w-3.5" />
</Button>
</div>
}
/>
{/* 마스터-디테일 패널 */}
<ResizablePanelGroup
direction="horizontal"
className="flex-1 rounded-lg border bg-card"
>
{/* 좌측: 재고 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<div className="flex items-center gap-2">
<span className="text-[13px] font-bold"> </span>
<Badge variant="default" className="rounded-full text-[11px]">
{stockItems.length}
</Badge>
</div>
</div>
<EDataTable
columns={stockColumns}
data={ts.groupData(stockItems)}
rowKey={(row) => row.id}
loading={stockLoading}
emptyMessage="등록된 재고가 없어요"
selectedId={selectedStockId}
onSelect={(id) => setSelectedStockId(id)}
showRowNumber
showPagination={false}
draggableColumns={false}
columnOrderKey="c16-inventory"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 상세 이력 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
{!selectedStock ? (
<div className="flex flex-col items-center justify-center flex-1 m-5 border-2 border-dashed rounded-lg border-border">
<Package className="h-12 w-12 text-muted-foreground/40 mb-4" />
<p className="text-sm font-semibold text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
</p>
</div>
) : (
<>
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" />
<span className="text-[13px] font-bold">
{selectedStock.item_name || selectedStock.item_code}
</span>
<Badge
variant="outline"
className="rounded-full text-[11px] font-mono"
>
{selectedStock.item_code}
</Badge>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">:</span>
<span
className={cn(
"font-bold font-mono",
selectedStock._isLow
? "text-destructive"
: "text-foreground"
)}
>
{Number(selectedStock.current_qty || 0).toLocaleString()}
</span>
{selectedStock._isLow && (
<AlertTriangle className="h-3.5 w-3.5 text-destructive" />
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => {
setAdjustForm({
adjust_type: "증가",
adjust_qty: "",
reason: "",
});
setAdjustModalOpen(true);
}}
>
<ClipboardEdit className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 재고 요약 카드 */}
<div className="grid grid-cols-4 gap-2 px-4 py-3 border-b">
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-sm font-bold font-mono">
{Number(selectedStock.current_qty || 0).toLocaleString()}
</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-sm font-bold font-mono">
{Number(selectedStock.safety_qty || 0).toLocaleString()}
</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-sm font-bold truncate max-w-full">
{selectedStock.warehouse_name || "-"}
</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<Badge
variant={getStatusVariant(selectedStock.status)}
className="text-[10px] mt-0.5"
>
{selectedStock.status || "-"}
</Badge>
</div>
</div>
{/* 이력 서브헤더 */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">
</span>
<Badge variant="secondary" className="rounded-full text-[10px]">
{historyItems.length}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs"
onClick={fetchHistory}
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
{/* 이력 테이블 */}
<div className="flex-1 overflow-auto">
{historyLoading ? (
<div className="flex items-center justify-center h-20">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : historyItems.length === 0 ? (
<div className="flex items-center justify-center h-20 text-xs text-muted-foreground">
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{historyItems.map((h, idx) => (
<TableRow key={h.id || idx} className="text-xs">
<TableCell className="text-center text-muted-foreground">
{idx + 1}
</TableCell>
<TableCell className="font-mono">
{h.transaction_date ? String(h.transaction_date).slice(0, 10) : h.history_date || ""}
</TableCell>
<TableCell>
<Badge
variant={getHistoryTypeVariant(h.transaction_type || h.history_type)}
className="text-[10px]"
>
{h.transaction_type || h.history_type}
</Badge>
</TableCell>
<TableCell
className={cn(
"text-right font-mono",
Number(h.quantity ?? h.change_qty) > 0
? "text-primary"
: "text-destructive"
)}
>
{Number(h.quantity ?? h.change_qty) > 0 ? "+" : ""}
{Number(h.quantity ?? h.change_qty ?? 0).toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono">
{Number(h.balance_qty ?? h.after_qty ?? 0).toLocaleString()}
</TableCell>
<TableCell className="font-mono truncate max-w-[120px]">
{h.reference_number || h.reference_no || ""}
</TableCell>
<TableCell className="truncate max-w-[150px]">
{h.remark || h.reason || ""}
</TableCell>
<TableCell>{userMap[h.writer] || userMap[h.created_by] || h.writer || h.created_by || ""}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{/* 재고 조정 Dialog */}
<Dialog open={adjustModalOpen} onOpenChange={setAdjustModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{selectedStock
? `${selectedStock.item_name || selectedStock.item_code} — 현재 수량: ${Number(selectedStock.current_qty || 0).toLocaleString()}`
: ""}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.adjust_type}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="증가"> ( )</SelectItem>
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Input
type="number"
min={1}
placeholder="수량을 입력해주세요"
value={adjustForm.adjust_qty}
onChange={(e) =>
setAdjustForm((prev) => ({
...prev,
adjust_qty: e.target.value,
}))
}
/>
{adjustForm.adjust_qty && selectedStock && (
<p className="text-xs text-muted-foreground">
:{" "}
<span className="font-mono font-bold">
{(
Number(selectedStock.current_qty || 0) +
(adjustForm.adjust_type === "증가" ? 1 : -1) *
Number(adjustForm.adjust_qty || 0)
).toLocaleString()}
</span>
</p>
)}
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
*
</Label>
<Textarea
placeholder="조정 사유를 입력해주세요"
rows={3}
value={adjustForm.reason}
onChange={(e) =>
setAdjustForm((prev) => ({
...prev,
reason: e.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAdjustModalOpen(false)}
>
</Button>
<Button onClick={handleAdjustSave} disabled={adjustSaving}>
{adjustSaving && (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,661 +0,0 @@
"use client";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Card 제거 — rounded-lg border bg-card 패턴 사용
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Search,
RotateCcw,
Package,
ClipboardList,
Factory,
MapPin,
AlertTriangle,
CheckCircle2,
Loader2,
Inbox,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
getWorkOrders,
getMaterialStatus,
getWarehouses,
type WorkOrder,
type MaterialData,
type WarehouseData,
} from "@/lib/api/materialStatus";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { apiClient } from "@/lib/api/client";
const GRID_COLUMNS = [
{ key: "plan_no", label: "계획번호" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "plan_qty", label: "수량" },
{ key: "plan_date", label: "일자" },
{ key: "status", label: "상태" },
];
const formatDate = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
const FALLBACK_STATUS_MAP: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
const STATUS_STYLE_MAP: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
// 카테고리 라벨 기반으로 스타일 매칭
const LABEL_STYLE_MAP: Record<string, string> = {
"일반": "bg-secondary text-secondary-foreground border-border",
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
"계획": "bg-secondary text-secondary-foreground border-border",
"대기": "bg-secondary text-secondary-foreground border-border",
"진행중": "bg-primary/10 text-primary border-primary/20",
"완료": "bg-accent text-accent-foreground border-accent/50",
"취소": "bg-muted text-muted-foreground border-border",
};
export default function MaterialStatusPage() {
const ts = useTableSettings("c16-material-status", "work_instruction", GRID_COLUMNS);
const today = new Date();
const monthAgo = new Date(today);
monthAgo.setMonth(today.getMonth() - 1);
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
// 카테고리 코드→라벨 매핑
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await apiClient.get("/table-categories/work_instruction/status/values");
if (res.data?.success && res.data.data?.length > 0) {
const map: Record<string, string> = {};
const flatten = (vals: any[]) => {
for (const v of vals) {
map[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(res.data.data);
setStatusMap(map);
}
} catch { /* ignore */ }
})();
}, []);
const getStatusLabel = useCallback((status: string) => {
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
}, [statusMap]);
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
const [warehouse, setWarehouse] = useState("");
const [materialSearch, setMaterialSearch] = useState("");
const [showShortageOnly, setShowShortageOnly] = useState(false);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [materialsLoading, setMaterialsLoading] = useState(false);
// 창고 목록 초기 로드
useEffect(() => {
(async () => {
const res = await getWarehouses();
if (res.success && res.data) {
setWarehouses(res.data);
}
})();
}, []);
// 작업지시 검색
const handleSearch = useCallback(async () => {
setWorkOrdersLoading(true);
try {
const res = await getWorkOrders({
dateFrom: searchDateFrom,
dateTo: searchDateTo,
itemCode: searchItemCode || undefined,
itemName: searchItemName || undefined,
});
if (res.success && res.data) {
setWorkOrders(res.data);
setCheckedWoIds([]);
setSelectedWoId(null);
setMaterials([]);
}
} finally {
setWorkOrdersLoading(false);
}
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
// 초기 로드
useEffect(() => {
handleSearch();
}, []);
const isAllChecked =
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
const handleCheckAll = useCallback(
(checked: boolean) => {
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
},
[workOrders]
);
const handleCheckWo = useCallback((id: string, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: string) => {
setSelectedWoId((prev) => (prev === id ? null : id));
}, []);
// 선택된 작업지시의 자재 조회
const handleLoadSelectedMaterials = useCallback(async () => {
if (checkedWoIds.length === 0) {
alert("자재를 조회할 작업지시를 선택해주세요.");
return;
}
setMaterialsLoading(true);
try {
const res = await getMaterialStatus({
planIds: checkedWoIds,
warehouseCode: warehouse || undefined,
});
if (res.success && res.data) {
setMaterials(res.data);
}
} finally {
setMaterialsLoading(false);
}
}, [checkedWoIds, warehouse]);
const handleResetSearch = useCallback(() => {
const t = new Date();
const m = new Date(t);
m.setMonth(t.getMonth() - 1);
setSearchDateFrom(formatDate(m));
setSearchDateTo(formatDate(t));
setSearchItemCode("");
setSearchItemName("");
setMaterialSearch("");
setShowShortageOnly(false);
}, []);
const filteredMaterials = useMemo(() => {
return materials.filter((m) => {
const searchLower = materialSearch.toLowerCase();
const matchesSearch =
!materialSearch ||
m.code.toLowerCase().includes(searchLower) ||
m.name.toLowerCase().includes(searchLower);
const matchesShortage = !showShortageOnly || m.current < m.required;
return matchesSearch && matchesShortage;
});
}, [materials, materialSearch, showShortageOnly]);
return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
{/* 검색 영역 */}
<div className="shrink-0 flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<div className="flex items-center gap-1.5">
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
/>
<span className="text-muted-foreground/50 text-xs">~</span>
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
placeholder="품목코드"
className="h-9 w-[140px]"
value={searchItemCode}
onChange={(e) => setSearchItemCode(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
placeholder="품목명"
className="h-9 w-[140px]"
value={searchItemName}
onChange={(e) => setSearchItemName(e.target.value)}
/>
</div>
<div className="flex-1" />
<div className="flex items-end gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleResetSearch}
>
<RotateCcw className="mr-1 h-4 w-4" />
</Button>
<Button
size="sm"
className="h-9"
onClick={handleSearch}
disabled={workOrdersLoading}
>
{workOrdersLoading ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Search className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</div>
{/* 메인 콘텐츠 (좌우 분할) */}
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
<ResizablePanelGroup direction="horizontal">
{/* 왼쪽: 작업지시 리스트 */}
<ResizablePanel defaultSize={35} minSize={25}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5 shrink-0">
<div className="flex items-center gap-2">
<Checkbox
checked={isAllChecked}
onCheckedChange={handleCheckAll}
/>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{workOrders.length}
</span>
<Button
size="sm"
className="h-8"
onClick={handleLoadSelectedMaterials}
disabled={materialsLoading}
>
{materialsLoading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Search className="mr-1.5 h-3.5 w-3.5" />
)}
</Button>
<Button variant="ghost" size="sm" className="h-8" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 작업지시 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{workOrdersLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
...
</p>
</div>
) : workOrders.length === 0 ? (
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
ts.groupData(workOrders).map((wo) => {
if ((wo as any)._isGroupSummary || (wo as any)._isGroupHeader) return null;
return (
<div
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
"hover:border-primary/50 hover:shadow-sm",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-sm"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
{ts.isVisible("plan_no") && (
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
)}
{ts.isVisible("status") && (
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
)}
>
{getStatusLabel(wo.status)}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{ts.isVisible("item_name") && (
<span className="text-sm font-semibold">
{wo.item_name}
</span>
)}
{ts.isVisible("item_code") && (
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{ts.isVisible("plan_qty") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
</>
)}
{ts.isVisible("plan_qty") && ts.isVisible("plan_date") && (
<span className="mx-1">|</span>
)}
{ts.isVisible("plan_date") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
</>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 원자재 현황 */}
<ResizablePanel defaultSize={65} minSize={35}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center gap-2 border-b bg-muted/30 px-3 py-2.5 shrink-0">
<Factory className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
{/* 필터 */}
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/30 px-4 py-2.5 shrink-0">
<Input
placeholder="원자재 검색"
className="h-8 min-w-[150px] flex-1 text-xs"
value={materialSearch}
onChange={(e) => setMaterialSearch(e.target.value)}
/>
<Select value={warehouse} onValueChange={setWarehouse}>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue placeholder="전체 창고" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{warehouses.map((wh) => (
<SelectItem
key={wh.warehouse_code}
value={wh.warehouse_code}
>
{wh.warehouse_name}
{wh.warehouse_type
? ` (${wh.warehouse_type})`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium">
<Checkbox
checked={showShortageOnly}
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
/>
<span> </span>
</label>
<span className="ml-auto rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{filteredMaterials.length}
</span>
</div>
{/* 원자재 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{materialsLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
...
</p>
</div>
) : materials.length === 0 ? (
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : filteredMaterials.length === 0 ? (
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
<Package className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
filteredMaterials.map((material) => {
const shortage = material.required - material.current;
const isShortage = shortage > 0;
const percentage =
material.required > 0
? Math.min(
(material.current / material.required) * 100,
100
)
: 100;
return (
<div
key={material.code}
className={cn(
"rounded-lg border p-3 transition-all hover:shadow-sm",
isShortage
? "border-destructive/30 bg-destructive/5"
: "border-primary/15 bg-primary/5"
)}
>
{/* 메인 정보 라인 */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold">
{material.name}
</span>
<span className="text-xs text-muted-foreground">
({material.code})
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span className="text-xs font-semibold text-primary">
{material.required.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-foreground"
)}
>
{material.current.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
{isShortage ? "부족:" : "여유:"}
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-primary"
)}
>
{Math.abs(shortage).toLocaleString()}
{material.unit}
</span>
<span className="text-xs font-semibold text-muted-foreground">
({percentage.toFixed(0)}%)
</span>
{isShortage ? (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-destructive bg-destructive/10 px-2 py-0.5 text-[11px] font-semibold text-destructive">
<AlertTriangle className="h-3 w-3" />
</span>
) : (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-primary bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
</div>
{/* 위치별 재고 */}
{material.locations.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
{material.locations.map((loc, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
{material.unit}
</span>
</span>
))}
</div>
)}
</div>
);
})
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,759 +0,0 @@
"use client";
/**
* Type D (2)
*
* Tab 1: 회사정보 (company_mng )
* Tab 2: 부서관리 (dept_info + user_info )
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Building2, Users, Pencil, Save, Loader2, Plus, Trash2,
Upload, X, Image as ImageIcon, ChevronRight, FolderOpen, Folder,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import * as departmentAPI from "@/lib/api/department";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const COMPANY_TABLE = "company_mng";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
/* ── 트리 노드 타입 ── */
interface DeptNode {
dept_code: string;
dept_name: string;
parent_dept_code: string | null;
status?: string;
children: DeptNode[];
}
export default function CompanyPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
/* ===================== Tab 1: 회사정보 ===================== */
const [companyData, setCompanyData] = useState<Record<string, any>>({});
const [companyForm, setCompanyForm] = useState<Record<string, any>>({});
const [companyLoading, setCompanyLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
// 이미지 업로드 refs
const imageRef = useRef<HTMLInputElement>(null);
const logoRef = useRef<HTMLInputElement>(null);
const sealRef = useRef<HTMLInputElement>(null);
// 이미지 미리보기
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [sealPreview, setSealPreview] = useState<string | null>(null);
const fetchCompany = useCallback(async () => {
setCompanyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/data`, {
page: 1, size: 1, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
if (rows.length > 0) {
setCompanyData(rows[0]);
setCompanyForm(rows[0]);
if (rows[0].company_image) setImagePreview(rows[0].company_image);
if (rows[0].company_logo) setLogoPreview(rows[0].company_logo);
if (rows[0].company_seal) setSealPreview(rows[0].company_seal);
}
} catch {
toast.error("회사 정보를 불러오는데 실패했어요.");
} finally {
setCompanyLoading(false);
}
}, []);
useEffect(() => { fetchCompany(); }, [fetchCompany]);
const handleImageUpload = (
e: React.ChangeEvent<HTMLInputElement>,
field: string,
setPreview: React.Dispatch<React.SetStateAction<string | null>>,
) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setPreview(result);
setCompanyForm((prev) => ({ ...prev, [field]: result }));
};
reader.readAsDataURL(file);
};
const handleCompanySave = async () => {
if (!companyForm.company_name) {
toast.error("회사명은 필수예요.");
return;
}
setSaving(true);
try {
const { id, created_at, updated_at, writer, created_date, updated_date, regdate, company_code, ...updatedData } = companyForm;
if (companyData.company_code) {
await apiClient.put(`/table-management/tables/${COMPANY_TABLE}/edit`, {
originalData: { company_code: companyData.company_code },
updatedData,
});
} else {
await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/add`, { company_code, ...updatedData });
}
toast.success("회사 정보가 저장되었어요.");
setEditMode(false);
fetchCompany();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
};
const cancelEdit = () => {
setCompanyForm(companyData);
setImagePreview(companyData.company_image || null);
setLogoPreview(companyData.company_logo || null);
setSealPreview(companyData.company_seal || null);
setEditMode(false);
};
/* ===================== Tab 2: 부서관리 ===================== */
const [depts, setDepts] = useState<any[]>([]);
const [deptTree, setDeptTree] = useState<DeptNode[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [selectedDeptCode, setSelectedDeptCode] = useState<string | null>(null);
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
// 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [deptSaving, setDeptSaving] = useState(false);
// 채번
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 트리 구성
const buildTree = (flatDepts: any[]): DeptNode[] => {
const map: Record<string, DeptNode> = {};
const roots: DeptNode[] = [];
flatDepts.forEach((d) => {
map[d.dept_code] = { ...d, children: [] };
});
flatDepts.forEach((d) => {
const node = map[d.dept_code];
if (d.parent_dept_code && map[d.parent_dept_code]) {
map[d.parent_dept_code].children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setDepts(raw);
setDeptTree(buildTree(raw));
// 전부 펼치기
setExpandedDepts(new Set(raw.map((d: any) => d.dept_code)));
} catch {
toast.error("부서 목록을 불러오는데 실패했어요.");
} finally {
setDeptLoading(false);
}
}, []);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
const selectedDept = depts.find((d) => d.dept_code === selectedDeptCode);
// 사원 조회
const fetchMembers = useCallback(async () => {
if (!selectedDeptCode) { setMembers([]); return; }
setMemberLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 트리 토글
const toggleExpand = (code: string) => {
setExpandedDepts((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code); else next.add(code);
return next;
});
};
// 부서 등록
const openDeptRegister = async () => {
setDeptForm({});
setDeptEditMode(false);
setPreviewCode(null);
setNumberingRuleId(null);
setDeptModalOpen(true);
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch { /* 채번 규칙 없으면 무시 */ }
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수예요."); return; }
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
setDeptSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
});
if (!response.success) { toast.error((response as any).error || "수정에 실패했어요."); return; }
toast.success("수정되었어요.");
} else {
const companyCode = user?.companyCode || "";
let allocatedCode: string | undefined;
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
allocatedCode = allocRes.data.generatedCode;
} else { toast.error("채번 코드 할당에 실패했어요."); return; }
}
const response = await departmentAPI.createDepartment(companyCode, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
dept_code: allocatedCode,
});
if (!response.success) { toast.error((response as any).error || "등록에 실패했어요."); return; }
toast.success("등록되었어요.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setDeptSaving(false);
}
};
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제할까요?", {
description: "해당 부서에 소속된 사원 정보는 유지돼요.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
if (!response.success) { toast.error((response as any).error || "삭제에 실패했어요."); return; }
toast.success((response as any).message || "삭제되었어요.");
setSelectedDeptCode(null);
fetchDepts();
} catch { toast.error("삭제에 실패했어요."); }
};
// 사원 추가/수정
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수예요."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수예요."); return; }
if (!userForm.dept_code) { toast.error("부서는 필수예요."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setDeptSaving(true);
try {
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
tel: userForm.tel || undefined,
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined),
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었어요." : "사원이 추가되었어요.");
setUserModalOpen(false);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setDeptSaving(false);
}
};
// EDataTable 컬럼 정의 (사원 목록)
const companyMemberColumns: EDataTableColumn[] = [
{ key: "sabun", label: "사번", width: "w-[80px]", render: (val: any) => <span className="text-[13px]">{val || "-"}</span> },
{ key: "user_name", label: "이름", width: "w-[90px]" },
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
{ key: "position_name", label: "직급", width: "w-[80px]", render: (val: any) => <span>{val || "-"}</span> },
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]", render: (val: any) => <span>{val || "-"}</span> },
{ key: "email", label: "이메일" },
];
/* ── 트리 렌더 ── */
const renderTree = (nodes: DeptNode[], depth = 0) => {
return nodes.map((node) => {
const isExpanded = expandedDepts.has(node.dept_code);
const isSelected = selectedDeptCode === node.dept_code;
const hasChildren = node.children.length > 0;
return (
<div key={node.dept_code}>
<div
className={cn(
"flex items-center gap-1.5 px-3 py-2 cursor-pointer text-sm transition-colors hover:bg-accent",
isSelected && "bg-primary/10 text-primary font-semibold border-l-2 border-primary",
!isSelected && "border-l-2 border-transparent",
)}
style={{ paddingLeft: `${12 + depth * 20}px` }}
onClick={() => setSelectedDeptCode(isSelected ? null : node.dept_code)}
>
{hasChildren ? (
<button
className="p-0.5 rounded hover:bg-accent"
onClick={(e) => { e.stopPropagation(); toggleExpand(node.dept_code); }}
>
<ChevronRight className={cn("w-3.5 h-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="w-4.5" />
)}
{isExpanded && hasChildren
? <FolderOpen className="w-4 h-4 text-muted-foreground" />
: <Folder className="w-4 h-4 text-muted-foreground" />
}
<span className="truncate">{node.dept_name}</span>
{node.status === "inactive" && <Badge variant="outline" className="text-[10px] px-1 py-0"></Badge>}
</div>
{isExpanded && hasChildren && renderTree(node.children, depth + 1)}
</div>
);
});
};
/* ── 이미지 업로드 박스 ── */
const ImageUploadBox = ({
label, preview, inputRef, field, setPreview,
}: {
label: string;
preview: string | null;
inputRef: React.RefObject<HTMLInputElement | null>;
field: string;
setPreview: React.Dispatch<React.SetStateAction<string | null>>;
}) => (
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium">{label}</Label>
<div className="relative w-40 h-40 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
{preview ? (
<>
<img src={preview} alt={label} className="w-full h-full object-contain" />
{editMode && (
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" className="h-7 text-xs" onClick={() => inputRef.current?.click()}>
<Upload className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => {
setPreview(null);
setCompanyForm((prev) => ({ ...prev, [field]: null }));
}}>
<X className="w-3 h-3" />
</Button>
</div>
)}
</>
) : (
<button
className="flex flex-col items-center gap-1.5 text-muted-foreground"
onClick={() => editMode && inputRef.current?.click()}
disabled={!editMode}
>
<ImageIcon className="w-8 h-8" />
<span className="text-xs">{editMode ? "이미지 업로드" : "이미지 없음"}</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleImageUpload(e, field, setPreview)}
/>
</div>
);
return (
<div className="h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
{/* 탭 컨테이너 */}
<Tabs defaultValue="company" className="flex flex-col h-full gap-0 min-h-0">
{/* 탭 헤더 — border-b 스타일 */}
<div className="shrink-0 border-b bg-background px-4">
<TabsList className="h-12 bg-transparent gap-1">
<TabsTrigger
value="company"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
>
<Building2 className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</div>
{/* ===================== Tab 1: 회사정보 ===================== */}
<TabsContent value="company" className="flex-1 overflow-auto mt-0 p-4">
<div className="border rounded-lg bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-6 py-3 border-b bg-muted/30">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4 text-muted-foreground" />
<span> </span>
</div>
<div className="flex gap-1.5">
{editMode ? (
<>
<Button variant="outline" size="sm" onClick={cancelEdit}></Button>
<Button size="sm" onClick={handleCompanySave} disabled={saving}>
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Save className="w-3.5 h-3.5 mr-1" />}
</Button>
</>
) : (
<Button size="sm" onClick={() => setEditMode(true)}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{companyLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="p-6 space-y-6">
{/* 기본 정보 섹션 제목 */}
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="flex-1 h-px bg-border" />
</div>
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={companyForm.company_name || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, company_name: e.target.value }))}
placeholder="회사명을 입력해주세요"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.business_registration_number || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, business_registration_number: e.target.value }))}
placeholder="000-00-00000"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.representative_name || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_name: e.target.value }))}
placeholder="대표자명"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.representative_phone || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_phone: e.target.value }))}
placeholder="02-0000-0000"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.fax || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, fax: e.target.value }))}
placeholder="02-0000-0001"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.email || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, email: e.target.value }))}
placeholder="example@company.com"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.website || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, website: e.target.value }))}
placeholder="https://www.company.com"
className="h-9" disabled={!editMode}
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.address || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, address: e.target.value }))}
placeholder="회사 주소를 입력해주세요"
className="h-9" disabled={!editMode}
/>
</div>
</div>
{/* 이미지 섹션 */}
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="flex-1 h-px bg-border" />
</div>
<div className="flex gap-6">
<ImageUploadBox label="회사 이미지" preview={imagePreview} inputRef={imageRef} field="company_image" setPreview={setImagePreview} />
<ImageUploadBox label="회사 로고" preview={logoPreview} inputRef={logoRef} field="company_logo" setPreview={setLogoPreview} />
<ImageUploadBox label="직인" preview={sealPreview} inputRef={sealRef} field="company_seal" setPreview={setSealPreview} />
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
{/* ── 부서 등록/수정 모달 ── */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정해요." : "새로운 부서를 등록해요."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성됩니다")}
className="h-9" disabled readOnly
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input
value={deptForm.dept_name || ""}
onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명" className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={handleDeptSave} disabled={deptSaving}>
{deptSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 사원 추가/수정 모달 ── */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>
{userEditMode
? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정해요.`
: selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가해요.` : "사원을 추가해요."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"> ID <span className="text-destructive">*</span></Label>
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번" className="h-9" autoComplete="off" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input type="date" value={userForm.regdate || ""} onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input type="date" value={userForm.end_date || ""} onChange={(e) => setUserForm((p) => ({ ...p, end_date: e.target.value }))} className="h-9" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={handleUserSave} disabled={deptSaving}>
{deptSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
</div>
);
}
@@ -1,772 +0,0 @@
"use client";
/**
*
*
* 좌측: 부서 (dept_info)
* 우측: 선택한 (user_info)
*
* 모달: 부서 (dept_info), (user_info)
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Users, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import * as departmentAPI from "@/lib/api/department";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
const DEPT_COLUMNS = [
{ key: "parent_dept_code", label: "상위부서" },
{ key: "status", label: "상태" },
];
export default function DepartmentPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 검색 필터 (DynamicSearchFilter)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 좌측: 부서
const [depts, setDepts] = useState<any[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [deptCount, setDeptCount] = useState(0);
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
// 우측: 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 채번 시스템
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 사원 탭 (재직중/퇴사)
const [memberTab, setMemberTab] = useState<"active" | "resigned">("active");
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 테이블 설정
const ts = useTableSettings("c16-department", DEPT_TABLE, DEPT_COLUMNS);
// 부서 조회
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
setDepts(data);
setDeptCount(res.data?.data?.total || data.length);
} catch (err) {
toast.error("부서 목록을 불러오는데 실패했습니다.");
} finally {
setDeptLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
// 선택된 부서
const selectedDept = depts.find((d) => d.id === selectedDeptId);
const selectedDeptCode = selectedDept?.dept_code || null;
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
const fetchMembers = useCallback(async () => {
setMemberLoading(true);
try {
const filters = selectedDeptCode
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
: [];
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 부서 등록
const openDeptRegister = async () => {
setDeptForm({});
setDeptEditMode(false);
setPreviewCode(null);
setNumberingRuleId(null);
setDeptModalOpen(true);
// 채번 규칙 조회 (dept_info.dept_code) — path params로 직접 호출
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch {
// 채번 규칙 없으면 무시
}
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
setSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
});
if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; }
toast.success("수정되었습니다.");
} else {
const companyCode = user?.companyCode || "";
// 채번 규칙이 있으면 allocate로 실제 코드 할당
let allocatedCode: string | undefined;
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
allocatedCode = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
return;
}
}
const response = await departmentAPI.createDepartment(companyCode, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
dept_code: allocatedCode,
});
if (!response.success) {
toast.error((response as any).error || "등록에 실패했습니다.");
return;
}
toast.success("등록되었습니다.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 부서 삭제
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제하시겠습니까?", {
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; }
toast.success(response.message || "삭제되었습니다.");
setSelectedDeptId(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 사원 추가/수정
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
if (!userForm.dept_code) { toast.error("부서는 필수입니다."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setSaving(true);
try {
// 비밀번호 미입력 시 기본값 (신규만)
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
tel: userForm.tel || undefined,
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined),
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
end_date: userForm.end_date || null,
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
setUserModalOpen(false);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (depts.length === 0) return;
const data = depts.map((d) => ({
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
}));
await exportToExcel(data, "부서관리.xlsx", "부서");
toast.success("다운로드 완료");
};
// 퇴사일 기반 재직/퇴사 분리
const _now = new Date();
const today = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`;
const activeMembers = members.filter((m) => !m.end_date || m.end_date.substring(0, 10) >= today);
const resignedMembers = members.filter((m) => m.end_date && m.end_date.substring(0, 10) < today);
// EDataTable 컬럼 정의 (부서 목록) — ts.visibleColumns 순서를 따름
const deptColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
dept_code: { width: "w-[120px]" },
dept_name: { minWidth: "min-w-[140px]" },
parent_dept_code: {
width: "w-[110px]",
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
},
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "active" ? "default" : "outline"}
className="text-[10px] px-1.5 py-0 h-5"
>
{val === "active" ? "활성" : (val || "\u2014")}
</Badge>
) : null,
},
};
// dept_code, dept_name은 항상 표시 (DEPT_COLUMNS에 포함되지 않으므로 visibleColumns에 없음)
const fixedCols: EDataTableColumn[] = [
{ key: "dept_code", label: "부서코드", ...colProps["dept_code"] },
{ key: "dept_name", label: "부서명", ...colProps["dept_name"] },
];
const dynamicCols = ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
return [...fixedCols, ...dynamicCols];
}, [ts.visibleColumns]);
return (
<div className="flex h-full flex-col gap-3 p-4">
{/* 검색 필터 바 */}
<DynamicSearchFilter
tableName={DEPT_TABLE}
filterId="c16-department"
onFilterChange={setSearchFilters}
dataCount={deptCount}
externalFilterConfig={ts.filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-8" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={() => void handleExcelDownload()}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 마스터-디테일 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted shrink-0">
<div className="flex items-center gap-2.5">
<span className="text-[13px] font-bold"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
{deptCount}
</span>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={() => void openDeptRegister()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={() => void handleDeptDelete()}>
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={() => ts.setOpen(true)}>
<Settings2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* 부서 테이블 */}
<EDataTable
columns={deptColumns}
data={ts.groupData(depts)}
rowKey={(row) => row.id}
loading={deptLoading}
emptyMessage="등록된 부서가 없어요"
selectedId={selectedDeptId}
onSelect={(id) => setSelectedDeptId(id)}
onRowDoubleClick={() => openDeptEdit()}
showRowNumber
showPagination={false}
draggableColumns={false}
columnOrderKey="c16-department"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 목록 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
{!selectedDeptId ? (
/* 빈 상태 */
<div className="flex-1 flex items-center justify-center p-5">
<div className="flex flex-col items-center justify-center text-center border-2 border-dashed border-border rounded-lg px-10 py-16">
<Users className="w-12 h-12 text-muted-foreground/40 mb-4" />
<div className="text-sm font-semibold text-muted-foreground mb-1.5"> </div>
<div className="text-xs text-muted-foreground"> </div>
</div>
</div>
) : (
<>
{/* 디테일 헤더 */}
<div className="flex items-center gap-3 px-4 py-3 border-b bg-muted shrink-0">
<span className="text-[13px] font-bold">{selectedDept?.dept_name || "-"}</span>
<span className="font-mono text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
{selectedDept?.dept_code || "-"}
</span>
<div className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
{/* 재직/퇴사 탭 */}
<div className="flex border-b border-border px-4 shrink-0 bg-muted">
<button
onClick={() => setMemberTab("active")}
className={cn("px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
memberTab === "active" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"
)}
>
{activeMembers.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">{activeMembers.length}</Badge>
)}
</button>
<button
onClick={() => setMemberTab("resigned")}
className={cn("px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
memberTab === "resigned" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"
)}
>
{resignedMembers.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">{resignedMembers.length}</Badge>
)}
</button>
</div>
<div className="flex-1 overflow-auto">
{memberLoading ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
</div>
) : memberTab === "active" ? (
activeMembers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm"> </div>
) : (
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">ID</TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeMembers.map((member, idx) => (
<TableRow
key={member.id || member.user_id}
className="cursor-pointer select-none hover:bg-muted/50"
onDoubleClick={() => openUserModal(member)}
>
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
<TableCell className="font-mono text-[13px] text-muted-foreground">{member.sabun || "—"}</TableCell>
<TableCell className="text-sm font-medium">{member.user_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{member.user_id}</TableCell>
<TableCell className="text-[13px]">{member.position_name || "—"}</TableCell>
<TableCell className="text-[13px]">{member.cell_phone || "—"}</TableCell>
<TableCell className="text-[13px]">{member.email || "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
) : (
resignedMembers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm"> </div>
) : (
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">ID</TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resignedMembers.map((member, idx) => (
<TableRow
key={member.id || member.user_id}
className="cursor-pointer select-none hover:bg-muted/50"
onDoubleClick={() => openUserModal(member)}
>
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
<TableCell className="font-mono text-[13px] text-muted-foreground">{member.sabun || "—"}</TableCell>
<TableCell className="text-sm font-medium">{member.user_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{member.user_id}</TableCell>
<TableCell className="text-[13px]">{member.position_name || "—"}</TableCell>
<TableCell className="text-[13px]">{member.cell_phone || "—"}</TableCell>
<TableCell className="text-[13px]">{member.email || "—"}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{member.end_date ? member.end_date.substring(0, 10) : "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
)}
</div>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 부서 등록/수정 모달 */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성돼요")}
className="h-9"
disabled
readOnly
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Input
value={deptForm.dept_name || ""}
onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명을 입력해 주세요"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Select
value={deptForm.parent_dept_code || ""}
onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="상위부서 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={() => void handleDeptSave()} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 사원 추가/수정 모달 */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>
{userEditMode
? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.`
: selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
ID <span className="text-destructive">*</span>
</span>
<Input
value={userForm.user_id || ""}
onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID를 입력해 주세요"
className="h-9"
disabled={userEditMode}
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Input
value={userForm.user_name || ""}
onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름을 입력해 주세요"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.sabun || ""}
onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번"
className="h-9"
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.user_password || ""}
onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력해 주세요" : "미입력 시 기본값이 설정돼요"}
className="h-9"
type="password"
autoComplete="new-password"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.position_name || ""}
onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9">
<SelectValue placeholder="부서를 선택해 주세요" />
</SelectTrigger>
<SelectContent>
{depts.map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.cell_phone || ""}
onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000"
className={cn("h-9", formErrors.cell_phone && "border-destructive")}
/>
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.email || ""}
onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com"
className={cn("h-9", formErrors.email && "border-destructive")}
/>
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
type="date"
value={userForm.regdate ? userForm.regdate.substring(0, 10) : ""}
onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
type="date"
value={userForm.end_date ? userForm.end_date.substring(0, 10) : ""}
onChange={(e) => setUserForm((p) => ({ ...p, end_date: e.target.value }))}
className="h-9"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={() => void handleUserSave()} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DEPT_TABLE}
userId={user?.userId}
onSuccess={() => fetchDepts()}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -1,902 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { exportToExcel } from "@/lib/utils/excelExport";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
Pencil, Copy, Settings2, Check, ChevronsUpDown,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ImageUpload } from "@/components/common/ImageUpload";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
// 검색 가능한 카테고리 콤보박스
function CategoryCombobox({ options, value, onChange, placeholder }: {
options: { code: string; label: string }[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const selected = options.find((o) => o.code === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
<span className="truncate">{selected?.label || <span className="text-muted-foreground">{placeholder}</span>}</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{options.map((opt) => (
<CommandItem key={opt.code} value={opt.label} onSelect={() => { onChange(opt.code); setOpen(false); }}>
<Check className={cn("mr-2 h-3.5 w-3.5", value === opt.code ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 다중 선택 카테고리 콤보박스
function MultiCategoryCombobox({ options, value, onChange, placeholder }: {
options: { code: string; label: string }[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const selectedCodes = value ? value.split(",").map((c) => c.trim()).filter(Boolean) : [];
const selectedLabels = selectedCodes.map((code) => options.find((o) => o.code === code)?.label || code).filter(Boolean);
const toggle = (code: string) => {
const next = selectedCodes.includes(code)
? selectedCodes.filter((c) => c !== code)
: [...selectedCodes, code];
onChange(next.join(","));
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
<span className="truncate">
{selectedLabels.length > 0
? selectedLabels.join(", ")
: <span className="text-muted-foreground">{placeholder}</span>}
</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{options.map((opt) => (
<CommandItem key={opt.code} value={opt.label} onSelect={() => toggle(opt.code)}>
<Check className={cn("mr-2 h-3.5 w-3.5", selectedCodes.includes(opt.code) ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const TABLE_NAME = "item_info";
const GRID_COLUMNS = [
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "image", label: "이미지", type: "image" },
{ key: "division", label: "관리품목" },
{ key: "type", label: "품목구분" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
{ key: "selling_price", label: "판매가격", align: "right" as const, formatNumber: true },
{ key: "standard_price", label: "기준단가", align: "right" as const, formatNumber: true },
{ key: "weight", label: "중량", align: "right" as const },
{ key: "inventory_unit", label: "재고단위" },
{ key: "user_type01", label: "대분류" },
{ key: "user_type02", label: "중분류" },
{ key: "lead_time", label: "생산 리드타임(일)", align: "right" as const },
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text", placeholder: "숫자 입력 (예: 3.5)" },
{ key: "volum", label: "부피", type: "text", placeholder: "숫자 입력 (예: 100)" },
{ key: "specific_gravity", label: "비중", type: "text", placeholder: "숫자 입력 (예: 7.85)" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
{ key: "image", label: "품목 이미지", type: "image" },
{ key: "meno", label: "메모", type: "textarea" },
];
const CATEGORY_COLUMNS = [
"division", "type", "unit", "material", "status",
"inventory_unit", "currency_code", "user_type01", "user_type02",
];
export default function ItemInfoPage() {
const { user } = useAuth();
const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS);
const [items, setItems] = useState<any[]>([]);
const [rawItems, setRawItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 검색 필터 (DynamicSearchFilter)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Record<string, any>>({});
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 옵션 (API에서 로드)
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 프리뷰 코드에서 각 파트별 표시값을 추출
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
if (!previewCode || !rule?.parts) return [];
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const globalSep = rule.separator || "";
// 1단계: 각 파트의 "예상 값"과 "구분자" 산출 (sequence, category 제외)
const partMeta = sorted.map((part: any, idx: number) => {
const sep = idx < sorted.length - 1
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
: "";
const config = part.autoConfig || {};
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
switch (part.partType) {
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
case "date": {
const now = new Date();
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
const fmt = config.dateFormat || "YYYYMMDD";
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
}
// sequence, category: 알 수 없으므로 프리뷰 코드에서 추출
default: return { known: false, sep, isManual: false, partType: part.partType };
}
});
// 2단계: 프리뷰 코드를 앞에서부터 소비하면서 각 파트의 실제 값을 추출
let remaining = previewCode;
const results: { value: string; isManual: boolean; separator: string }[] = [];
for (let i = 0; i < partMeta.length; i++) {
const meta = partMeta[i];
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
if (meta.isManual) {
// manual 파트: "____" 마커 찾아서 스킵
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) {
remaining = remaining.substring(markerIdx + 4);
// 이 파트 뒤 구분자 소비
if (meta.sep && remaining.startsWith(meta.sep)) {
remaining = remaining.substring(meta.sep.length);
}
}
results.push({ value: "", isManual: true, separator: meta.sep });
continue;
}
if (meta.known) {
// 알려진 값: 프리뷰 코드에서 해당 값을 소비
const valIdx = remaining.indexOf(meta.value);
if (valIdx >= 0) {
remaining = remaining.substring(valIdx + meta.value.length);
// 구분자 소비
if (meta.sep && remaining.startsWith(meta.sep)) {
remaining = remaining.substring(meta.sep.length);
}
}
results.push({ value: meta.value, isManual: false, separator: meta.sep });
} else {
// 미지의 값 (sequence, category 등): 다음 파트의 값 또는 구분자 기반으로 경계 탐색
let endIdx = remaining.length;
if (meta.sep) {
// 이 파트 뒤 구분자로 끝나는 지점 찾기
// 단, 다음 파트의 시작을 기준으로 역방향 탐색
if (nextMeta) {
// 다음 파트가 known이면 그 값이 시작되는 위치 탐색
if (nextMeta.known && nextMeta.value) {
// 구분자 + 다음 값 패턴으로 찾기
const pattern = meta.sep + nextMeta.value;
const patIdx = remaining.indexOf(pattern);
if (patIdx >= 0) endIdx = patIdx;
} else if (nextMeta.isManual) {
// 다음이 manual이면 구분자 + "____" 패턴
const pattern = meta.sep + "____";
const patIdx = remaining.indexOf(pattern);
if (patIdx >= 0) endIdx = patIdx;
} else {
// 구분자로 분리
const sepIdx = remaining.indexOf(meta.sep);
if (sepIdx >= 0) endIdx = sepIdx;
}
}
} else if (nextMeta) {
// 구분자 없이 다음 파트의 알려진 값으로 경계 탐색
if (nextMeta.known && nextMeta.value) {
const valIdx = remaining.indexOf(nextMeta.value);
if (valIdx >= 0) endIdx = valIdx;
} else if (nextMeta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) endIdx = markerIdx;
}
}
const extracted = remaining.substring(0, endIdx);
remaining = remaining.substring(endIdx);
// 구분자 소비
if (meta.sep && remaining.startsWith(meta.sep)) {
remaining = remaining.substring(meta.sep.length);
}
results.push({ value: extracted, isManual: false, separator: meta.sep });
}
}
return results;
};
// 카테고리 옵션 로드
useEffect(() => {
const loadCategories = async () => {
try {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
CATEGORY_COLUMNS.map(async (colName) => {
try {
const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[colName] = flatten(res.data.data);
}
} catch { /* skip */ }
})
);
setCategoryOptions(optMap);
} catch (err) {
console.error("카테고리 로드 실패:", err);
}
};
loadCategories();
}, []);
// 데이터 조회
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 99999,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const resolve = (col: string, code: string) => {
if (!code) return "";
// 쉼표 구분 다중값 지원
if (code.includes(",")) {
return code.split(",").map((c) => {
const trimmed = c.trim();
if (!trimmed || trimmed === "s") return "";
return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed;
}).filter(Boolean).join(", ");
}
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
setRawItems(raw);
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했어요.");
} finally {
setLoading(false);
}
}, [categoryOptions, searchFilters]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
setIsNumberingLoading(true);
// 규칙 조회 (캐싱)
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
// preview 호출 (formData + manualInputValue 전달)
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
// 파트별 표시값 추출
const parts = parsePreviewIntoParts(generatedCode, rule);
setNumberingParts(parts);
return { code: generatedCode, parts };
} catch { /* 채번 규칙 없으면 무시 */ }
finally {
setIsNumberingLoading(false);
}
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
};
// 파트 값으로부터 전체 코드 조합 (수동 입력값 포함)
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
return parts.map((p, idx) => {
const val = p.isManual ? manualVal : p.value;
const sep = idx < parts.length - 1 ? p.separator : "";
return val + sep;
}).join("");
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 미리보기
const result = await loadNumberingPreview({});
if (result.code) {
const hasManual = result.parts.some(p => p.isManual);
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
setFormData(prev => ({ ...prev, item_number: displayCode }));
}
};
// 수정 모달 열기
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 복사 모달 열기
const openCopyModal = async (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 복사된 formData 기반으로 preview
const result = await loadNumberingPreview(rest);
if (result.code) {
const hasManual = result.parts.some(p => p.isManual);
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
setFormData(prev => ({ ...prev, item_number: displayCode }));
}
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(formData, manualInputValue);
if (result.parts.length > 0) {
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신 (순번 재계산)
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingParts.some(p => p.isManual)) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(formData, manualInputValue);
if (result.parts.length > 0) {
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장
const handleSave = async () => {
if (!formData.item_name) {
toast.error("품명은 필수 입력이에요.");
return;
}
setSaving(true);
try {
if (isEditMode && editId) {
// 수정: item_number는 변경하지 않음
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
if (numberingRuleIdRef.current) {
try {
const hasManual = numberingParts.some(p => p.isManual);
const userInputCode = hasManual && manualInputValue
? manualInputValue
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
fetchItems();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
};
// 삭제
const handleDelete = async () => {
if (!selectedId) {
toast.error("삭제할 품목을 선택해 주세요.");
return;
}
if (!confirm("선택한 품목을 삭제할까요?")) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: selectedId }],
});
toast.success("삭제되었어요.");
setSelectedId(null);
fetchItems();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했어요.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) {
toast.error("다운로드할 데이터가 없어요.");
return;
}
const exportData = items.map((item) => {
const row: Record<string, any> = {};
for (const col of GRID_COLUMNS) {
row[col.label] = item[col.key] || "";
}
return row;
});
await exportToExcel(exportData, "품목정보.xlsx", "품목정보");
toast.success("엑셀 다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-0">
{/* 검색 필터 바 */}
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-info"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
/>
{/* 액션 바 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">{items.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<div className="mx-1 h-5 w-px bg-border" />
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button
variant="outline"
size="sm"
disabled={!selectedId}
onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openCopyModal(item);
}}
>
<Copy className="w-4 h-4 mr-1.5" />
</Button>
<Button
variant="outline"
size="sm"
disabled={!selectedId}
onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openEditModal(item);
}}
>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 메인 테이블 */}
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: (col as any).type === "image" ? "center" : col.align as "left" | "center" | "right" | undefined,
formatNumber: (col as any).formatNumber,
width: (col as any).type === "image" ? "w-[50px]" : undefined,
render: (col as any).type === "image" ? (val: any) => (
val ? (
<img src={String(val).startsWith("http") || String(val).startsWith("/") ? val : `/api/files/preview/${val}`} alt="" className="h-8 w-8 rounded object-cover border border-border mx-auto" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
) : <div className="h-8 w-8 rounded bg-muted mx-auto" />
) : undefined,
}))}
data={ts.groupData(items)}
loading={loading}
emptyMessage="등록된 품목이 없어요"
selectedId={selectedId}
onSelect={(id) => setSelectedId(id)}
onRowDoubleClick={(row) => openEditModal(row)}
showRowNumber
draggableColumns={false}
/>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) {
setNumberingParts([]);
setManualInputValue("");
setNumberingRule(null);
numberingRuleIdRef.current = null;
}
}}>
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "품목 정보를 수정해요." : "새로운 품목을 등록해요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-4 p-6">
{FORM_FIELDS.map((field) => (
<div
key={field.key}
className={cn("space-y-1.5", (field.type === "textarea" || field.type === "image") && "col-span-2")}
>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "image" ? (
<ImageUpload
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
tableName={TABLE_NAME}
recordId={formData.id || ""}
columnName={field.key}
height="h-32"
/>
) : field.type === "multi-category" ? (
<MultiCategoryCombobox
options={categoryOptions[field.key] || []}
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
placeholder={`${field.label} 선택`}
/>
) : field.type === "category" ? (
<CategoryCombobox
options={categoryOptions[field.key] || []}
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
placeholder={`${field.label} 선택`}
/>
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.label}
rows={3}
/>
) : field.type === "numbering" ? (
// 채번 세그먼트 UI
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading && numberingParts.length === 0 ? (
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : numberingParts.some(p => p.isManual) ? (
// 파트별 세그먼트 렌더링 (수동 입력 파트 있음)
<div className="flex h-9 items-center rounded-md border border-input">
{numberingParts.map((part, idx) => {
const isFirst = idx === 0;
const isLast = idx === numberingParts.length - 1;
if (part.isManual) {
return (
<React.Fragment key={idx}>
<input
type="text"
value={manualInputValue}
onChange={(e) => {
const val = e.target.value;
setManualInputValue(val);
setFormData(prev => ({
...prev,
item_number: buildCodeFromParts(numberingParts, val),
}));
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
/>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
}
// auto 파트: 회색 배경 읽기전용
return (
<React.Fragment key={idx}>
<span className={cn(
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
isFirst && "rounded-l-[5px]",
isLast && "rounded-r-[5px]",
)}>
{part.value}
</span>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
})}
</div>
) : (
// 전체 auto: 읽기전용 표시
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
onChange={(e) => {
const raw = e.target.value.replace(/[^\d.-]/g, "");
setFormData((prev) => ({ ...prev, [field.key]: raw }));
}}
placeholder={field.placeholder || field.label}
className="h-9 text-right"
/>
) : (
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder || field.label}
className="h-9"
/>
)}
</div>
))}
</div>
</div>
<DialogFooter className="shrink-0 border-t px-6 py-3">
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving
? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
: <Save className="w-4 h-4 mr-1.5" />
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
userId={user?.userId}
onSuccess={() => {
fetchItems();
}}
/>
</div>
);
}
@@ -1,136 +0,0 @@
"use client";
import React, { useState, useCallback, useRef, useEffect } from "react";
import { Settings2, Tags, Hash } from "lucide-react";
import { cn } from "@/lib/utils";
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
const TABS = [
{ id: "category", label: "카테고리 설정", icon: Tags },
{ id: "numbering", label: "코드 설정", icon: Hash },
] as const;
type TabId = (typeof TABS)[number]["id"];
export default function OptionsSettingPage() {
const [activeTab, setActiveTab] = useState<TabId>("category");
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
const [selectedTableName, setSelectedTableName] = useState("");
const [leftWidth, setLeftWidth] = useState(340);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback(() => {
setIsDragging(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, []);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setLeftWidth(Math.max(260, Math.min(500, e.clientX - rect.left)));
};
const handleMouseUp = () => {
setIsDragging(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging]);
return (
<div className="flex h-full flex-col p-3 gap-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-primary" />
<h1 className="text-sm font-semibold"> </h1>
</div>
<div className="flex bg-muted rounded-md p-0.5 gap-0.5">
{TABS.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-all",
activeTab === tab.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
{tab.label}
</button>
);
})}
</div>
</div>
<div className="flex-1 min-h-0">
{activeTab === "category" && (
<div ref={containerRef} className="flex h-full">
<div
style={{ width: leftWidth }}
className="shrink-0 border rounded-lg bg-card overflow-hidden"
>
<CategoryColumnList
tableName=""
selectedColumn={selectedColumn}
onColumnSelect={(uniqueKey, label, tableName) => {
setSelectedColumn(uniqueKey);
setSelectedColumnLabel(label);
setSelectedTableName(tableName);
}}
/>
</div>
<div
onMouseDown={handleMouseDown}
className={cn(
"w-1.5 mx-0.5 cursor-col-resize rounded-full transition-colors shrink-0",
isDragging ? "bg-primary" : "bg-border hover:bg-primary/50"
)}
/>
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
{selectedColumn && selectedTableName ? (
<CategoryValueManager
tableName={selectedTableName}
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
columnLabel={selectedColumnLabel}
/>
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center space-y-2">
<Tags className="h-8 w-8 mx-auto text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
</div>
)}
{activeTab === "numbering" && (
<div className="h-full border rounded-lg bg-card overflow-auto">
<NumberingRuleDesigner />
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,666 +0,0 @@
"use client";
/**
*
*
* (equipment_mng) + (work_instruction)
*
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import { RefreshCw, Clock, Loader2, Inbox, Wrench, Zap, Pause, Power, Settings2 } from "lucide-react";
/* ───── 상태 정의 ───── */
type OperationStatus = "running" | "idle" | "maintenance" | "off" | "unknown";
interface StatusConfig {
label: string;
color: string;
bg: string;
border: string;
bar: string;
icon: React.ReactNode;
badgeBg: string;
badgeText: string;
cardGlow: string;
}
const STATUS_MAP: Record<OperationStatus, StatusConfig> = {
running: {
label: "가동중",
color: "text-emerald-400",
bg: "bg-emerald-500/10",
border: "border-emerald-500/30",
bar: "bg-emerald-400",
icon: <Zap className="h-4 w-4" />,
badgeBg: "bg-emerald-500/20",
badgeText: "text-emerald-300",
cardGlow: "shadow-emerald-500/5",
},
idle: {
label: "대기",
color: "text-amber-400",
bg: "bg-amber-500/10",
border: "border-amber-500/30",
bar: "bg-amber-400",
icon: <Pause className="h-4 w-4" />,
badgeBg: "bg-amber-500/20",
badgeText: "text-amber-300",
cardGlow: "shadow-amber-500/5",
},
maintenance: {
label: "점검/수리",
color: "text-red-400",
bg: "bg-red-500/10",
border: "border-red-500/30",
bar: "bg-red-400",
icon: <Wrench className="h-4 w-4" />,
badgeBg: "bg-red-500/20",
badgeText: "text-red-300",
cardGlow: "shadow-red-500/5",
},
off: {
label: "비가동",
color: "text-gray-400",
bg: "bg-gray-500/10",
border: "border-gray-500/30",
bar: "bg-gray-500",
icon: <Power className="h-4 w-4" />,
badgeBg: "bg-gray-500/20",
badgeText: "text-gray-400",
cardGlow: "shadow-gray-500/5",
},
unknown: {
label: "미설정",
color: "text-gray-500",
bg: "bg-gray-500/10",
border: "border-gray-600/30",
bar: "bg-gray-600",
icon: <Power className="h-4 w-4" />,
badgeBg: "bg-gray-600/20",
badgeText: "text-gray-500",
cardGlow: "",
},
};
/** operation_status 값 → 내부 키 매핑 */
function resolveStatus(raw: string | null | undefined): OperationStatus {
if (!raw) return "unknown";
const v = raw.trim().toLowerCase();
if (["running", "가동", "가동중"].includes(v)) return "running";
if (["idle", "대기"].includes(v)) return "idle";
if (["maintenance", "점검", "수리", "점검/수리", "점검중"].includes(v)) return "maintenance";
if (["off", "비가동", "정지"].includes(v)) return "off";
return "unknown";
}
/* ───── 타입 ───── */
interface Equipment {
id: string;
equipment_code: string;
equipment_name: string;
equipment_type: string;
installation_location: string;
operation_status: string;
manufacturer: string;
model_name: string;
image_path: string;
}
interface WorkInstruction {
id: string;
wi_id?: string;
instruction_number: string;
work_instruction_no?: string;
item_name: string;
equipment_id: string;
worker_name: string;
status: string;
progress_status?: string;
}
interface ProcessRow {
wo_id: string;
status: string; // acceptable / in_progress / completed
parent_process_id?: string | null;
}
/* ───── 컴포넌트 ───── */
export default function EquipmentMonitoringPage() {
const { settings } = useMonitoringSettings("equipment");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
const df = settings.displayFields;
const [equipments, setEquipments] = useState<Equipment[]>([]);
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
const [processRows, setProcessRows] = useState<ProcessRow[]>([]);
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
const autoRefreshRef = useRef(autoRefresh);
// autoRefreshRef 동기화
useEffect(() => {
autoRefreshRef.current = autoRefresh;
}, [autoRefresh]);
/* ── 시간 업데이트 ── */
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
/* ── 데이터 fetch ── */
const fetchData = useCallback(async () => {
try {
setLoading(true);
const [equipRes, wiRes, procRes] = await Promise.all([
apiClient.post("/table-management/tables/equipment_mng/data", {
autoFilter: true,
page: 1,
size: 500,
}),
apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })),
apiClient.post("/table-management/tables/work_order_process/data", {
page: 1,
size: 2000,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
const eqRows: Equipment[] = equipRes.data?.data?.data ?? equipRes.data?.data?.rows ?? [];
setEquipments(eqRows);
const wiRows: WorkInstruction[] = wiRes.data?.data ?? wiRes.data?.rows ?? [];
setWorkInstructions(wiRows);
const pRows: ProcessRow[] = procRes.data?.data?.data ?? procRes.data?.data?.rows ?? [];
setProcessRows(pRows);
} catch (err) {
console.error("설비 모니터링 데이터 조회 실패:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
/* ── 자동 갱신 ── */
useEffect(() => {
const interval = setInterval(() => {
if (autoRefreshRef.current) fetchData();
}, settings.refreshInterval * 1000);
return () => clearInterval(interval);
}, [fetchData, settings.refreshInterval]);
/* ── 설비별 작업지시 맵 ── */
const wiMap = useMemo(() => {
const map: Record<string, WorkInstruction[]> = {};
workInstructions.forEach((wi) => {
const eqId = wi.equipment_id;
if (eqId) {
if (!map[eqId]) map[eqId] = [];
map[eqId].push(wi);
}
});
return map;
}, [workInstructions]);
/* ── 설비 상태 자동 판단: 공정 데이터 기반 ── */
const inferredStatus = useMemo(() => {
// 작업지시 ID → 설비 ID 매핑
const wiToEquip: Record<string, string> = {};
workInstructions.forEach((wi) => {
const wiId = wi.wi_id || wi.id;
if (wi.equipment_id) wiToEquip[wiId] = wi.equipment_id;
});
// 설비별 공정 상태 집계
const equipProcessStatus: Record<string, Set<string>> = {};
processRows.forEach((p) => {
const eqId = wiToEquip[p.wo_id];
if (!eqId) return;
if (!equipProcessStatus[eqId]) equipProcessStatus[eqId] = new Set();
equipProcessStatus[eqId].add(p.status);
});
// 설비별 상태 판단
const result: Record<string, OperationStatus> = {};
equipments.forEach((eq) => {
const dbStatus = resolveStatus(eq.operation_status);
// DB에 점검/수리가 명시되어 있으면 그대로 사용
if (dbStatus === "maintenance") {
result[eq.id] = "maintenance";
return;
}
const statuses = equipProcessStatus[eq.id];
if (statuses) {
if (statuses.has("in_progress")) {
result[eq.id] = "running"; // 진행중 공정 있음 → 가동중
} else if (statuses.has("acceptable")) {
result[eq.id] = "idle"; // 접수 대기 공정 있음 → 대기
} else {
// 전부 completed → DB 상태 사용
result[eq.id] = dbStatus !== "unknown" ? dbStatus : "idle";
}
} else {
// 공정 데이터 없음 → 작업지시 여부로 판단
const eqWIs = wiMap[eq.id];
if (eqWIs && eqWIs.length > 0) {
result[eq.id] = "idle"; // 작업지시 배정됨 → 대기
} else {
result[eq.id] = dbStatus !== "unknown" ? dbStatus : "off";
}
}
});
return result;
}, [equipments, workInstructions, processRows, wiMap]);
/* ── 요약 통계 (추론 상태 기반) ── */
const stats = useMemo(() => {
const counts: Record<OperationStatus, number> = { running: 0, idle: 0, maintenance: 0, off: 0, unknown: 0 };
equipments.forEach((eq) => {
counts[inferredStatus[eq.id] ?? "unknown"]++;
});
return { total: equipments.length, ...counts };
}, [equipments, inferredStatus]);
/* ── 필터된 설비 ── */
const filteredEquipments = useMemo(() => {
if (filterStatus === "all") return equipments;
return equipments.filter((eq) => (inferredStatus[eq.id] ?? "unknown") === filterStatus);
}, [equipments, filterStatus, inferredStatus]);
/* ── 가동률 (센서 미연동 — 상태 기반 고정값) ── */
const utilizationMap = useMemo(() => {
const map: Record<string, number | null> = {};
// 설비 ID를 해시하여 상태별 고정 범위 내 값 생성 (리렌더링해도 안 변함)
const hash = (id: string) => {
let h = 0;
for (let i = 0; i < id.length; i++) h = ((h << 5) - h + id.charCodeAt(i)) | 0;
return Math.abs(h);
};
equipments.forEach((eq) => {
const s = inferredStatus[eq.id] ?? "unknown";
const h = hash(eq.id);
if (s === "running") map[eq.id] = 75 + (h % 20); // 75~94 고정
else if (s === "idle") map[eq.id] = 20 + (h % 30); // 20~49 고정
else if (s === "maintenance") map[eq.id] = 0;
else if (s === "off") map[eq.id] = 0;
else map[eq.id] = null;
});
return map;
}, [equipments, inferredStatus]);
const getUtilization = (eq: Equipment): number | null => utilizationMap[eq.id] ?? null;
/* ── 요약 카드 배열 ── */
const summaryCards: {
label: string;
count: number;
status: OperationStatus | "total";
color: string;
bg: string;
border: string;
icon: React.ReactNode;
}[] = [
{
label: "전체설비",
count: stats.total,
status: "total",
color: "text-blue-400",
bg: "bg-blue-500/10",
border: "border-blue-500/30",
icon: <Inbox className="h-5 w-5" />,
},
{
label: "가동중",
count: stats.running,
status: "running",
color: "text-emerald-400",
bg: "bg-emerald-500/10",
border: "border-emerald-500/30",
icon: <Zap className="h-5 w-5" />,
},
{
label: "대기",
count: stats.idle,
status: "idle",
color: "text-amber-400",
bg: "bg-amber-500/10",
border: "border-amber-500/30",
icon: <Pause className="h-5 w-5" />,
},
{
label: "점검/수리",
count: stats.maintenance,
status: "maintenance",
color: "text-red-400",
bg: "bg-red-500/10",
border: "border-red-500/30",
icon: <Wrench className="h-5 w-5" />,
},
{
label: "비가동",
count: stats.off + stats.unknown,
status: "off",
color: "text-gray-400",
bg: "bg-gray-500/10",
border: "border-gray-500/30",
icon: <Power className="h-5 w-5" />,
},
];
/* ── 필터 pill ── */
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-700 hover:bg-blue-500/30" },
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-700 hover:bg-emerald-500/30" },
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-700 hover:bg-amber-500/30" },
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-700 hover:bg-red-500/30" },
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-700 hover:bg-gray-500/30" },
];
/* ── 포맷 ── */
const formatTime = (d: Date) =>
d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
const formatDate = (d: Date) =>
d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" });
/* ────────────── 렌더 ────────────── */
return (
<div className={cn("min-h-screen space-y-5 p-4 md:p-6", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}></h1>
</div>
<div className="flex items-center gap-3">
{/* 현재 시간 */}
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm",
theme.mutedText,
theme.cardBorder,
)}
>
<Clock className="h-4 w-4" />
<span className="font-mono">{formatDate(currentTime)}</span>
<span className={cn("font-mono", theme.text)}>{formatTime(currentTime)}</span>
</div>
{/* 자동갱신 토글 */}
<Button
variant="outline"
size="sm"
className={cn(
"gap-1.5 text-xs",
autoRefresh
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => setAutoRefresh((v) => !v)}
>
<span
className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "animate-pulse bg-emerald-400" : "bg-muted-foreground")}
/>
{autoRefresh ? "ON" : "OFF"}
</Button>
{/* 새로고침 */}
<Button variant="outline" size="sm" className="gap-1.5" onClick={fetchData} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
{/* 설정 */}
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
>
<Settings2 className="h-4 w-4" />
</Button>
</div>
</header>
{/* ── 요약 카드 5개 ── */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryCards.map((card) => (
<button
key={card.label}
onClick={() => setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))}
className={cn(
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
card.bg,
card.border,
"hover:shadow-lg",
)}
>
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
{card.icon}
</div>
<div className="text-left">
<p className="text-muted-foreground text-xs">{card.label}</p>
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
</div>
</button>
))}
</div>
{/* ── 필터 pill ── */}
<div className="flex flex-wrap gap-2">
{filterPills.map((pill) => (
<button
key={pill.value}
onClick={() => setFilterStatus(pill.value)}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
filterStatus === pill.value
? cn(pill.color, "ring-1 ring-foreground/10")
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{pill.label}
</button>
))}
<span className="text-muted-foreground ml-auto self-center text-sm">{filteredEquipments.length} </span>
</div>
{/* ── 로딩 ── */}
{loading && equipments.length === 0 && (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
<span className="text-muted-foreground ml-3"> ...</span>
</div>
)}
{/* ── 데이터 없음 ── */}
{!loading && equipments.length === 0 && (
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<p className="text-lg"> .</p>
</div>
)}
{/* ── 설비 카드 그리드 ── */}
{filteredEquipments.length > 0 && (
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}>
{filteredEquipments.map((eq) => {
const status = inferredStatus[eq.id] ?? "unknown";
const cfg = STATUS_MAP[status];
const utilization = getUtilization(eq);
const eqWIs = wiMap[eq.id] ?? [];
return (
<div
key={eq.id}
className={cn(
"relative overflow-hidden rounded-xl border bg-card backdrop-blur transition-all hover:shadow-lg",
cfg.border,
cfg.cardGlow,
)}
>
{/* 좌측 색상 바 */}
<div className={cn("absolute top-0 bottom-0 left-0 w-1 rounded-l-xl", cfg.bar)} />
{/* 상단: 설비명 + 상태 배지 */}
<div className="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
<div className="min-w-0 flex-1">
{df.equipmentName && (
<h3 className={cn("truncate text-base font-semibold", theme.text)}>
{eq.equipment_name || "이름 없음"}
</h3>
)}
<p className={cn("mt-0.5 truncate text-xs", theme.mutedText)}>
{df.equipmentType && (eq.equipment_type || "-")}
{df.equipmentType && df.equipmentLocation && " · "}
{df.equipmentLocation && (eq.installation_location || "-")}
</p>
</div>
{df.operationStatus && (
<Badge
className={cn("ml-2 shrink-0 gap-1 border-0 text-xs font-medium", cfg.badgeBg, cfg.badgeText)}
>
{cfg.icon}
{cfg.label}
</Badge>
)}
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 정보 그리드 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 py-2.5 pl-5 text-sm">
{df.dailyOperationTime && (
<div>
<span className={cn("text-xs", theme.mutedText)}> </span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.dailyProductionQty && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>-</p>
</div>
)}
{df.worker && (
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("font-medium", theme.text)}>
{eqWIs.length > 0 && eqWIs[0].worker_name ? eqWIs[0].worker_name : "-"}
</p>
</div>
)}
<div>
<span className={cn("text-xs", theme.mutedText)}></span>
<p className={cn("truncate font-medium", theme.text)}>{eq.equipment_code || "-"}</p>
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 가동률 프로그레스 */}
{df.utilizationBar && (
<div className="px-4 py-2.5 pl-5">
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className={theme.mutedText}></span>
<span
className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : theme.mutedText)}
>
{utilization !== null ? `${utilization}%` : "-"}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
{utilization !== null && (
<div
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
style={{ width: `${utilization}%` }}
/>
)}
</div>
</div>
)}
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 현재 작업지시 */}
{df.currentWorkInstruction && (
<div className="px-4 py-2.5 pl-5">
<p className={cn("mb-1 text-xs", theme.mutedText)}> </p>
{eqWIs.length > 0 ? (
<div className="space-y-1">
{eqWIs.slice(0, 2).map((wi) => (
<div key={wi.id} className="flex items-center gap-2 text-sm">
<span className="shrink-0 font-mono text-xs text-blue-400">
{wi.instruction_number || "-"}
</span>
<span className={cn("truncate", theme.mutedText)}>{wi.item_name || "-"}</span>
</div>
))}
{eqWIs.length > 2 && <p className={cn("text-xs", theme.mutedText)}>+{eqWIs.length - 2} </p>}
</div>
) : (
<p className={cn("text-sm italic", theme.mutedText)}> </p>
)}
</div>
)}
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-border" />
{/* 센서 데이터 (PLC 미연동) */}
{df.sensorData && (
<div className="flex items-center gap-4 px-4 py-2.5 pl-5 text-xs">
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}></span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
<div className="flex items-center gap-1.5">
<span className={theme.mutedText}>RPM</span>
<span className={cn("font-mono", theme.mutedText)}>-</span>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* 필터 결과 없음 */}
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
<div className="text-muted-foreground flex flex-col items-center justify-center py-16">
<Inbox className="mb-2 h-10 w-10" />
<p> .</p>
</div>
)}
</div>
);
}
@@ -1,557 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import type { ProductionDisplayFields } from "@/types/monitoringSettings";
import {
RefreshCw,
Clock,
Loader2,
Inbox,
Timer,
CheckCircle2,
AlertTriangle,
TrendingUp,
Play,
Pause,
Settings2,
} from "lucide-react";
// ─── 타입 정의 ─────────────────────────────────────────────
interface WorkInstructionDetail {
item_name?: string;
spec?: string;
customer_name?: string;
}
interface WorkInstruction {
id: string;
wi_id?: string;
work_instruction_no: string;
status: string; // 일반 / 긴급
progress_status?: string; // 대기 / 진행중 / 완료
qty: number;
completed_qty: number;
start_date: string | null;
end_date: string | null;
worker: string | null;
equipment_id: string | null;
equipment_name?: string | null;
details?: WorkInstructionDetail[];
}
interface ProcessStep {
wo_id: string;
process_name: string;
status: string; // acceptable / in_progress / completed
seq_no: number;
parent_process_id?: string | null;
}
type FilterTab = "전체" | "대기" | "진행중" | "완료";
// ─── 유틸리티 ──────────────────────────────────────────────
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "-";
try {
const d = new Date(dateStr);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
} catch {
return dateStr;
}
}
function formatTime(date: Date): string {
const h = String(date.getHours()).padStart(2, "0");
const m = String(date.getMinutes()).padStart(2, "0");
const s = String(date.getSeconds()).padStart(2, "0");
return `${h}:${m}:${s}`;
}
// 작업지시 진행상태 계산: progress_status 우선, 없으면 공정현황으로 판단
function computeProgress(
wi: WorkInstruction,
processMap: Map<string, ProcessStep[]>,
): "대기" | "진행중" | "완료" {
// 1) DB의 progress_status가 있으면 우선 사용
const ps = wi.progress_status?.trim();
if (ps) {
if (["완료", "complete", "completed"].includes(ps.toLowerCase())) return "완료";
if (["진행중", "진행", "in_progress", "inprogress", "processing"].includes(ps.toLowerCase())) return "진행중";
if (["대기", "waiting", "pending"].includes(ps.toLowerCase())) return "대기";
}
// 2) 완료수량 기반 판단
const totalQty = Number((wi as any).total_qty || wi.qty || 0);
const completedQty = Number(wi.completed_qty || 0);
if (totalQty > 0 && completedQty >= totalQty) return "완료";
if (completedQty > 0) return "진행중";
// 3) 공정현황 fallback
const wiId = wi.wi_id || wi.id;
const steps = processMap.get(String(wiId));
if (!steps || steps.length === 0) return "대기";
const completedCount = steps.filter((s) => s.status === "completed").length;
const inProgressCount = steps.filter((s) => s.status === "in_progress").length;
if (completedCount === steps.length) return "완료";
if (completedCount > 0 || inProgressCount > 0) return "진행중";
return "대기";
}
// ─── 메인 컴포넌트 ────────────────────────────────────────
export default function ProductionMonitoringPage() {
const { settings } = useMonitoringSettings("production");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
// ─── 실시간 시계 ─────────────────────────────────────────
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// ─── 데이터 로드 ─────────────────────────────────────────
const fetchData = useCallback(async () => {
try {
setLoading(true);
// 작업지시 목록 조회
const wiRes = await apiClient.get("/work-instruction/list");
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
const seen = new Set<string>();
const wiData = wiRaw.filter((wi) => {
const key = wi.work_instruction_no || wi.id;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
setWorkInstructions(wiData);
// 공정현황 조회 (실패해도 작업지시는 표시)
try {
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
page: 1,
size: 1000,
autoFilter: true,
});
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
// wo_id + seq_no 기준 그룹핑, 마스터/분할 행 통합하여 가장 높은 상태 반영
const statusPriority = (s: string) => (s === "completed" ? 3 : s === "in_progress" ? 2 : s === "acceptable" ? 1 : 0);
const rawMap = new Map<string, ProcessStep[]>();
rows.forEach((row) => {
const key = String(row.wo_id);
if (!rawMap.has(key)) rawMap.set(key, []);
rawMap.get(key)!.push(row);
});
const map = new Map<string, ProcessStep[]>();
rawMap.forEach((steps, woId) => {
// seq_no + process_name 기준으로 대표 1건 (마스터 행 기본, 분할 행의 높은 status 반영)
const grouped = new Map<string, ProcessStep>();
for (const s of steps) {
const gk = `${s.seq_no}_${s.process_name}`;
const existing = grouped.get(gk);
if (!existing) {
// 마스터 행 우선, 없으면 아무거나
grouped.set(gk, { ...s });
} else {
// 더 높은 상태로 업데이트 (split 행이 in_progress/completed이면 반영)
if (statusPriority(s.status) > statusPriority(existing.status)) {
existing.status = s.status;
}
}
}
// parent_process_id 제거 (표시용 마스터 데이터)
const deduped = Array.from(grouped.values())
.map((s) => ({ ...s, parent_process_id: null }))
.sort((a, b) => (a.seq_no ?? 0) - (b.seq_no ?? 0));
map.set(woId, deduped);
});
setProcessMap(map);
} catch {
// 공정현황 조회 실패 → 빈 맵 유지
setProcessMap(new Map());
}
} catch (err) {
console.error("생산모니터링 데이터 조회 실패:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
// ─── 자동갱신 ────────────────────────────────────────────
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchData, settings.refreshInterval * 1000);
return () => clearInterval(timer);
}, [autoRefresh, fetchData, settings.refreshInterval]);
// ─── 통계 계산 ───────────────────────────────────────────
const stats = useMemo(() => {
let waiting = 0;
let inProgress = 0;
let completed = 0;
let totalQty = 0;
let completedQty = 0;
workInstructions.forEach((wi) => {
const progress = computeProgress(wi, processMap);
if (progress === "대기") waiting++;
else if (progress === "진행중") inProgress++;
else completed++;
totalQty += Number((wi as any).total_qty || wi.qty || 0);
completedQty += Number(wi.completed_qty) || 0;
});
const achievementRate = totalQty > 0 ? Math.round((completedQty / totalQty) * 100) : 0;
return { waiting, inProgress, completed, achievementRate };
}, [workInstructions, processMap]);
// ─── 필터링된 작업 목록 ──────────────────────────────────
const filteredInstructions = useMemo(() => {
return workInstructions.filter((wi) => {
if (activeTab === "전체") return true;
const progress = computeProgress(wi, processMap);
return progress === activeTab;
});
}, [workInstructions, processMap, activeTab]);
// ─── 렌더링 ──────────────────────────────────────────────
return (
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
{/* 헤더 */}
<div className="flex flex-shrink-0 items-center justify-between">
<h1 className={cn("text-2xl font-bold", theme.headerText)}></h1>
<div className="flex items-center gap-3">
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
<Clock className="h-4 w-4" />
<span className="font-mono">{formatTime(currentTime)}</span>
</div>
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading} className="gap-1.5">
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button
variant={autoRefresh ? "default" : "outline"}
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{autoRefresh ? "ON" : "OFF"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
className="gap-1.5"
>
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 요약 카드 */}
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
<SummaryCard
icon={<Timer className="h-5 w-5" />}
label="대기중"
value={stats.waiting}
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
/>
<SummaryCard
icon={<Loader2 className="h-5 w-5" />}
label="진행중"
value={stats.inProgress}
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
/>
<SummaryCard
icon={<CheckCircle2 className="h-5 w-5" />}
label="완료"
value={stats.completed}
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
/>
<SummaryCard
icon={<TrendingUp className="h-5 w-5" />}
label="달성율"
value={`${stats.achievementRate}%`}
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
/>
</div>
{/* 탭 필터 */}
<div className="flex flex-shrink-0 items-center gap-2">
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
<Button
key={tab}
variant={activeTab === tab ? "default" : "outline"}
size="sm"
onClick={() => setActiveTab(tab)}
className={cn(
"min-w-[64px]",
activeTab === tab && tab === "대기" && "bg-amber-500 text-white hover:bg-amber-600",
activeTab === tab && tab === "진행중" && "bg-blue-500 text-white hover:bg-blue-600",
activeTab === tab && tab === "완료" && "bg-emerald-500 text-white hover:bg-emerald-600",
)}
>
{tab}
{tab === "전체" && ` (${workInstructions.length})`}
{tab === "대기" && ` (${stats.waiting})`}
{tab === "진행중" && ` (${stats.inProgress})`}
{tab === "완료" && ` (${stats.completed})`}
</Button>
))}
</div>
{/* 로딩 상태 */}
{loading && workInstructions.length === 0 && (
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Loader2 className="mb-3 h-10 w-10 animate-spin" />
<span className="text-sm"> ...</span>
</div>
)}
{/* 빈 상태 */}
{!loading && filteredInstructions.length === 0 && (
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
<Inbox className="mb-3 h-12 w-12" />
<span className="text-sm">
{activeTab === "전체" ? "등록된 작업지시가 없습니다." : `"${activeTab}" 상태의 작업지시가 없습니다.`}
</span>
</div>
)}
{/* 작업 카드 */}
{filteredInstructions.length > 0 && (
<div
className={cn(
"flex-1 gap-4",
settings.layout === "grid" && "grid",
settings.layout === "list" && "flex flex-col",
settings.layout === "split" && "grid grid-cols-2",
)}
style={
settings.layout === "grid" ? { gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" } : undefined
}
>
{filteredInstructions.map((wi, idx) => (
<WorkCard
key={wi.id || `wi-${idx}`}
instruction={wi}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi, processMap)}
displayFields={settings.displayFields}
/>
))}
</div>
)}
</div>
);
}
// ─── 요약 카드 ─────────────────────────────────────────────
function SummaryCard({
icon,
label,
value,
colorClass,
}: {
icon: React.ReactNode;
label: string;
value: number | string;
colorClass: string;
}) {
return (
<div className="bg-card flex items-center gap-4 rounded-lg border p-4">
<div className={cn("rounded-lg border p-2.5", colorClass)}>{icon}</div>
<div className="flex flex-col">
<span className="text-muted-foreground text-xs">{label}</span>
<span className="text-foreground text-2xl font-bold">{value}</span>
</div>
</div>
);
}
// ─── 작업 카드 ─────────────────────────────────────────────
function WorkCard({
instruction: wi,
steps,
progress,
displayFields: df,
}: {
instruction: WorkInstruction;
steps: ProcessStep[];
progress: "대기" | "진행중" | "완료";
displayFields: ProductionDisplayFields;
}) {
// API 응답은 flat 구조 (details 배열 아님)
const itemName = (wi as any).item_name || "-";
const spec = (wi as any).item_spec || "-";
const customerName = (wi as any).customer_name || "-";
// 진척률 (total_qty 또는 qty)
const totalQty = Number((wi as any).total_qty || wi.qty || 0);
const completedQty = Number(wi.completed_qty || 0);
const progressPercent = totalQty > 0 ? Math.min(100, Math.round((completedQty / totalQty) * 100)) : 0;
// 공정 현황 계산
const completedSteps = steps.filter((s) => s.status === "completed").length;
const currentStep = steps.find((s) => s.status === "in_progress") || steps.find((s) => s.status !== "completed");
// 프로그레스바 색상
const barColor = progressPercent >= 100 ? "bg-emerald-500" : progressPercent >= 50 ? "bg-blue-500" : "bg-amber-500";
// 상태 배지 스타일
const statusBadge: Record<string, string> = {
: "bg-amber-500/10 text-amber-500 border-amber-500/30",
: "bg-blue-500/10 text-blue-500 border-blue-500/30",
: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
};
const isUrgent = wi.status === "긴급";
return (
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
{/* 카드 헤더 */}
<div className="bg-muted/30 flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
{df.workInstructionNo && (
<span className="text-foreground text-sm font-semibold">{wi.work_instruction_no}</span>
)}
{df.priority && isUrgent && (
<Badge variant="outline" className="gap-1 border-red-500/30 bg-red-500/10 text-xs text-red-500">
<AlertTriangle className="h-3 w-3" />
</Badge>
)}
</div>
<Badge variant="outline" className={cn("text-xs", statusBadge[progress])}>
{progress}
</Badge>
</div>
{/* 카드 본문 - 정보 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 border-b px-4 py-3 text-sm">
{df.itemName && <InfoRow label="품목명" value={itemName} />}
{df.spec && <InfoRow label="규격" value={spec} />}
{df.customerName && <InfoRow label="거래처" value={customerName} />}
{df.worker && <InfoRow label="작업자" value={wi.worker || "-"} />}
{df.dueDate && <InfoRow label="납기일" value={formatDate(wi.end_date)} />}
{df.equipment && <InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />}
{df.salesOrderNo && <InfoRow label="수주번호" value={(wi as any).sales_order_no || "-"} />}
{df.quantityInfo && <InfoRow label="수량" value={`${completedQty} / ${totalQty}`} />}
</div>
{/* 공정현황 */}
{df.processProgress && (
<div className="border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium"></span>
{steps.length > 0 && (
<span className="text-muted-foreground text-xs">
{completedSteps}/{steps.length}
{currentStep && (
<span>
{" "}
· : <span className="text-blue-400">{currentStep.process_name}</span>
</span>
)}
</span>
)}
</div>
{steps.length > 0 ? (
<div className="flex flex-wrap items-center gap-1">
{steps.map((step, idx) => {
const isDone = step.status === "completed";
const isInProgress = step.status === "in_progress";
const isCurrent = isInProgress || (!isDone && idx === completedSteps);
return (
<React.Fragment key={`${step.wo_id}-${step.seq_no}-${idx}`}>
{idx > 0 && (
<span className={cn("text-xs", isDone || isCurrent ? "text-emerald-400/60" : "text-muted-foreground/40")}></span>
)}
<span
className={cn(
"rounded px-2 py-0.5 text-xs font-medium transition-all",
isDone && "bg-emerald-500/20 text-emerald-400",
isCurrent && "animate-pulse bg-blue-500/20 text-blue-400",
!isDone && !isCurrent && "bg-muted text-muted-foreground",
)}
>
{step.process_name}
</span>
</React.Fragment>
);
})}
</div>
) : (
<span className="text-muted-foreground text-xs"> </span>
)}
</div>
)}
{/* 프로그레스바 */}
{df.progressBar && (
<div className="px-4 py-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{completedQty} / {totalQty}
</span>
<span
className={cn(
"text-xs font-bold",
progressPercent >= 100
? "text-emerald-500"
: progressPercent >= 50
? "text-blue-500"
: "text-amber-500",
)}
>
{progressPercent}%
</span>
</div>
<div className="bg-muted h-2.5 w-full overflow-hidden rounded-full">
<div
className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
</div>
);
}
// ─── 정보 행 ───────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="text-foreground truncate">{value}</span>
</div>
);
}
@@ -1,426 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { useMonitoringSettings } from "@/hooks/useMonitoringSettings";
import { getMonitoringTheme } from "@/lib/monitoringTheme";
import { useTabStore } from "@/stores/tabStore";
import { RefreshCw, Clock, Loader2, Inbox, Search, ClipboardCheck, Settings2 } from "lucide-react";
/* ───── 타입 ───── */
interface ProcessRow {
id: number;
wo_id: number;
process_code: string;
process_name: string;
status: string;
plan_qty: number;
input_qty: number;
good_qty: number;
defect_qty: number;
started_at: string | null;
completed_at: string | null;
worker_name: string;
}
interface InspectionRow {
no: number;
inspectionNo: string;
inspectionType: string;
itemName: string;
spec: string;
inspectionQty: number;
goodQty: number;
defectQty: number;
defectRate: number;
result: "합격" | "불합격" | "대기";
inspectorName: string;
inspectedAt: string;
remark: string;
}
/* ───── 탭 정의 ───── */
const TABS = [
{ key: "all", label: "전체" },
{ key: "process", label: "공정검사" },
{ key: "incoming", label: "입고검사" },
{ key: "shipping", label: "출하검사" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
/* ───── 유틸 ───── */
const fmt = (n: number) => n.toLocaleString("ko-KR");
const pct = (n: number) => `${n.toFixed(1)}%`;
const badgeVariant = (type: "result" | "type" | "defectRate", value: string | number) => {
if (type === "result") {
if (value === "합격") return "bg-emerald-100 text-emerald-700 border-emerald-200";
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
return "bg-amber-100 text-amber-700 border-amber-200";
}
if (type === "type") {
if (value === "공정검사") return "bg-purple-100 text-purple-700 border-purple-200";
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
return "bg-emerald-100 text-emerald-700 border-emerald-200";
}
// defectRate
const rate = typeof value === "number" ? value : parseFloat(String(value));
if (rate > 3) return "text-red-600 font-semibold";
if (rate >= 1) return "text-amber-600 font-semibold";
return "text-emerald-600";
};
/* ───── 컴포넌트 ───── */
export default function QualityMonitoringPage() {
const { settings } = useMonitoringSettings("quality");
const theme = getMonitoringTheme(settings.theme);
const openTab = useTabStore((s) => s.openTab);
const tc = settings.tableColumns;
const [processData, setProcessData] = useState<ProcessRow[]>([]);
const [loading, setLoading] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
/* ───── 시계 ───── */
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
/* ───── 데이터 조회 ───── */
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
setProcessData(rows);
} catch (err) {
console.error("품질점검현황 데이터 조회 실패:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
/* ───── 자동 갱신 ───── */
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(fetchData, settings.refreshInterval * 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [autoRefresh, fetchData, settings.refreshInterval]);
/* ───── 검사 행 변환 ───── */
const inspectionRows: InspectionRow[] = useMemo(() => {
const today = new Date().toISOString().slice(0, 10);
return processData
.filter((r) => {
// 금일 데이터만
const dt = r.completed_at || r.started_at || "";
return dt.slice(0, 10) === today;
})
.map((r, idx) => {
const inspQty = r.input_qty || r.plan_qty || 0;
const goodQty = r.good_qty ?? 0;
const defectQty = r.defect_qty ?? 0;
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
const result: InspectionRow["result"] = r.status !== "completed" ? "대기" : defectQty > 0 ? "불합격" : "합격";
return {
no: idx + 1,
inspectionNo: `QC-${String(r.id).padStart(8, "0").slice(0, 8)}`,
inspectionType: "공정검사",
itemName: r.process_name || "-",
spec: r.process_code || "-",
inspectionQty: inspQty,
goodQty,
defectQty,
defectRate,
result,
inspectorName: r.worker_name || "-",
inspectedAt: r.completed_at || r.started_at || "-",
remark: "",
};
});
}, [processData]);
/* ───── 탭 필터링 ───── */
const filteredRows = useMemo(() => {
if (activeTab === "all" || activeTab === "process") return inspectionRows;
// 입고/출하는 데이터 없음
return [];
}, [activeTab, inspectionRows]);
/* ───── 요약 통계 ───── */
const summary = useMemo(() => {
const total = inspectionRows.length;
const passed = inspectionRows.filter((r) => r.result === "합격").length;
const failed = inspectionRows.filter((r) => r.result === "불합격").length;
const pending = inspectionRows.filter((r) => r.result === "대기").length;
const passRate = total > 0 ? (passed / total) * 100 : 0;
return { total, passed, failed, pending, passRate };
}, [inspectionRows]);
/* ───── 요약 카드 정의 ───── */
const summaryCards = [
{
label: "금일 검사건수",
value: fmt(summary.total),
sub: "건",
color: "from-slate-500 to-slate-600",
textColor: "text-white",
},
{
label: "합격",
value: fmt(summary.passed),
sub: "건",
color: "from-emerald-500 to-emerald-600",
textColor: "text-white",
},
{
label: "불합격",
value: fmt(summary.failed),
sub: "건",
color: "from-red-500 to-red-600",
textColor: "text-white",
},
{
label: "검사대기",
value: fmt(summary.pending),
sub: "건",
color: "from-amber-500 to-amber-600",
textColor: "text-white",
},
{
label: "합격률",
value: pct(summary.passRate),
sub: "",
color: "from-purple-500 to-purple-600",
textColor: "text-white",
},
];
/* ───── 렌더링 ───── */
return (
<div className={cn("flex h-full flex-col", theme.root)} style={theme.cssVars}>
{/* ── 헤더 ── */}
<div className={cn("flex items-center justify-between border-b px-6 py-4", theme.header)}>
<div className="flex items-center gap-3">
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
<h1 className={cn("text-xl font-bold", theme.headerText)}>
<span className="text-emerald-600"></span>
</h1>
</div>
<div className="flex items-center gap-4">
<div className={cn("flex items-center gap-2 text-sm", theme.mutedText)}>
<Clock className="h-4 w-4" />
<span className="font-mono">
{currentTime.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})}
</span>
</div>
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
<Button
variant={autoRefresh ? "default" : "outline"}
size="sm"
onClick={() => setAutoRefresh((p) => !p)}
className={cn(autoRefresh && "bg-emerald-600 text-white hover:bg-emerald-700")}
>
<Clock className="mr-1 h-4 w-4" />
{autoRefresh ? "ON" : "OFF"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openTab({ type: "admin", title: "모니터링 설정", adminUrl: "/monitoring/settings" })}
className="gap-1.5"
>
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* ── 본문 ── */}
<div className="flex-1 space-y-6 overflow-auto p-6">
{/* 요약 카드 */}
<div className="grid grid-cols-5 gap-4">
{summaryCards.map((card) => (
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
<p className="text-sm font-medium text-white/80">{card.label}</p>
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
{card.value}
{card.sub && <span className="ml-1 text-base font-normal text-white/70">{card.sub}</span>}
</p>
</div>
))}
</div>
{/* 검사유형 탭 */}
<div className="flex items-center gap-2">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
activeTab === tab.key
? "bg-emerald-600 text-white shadow"
: "border bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
)}
>
{tab.label}
</button>
))}
</div>
{/* 테이블 영역 */}
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
{/* 입고/출하 준비중 */}
{activeTab === "incoming" || activeTab === "shipping" ? (
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
<Search className="mb-4 h-12 w-12 opacity-40" />
<p className="text-lg font-medium"></p>
<p className="mt-1 text-sm">
{activeTab === "incoming" ? "입고검사" : "출하검사"} .
</p>
</div>
) : loading && filteredRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
<Loader2 className="mb-4 h-10 w-10 animate-spin" />
<p> ...</p>
</div>
) : filteredRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
<Inbox className="mb-4 h-12 w-12 opacity-40" />
<p className="text-lg font-medium"> </p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className={theme.tableHeader}>
<TableHead className="w-[50px] text-center">No</TableHead>
{tc.inspectionNo && <TableHead className="min-w-[120px]"></TableHead>}
{tc.inspectionType && <TableHead className="min-w-[90px] text-center"></TableHead>}
{tc.itemName && <TableHead className="min-w-[140px]"></TableHead>}
{tc.spec && <TableHead className="min-w-[100px]"></TableHead>}
{tc.inspectionQty && <TableHead className="min-w-[80px] text-right"></TableHead>}
{tc.passFailQty && <TableHead className="min-w-[80px] text-right"></TableHead>}
{tc.passFailQty && <TableHead className="min-w-[80px] text-right"></TableHead>}
{tc.defectRate && <TableHead className="min-w-[70px] text-right"></TableHead>}
{tc.resultBar && <TableHead className="min-w-[160px] text-center"></TableHead>}
{tc.judgment && <TableHead className="min-w-[70px] text-center"></TableHead>}
{tc.inspector && <TableHead className="min-w-[80px] text-center"></TableHead>}
{tc.inspectedAt && <TableHead className="min-w-[150px]"></TableHead>}
{tc.inspectionCriteria && <TableHead className="min-w-[100px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.map((row) => {
const goodPct = row.inspectionQty > 0 ? (row.goodQty / row.inspectionQty) * 100 : 0;
const defectPct = row.inspectionQty > 0 ? (row.defectQty / row.inspectionQty) * 100 : 0;
return (
<TableRow key={row.no} className={theme.tableRowHover}>
<TableCell className={cn("text-center text-sm", theme.mutedText)}>{row.no}</TableCell>
{tc.inspectionNo && <TableCell className="font-mono text-sm">{row.inspectionNo}</TableCell>}
{tc.inspectionType && (
<TableCell className="text-center">
<Badge
variant="outline"
className={cn("text-xs", badgeVariant("type", row.inspectionType))}
>
{row.inspectionType}
</Badge>
</TableCell>
)}
{tc.itemName && <TableCell className="text-sm font-medium">{row.itemName}</TableCell>}
{tc.spec && <TableCell className={cn("text-sm", theme.mutedText)}>{row.spec}</TableCell>}
{tc.inspectionQty && (
<TableCell className="text-right text-sm">{fmt(row.inspectionQty)}</TableCell>
)}
{tc.passFailQty && (
<TableCell className="text-right text-sm text-emerald-600">{fmt(row.goodQty)}</TableCell>
)}
{tc.passFailQty && (
<TableCell className="text-right text-sm text-red-600">{fmt(row.defectQty)}</TableCell>
)}
{tc.defectRate && (
<TableCell className={cn("text-right text-sm", badgeVariant("defectRate", row.defectRate))}>
{pct(row.defectRate)}
</TableCell>
)}
{tc.resultBar && (
<TableCell>
<div className="flex items-center gap-2">
<div className="flex h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-600">
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${goodPct}%` }}
/>
<div className="h-full bg-red-500 transition-all" style={{ width: `${defectPct}%` }} />
</div>
<span className={cn("w-[42px] text-right text-xs whitespace-nowrap", theme.mutedText)}>
{pct(goodPct)}
</span>
</div>
</TableCell>
)}
{tc.judgment && (
<TableCell className="text-center">
<Badge variant="outline" className={cn("text-xs", badgeVariant("result", row.result))}>
{row.result}
</Badge>
</TableCell>
)}
{tc.inspector && <TableCell className="text-center text-sm">{row.inspectorName}</TableCell>}
{tc.inspectedAt && (
<TableCell className={cn("text-sm", theme.mutedText)}>
{row.inspectedAt !== "-"
? new Date(row.inspectedAt).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
: "-"}
</TableCell>
)}
{tc.inspectionCriteria && <TableCell className={cn("text-sm", theme.mutedText)}>-</TableCell>}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More