Compare commits

..

10 Commits

Author SHA1 Message Date
SeongHyun Kim 712f81f6cb Merge branch 'ksh-function-stabilization' into ksh-v2-work
ksh-function-stabilization의 9개 커밋을 ksh-v2-work에 병합한다.
[병합 내용]
- BLOCK O: pop-search 연결 탭 일관성 통합
- BLOCK P: 날짜 입력 타입 구현 + 셀 반응형 레이아웃
- pop-button 설정 패널 UX/UI 전면 개선
- 일괄 채번 + 모달 distinct + 선택 해제
- pop-scanner 바코드/QR 스캐너 컴포넌트
- pop-button 제어 실행 + 연결 데이터 UX
- BLOCK R: PC <-> POP 네비게이션 + Landing
- pop-profile 컴포넌트 (10번째 POP 컴포넌트)
- BLOCK S: 로그인 POP 모드 토글
[충돌 해결 3건 - 모두 양쪽 통합]
- UserDropdown.tsx: HEAD 결재함 + source POP 모드 메뉴 통합
- AppLayout.tsx: HEAD 결재함 + source POP 모드 메뉴 (모바일+사이드바 2곳)
- MenuFormModal.tsx: HEAD menuIcon 필드 + source 주석 제거 통합
2026-03-09 15:36:53 +09:00
SeongHyun Kim 48e9ece4f7 feat(login): POP 모드 토글 추가 - 로그인 시 POP/PC 진입 선택
로그인 폼에 POP 모드 Switch 토글을 추가하여 현장 작업자가
로그인 시점에서 POP 화면으로 직접 진입할 수 있도록 한다.
토글 상태는 localStorage에 저장되어 다음 로그인 시 유지된다.
[백엔드]
- 로그인 응답에 popLandingPath 추가 (getPopMenuList 재사용)
- AdminService/paramMap 변수 스코프 버그 수정
  (try 블록 내부 선언 -> 외부로 이동)
[프론트엔드]
- useLogin: isPopMode 상태 + localStorage 연동 + POP 분기 라우팅
- LoginForm: POP 모드 Switch 토글 UI (Monitor 아이콘)
- POP 미설정 시 에러 메시지 표시 후 로그인 중단
- LoginResponse 타입에 popLandingPath 필드 추가
2026-03-09 15:15:15 +09:00
SeongHyun Kim 4176fed07f feat(pop): pop-profile 컴포넌트 추가 - 사용자 프로필/PC전환/로그아웃
디자이너 팔레트에서 배치 가능한 10번째 POP 컴포넌트로 pop-profile을
추가한다. 화면 설계자가 필요한 화면에만 프로필 기능을 배치할 수 있도록
시스템 UI가 아닌 컴포넌트 등록 방식으로 구현한다.
[뷰어 컴포넌트]
- useAuth() 연동으로 실제 로그인 사용자 정보 표시
- Popover 드롭다운: 사용자 정보 + POP 대시보드 / PC 모드 / 로그아웃
- 비로그인 시 로그인 버튼 표시
- 아바타: 사용자 사진 또는 이름 이니셜 표시
[설정 패널]
- 아바타 크기 선택 (sm 32px / md 40px / lg 48px)
- 메뉴 항목 개별 on/off (대시보드 이동 / PC 모드 전환 / 로그아웃)
[디자이너 통합]
- PopComponentType에 "pop-profile" 추가
- DEFAULT_COMPONENT_GRID_SIZE: 1x1
- PALETTE_ITEMS: UserCircle 아이콘 + 설명
- COMPONENT_TYPE_LABELS: "프로필"
- PopComponentRegistry 등록 (category: action)
2026-03-09 13:21:18 +09:00
SeongHyun Kim 3933f1e966 feat(pop): PC <-> POP 모드 전환 네비게이션 + POP 기본 화면(Landing) 기능
PC 모드에서 프로필 드롭다운을 통해 POP 화면으로 진입하고, POP에서 PC로
돌아오는 양방향 네비게이션을 구현한다. 기존 메뉴 시스템(menu_info)을 활용하여
POP 화면의 권한 제어와 회사별 관리가 가능하도록 한다.
[백엔드: POP 메뉴 조회 API]
- AdminService.getPopMenuList: L1 POP 메뉴(menu_desc [POP] 또는
  menu_name_kor POP 포함) 하위의 active L2 메뉴 조회
- company_code 필터링 적용 (L1 + L2 모두)
- landingMenu 반환: menu_desc에 [POP_LANDING] 태그가 있는 메뉴
- GET /admin/pop-menus 라우트 추가
[프론트: PC -> POP 진입]
- AppLayout: handlePopModeClick 함수 추가
  - landingMenu 있으면 해당 URL로 바로 이동
  - 없으면 childMenus 수에 따라 단일 화면/대시보드/안내 분기
- UserDropdown: onPopModeClick prop + "POP 모드" 메뉴 항목 추가
- 사이드바 하단 + 모바일 헤더 프로필 드롭다운 2곳 모두 적용
[프론트: POP -> PC 복귀]
- DashboardHeader: "PC 모드" 버튼 추가 (router.push "/")
- POP 개별 화면 page.tsx: 상단 네비게이션 바 추가
  (POP 대시보드 / PC 모드 버튼)
[프론트: POP 대시보드 동적 메뉴]
- PopDashboard: 하드코딩 MENU_ITEMS -> menuApi.getPopMenus() API 조회
- API 실패 시 하드코딩 fallback 유지
[프론트: POP 기본 화면 설정 (MenuFormModal)]
- L2 POP 화면 수정 시 "POP 기본 화면으로 설정" 체크박스 추가
- 체크 시 menu_desc에 [POP_LANDING] 태그 자동 추가/제거
- 회사당 1개만 설정 가능 (다른 메뉴에 이미 설정 시 비활성화)
[API 타입]
- PopMenuItem, PopMenuResponse(landingMenu 포함) 인터페이스 추가
- menuApi.getPopMenus() 함수 추가
2026-03-09 12:16:26 +09:00
SeongHyun Kim 62e11127a7 feat(pop-button): 제어 실행 기능 추가 + 연결 데이터 UX 개선 + 필터 UI 개선
POP 버튼 컴포넌트에서 백엔드 제어관리(node_flows)를 직접 실행할 수
있도록 "제어 실행" 작업 유형을 추가하고, 데이터 수정 시 연결된
컴포넌트 기반으로 필드를 선택하는 UX로 개선한다.
[제어 실행 (custom-event 확장)]
- ButtonTask에 flowId/flowName 필드 추가
- ControlFlowTaskForm: Combobox(Popover+Command)로 검색/선택 UI
- executePopAction: flowId 기반 POST /dataflow/node-flows/:flowId/execute
- 기존 eventName 발행 메커니즘은 폴백으로 유지
[연결 데이터 UX 개선]
- extractCardFields -> extractConnectedFields 리팩토링
  (connections 기반 연결 컴포넌트에서만 필드 추출)
- pop-card-list/pop-field/pop-search 타입별 필드 추출 로직
- 시스템 필드(__cart_quantity 등)에 한글 라벨 부여
- UI 라벨: "화면 데이터" -> "연결된 데이터"
[pop-card-list 필터 UI]
- 필터 조건 레이아웃을 가로 -> 세로 스택으로 변경
- 조건 번호 표시 + 입력 필드 높이 확대
[버그 수정]
- apiClient baseURL 이중 /api 경로 수정
- 응답 필드명 camelCase 통일
2026-03-07 09:56:58 +09:00
SeongHyun Kim 20ad1d6829 feat(pop-scanner): 바코드/QR 스캐너 컴포넌트 + 멀티필드 파싱 + 반자동 매핑
모바일/태블릿 환경에서 바코드·QR을 카메라로 스캔하여 검색·입력 필드에
값을 자동 전달하는 pop-scanner 컴포넌트를 추가하고, JSON 형태의
멀티필드 데이터를 여러 컴포넌트에 분배하는 파싱 체계를 구현한다.
[pop-scanner 신규]
- 카메라 스캔 UI (BarcodeScanModal) + 아이콘 전용 버튼
- parseMode 3모드: none(단일값), auto(전역 자동매칭), json(반자동 매핑)
- auto: scan_auto_fill 전역 이벤트로 fieldName 기준 자동 입력
- json: 연결된 컴포넌트 필드를 체크박스 목록으로 표시,
  fieldName==JSON키 자동 매칭 + 관리자 override(enabled/sourceKey)
- getDynamicConnectionMeta로 parseMode별 sendable 동적 생성
[pop-field 연동]
- scan_auto_fill 구독: sections.fields의 fieldName과 JSON 키 매칭
- columnMapping 키를 fieldName 기준으로 통일 (fieldId→fieldName)
- targetColumn 선택 시 fieldName 자동 동기화
- 새 필드 fieldName 기본값을 빈 문자열로 변경
[pop-search 연동]
- scan_auto_fill 구독: filterColumns 전체 키 매칭
- set_value 수신 시 모달 타입이면 modalDisplayText도 갱신
[BarcodeScanModal 개선]
- 모달 열릴 때 상태 리셋 (scannedCode/error/isScanning)
- "다시 스캔" 버튼 추가
- 스캔 가이드 영역 확대 (h-3/5 w-4/5)
[getConnectedFields 필드 추출]
- filterColumns(복수) > modalConfig.valueField > fieldName 우선순위
- pop-field sections.fields[].fieldName 추출
2026-03-06 19:52:18 +09:00
SeongHyun Kim 955da6ae87 feat(pop): 일괄 채번 + 모달 distinct 중복 제거 + 선택 해제 기능
장바구니에서 여러 품목을 한꺼번에 입고 확정할 때 동일한 입고번호를
공유하도록 일괄 채번(shareAcrossItems) 기능을 추가하고, 입고 목록
화면에서 모달 검색 시 중복 항목을 제거하는 distinct 옵션과 선택된
필터를 해제하는 X 버튼을 구현한다.
[일괄 채번]
- pop-field 자동생성 설정에 shareAcrossItems 스위치 추가
- 백엔드 data-save / inbound-confirm: shareAcrossItems=true 매핑은
  아이템 루프 전 1회만 allocateCode 호출하여 공유 코드 발급
- PopFieldComponent에서 shareAcrossItems 값을 백엔드로 전달
[모달 distinct]
- ModalSelectConfig에 distinct?: boolean 필드 추가
- 설정 패널 필터 탭 영역에 "중복 제거" 체크박스 배치
- ModalDialog fetchData에서 displayField 기준 Set 필터링
[선택 해제]
- ModalSearchInput: 값 선택 시 > 아이콘 -> X 버튼으로 전환
- X 클릭 시 modalDisplayText + 필터값 초기화 (stopPropagation)
- handleModalClear 콜백 + onModalClear prop 체인 연결
2026-03-06 15:59:40 +09:00
SeongHyun Kim 516517eb34 feat(pop-button): 설정 패널 UX/UI 전면 개선 - 비개발자 친화적 설정 경험
화면 디자이너(비개발자)가 버튼 작업 설정을 직관적으로 할 수 있도록
설정 패널의 용어, 레이아웃, 구조를 전면 개선한다.
[디자인 통일]
- Input/Select 높이 h-8, 라벨 text-xs font-medium, 도움말 text-[11px]로 통일
- db-conditional UI를 가로 나열에서 세로 스택으로 전환 (좁은 패널 잘림 방지)
- 작업 항목 간 간격, 패딩, 둥근 모서리 일관성 확보
[자연어 라벨]
- "대상 테이블" → "어떤 테이블을 수정할까요?"
- "변경 컬럼" → "어떤 항목(컬럼)을 바꿀까요?"
- "연산" → "어떻게 바꿀까요?" + 각 연산별 설명 도움말
- "값 출처: 고정값" → "직접 입력", "연결 데이터" → "화면 데이터에서 가져오기"
- 비교 연산자에 한글 설명 추가 (">=" → ">= (이상이면)")
[구조 개선]
- "조회 키"를 "고급 설정" 토글로 숨김 (기본 접힘, 대부분 자동 매칭)
- "연결 필드명" 수동 입력 → 카드 컴포넌트 필드 목록에서 Select 선택
- 접힌 헤더에 요약 텍스트 표시 + 마우스 호버 시 전체 툴팁
- 펼친 상태 하단에 설정 요약 미리보기
[컬럼 코멘트 표시]
- 백엔드: getTableSchema SQL에 col_description() 추가
- 프론트: ColumnCombobox에서 코멘트 표시 + 한글명 검색 지원
- ColumnInfo 인터페이스에 comment 필드 추가
2026-03-06 14:06:53 +09:00
SeongHyun Kim 297b14d706 feat(pop-search): 날짜 입력 타입 구현 + 셀 반응형 레이아웃 개선
pop-search 컴포넌트의 date 입력 타입이 미구현 상태에서 네이티브 input으로
임시 처리되어 있던 것을 shadcn Calendar 기반 POP 전용 UI로 교체하고,
셀 크기에 입력 필드가 반응하지 않던 레이아웃 문제를 함께 해결한다.
[BLOCK P: 날짜 입력 타입]
- DateSingleInput: Calendar + Dialog/Popover 기반 단일 날짜 선택
- DateRangeInput: 프리셋(오늘/이번주/이번달) + Calendar 기간 선택
- CalendarDisplayMode(popover/modal) 설정으로 터치 환경 대응
- resolveFilterMode로 date->equals, range->range 자동 결정
- DateDetailSettings 설정 패널 추가 (모드 선택 + 캘린더 표시 방식)
- PopStringListComponent: range 필터 모드 구현 + 날짜 equals 비교 개선
[레이아웃 개선]
- 입력 필드가 셀 너비/높이에 반응하도록 h-full min-h-8 + w-full 적용
- labelPosition("top"/"left") 분기 제거 -> 항상 라벨 위 고정
- 설정 패널에서 "라벨 위치" Select UI 제거
- 기본 배치 크기 colSpan:4 rowSpan:2 -> colSpan:2 rowSpan:1
2026-03-06 12:22:23 +09:00
SeongHyun Kim 47384e1c2b feat(pop-search): 연결 탭 일관성 통합 + 필터 설정을 상세설정으로 이동
검색 컴포넌트의 연결 탭이 다른 컴포넌트(입력, 카드리스트)와 달리
필터 설정이 연결 폼에 포함되어 있던 비일관성을 해결한다.
- ConnectionEditor: FilterConnectionForm 제거, 모든 컴포넌트가
  SimpleConnectionForm 사용하도록 통합
- PopSearchConfig: 상세설정 탭에 FilterConnectionSection 추가
  (text/select/date-preset/modal 타입별 통합)
- FilterConnectionSection: 연결된 대상 컴포넌트의 테이블 컬럼을
  API 조회하여 체크박스 기반 복수 선택 UI 제공
  ("카드에서 표시 중" / "기타 컬럼" 그룹 구분)
- types: SearchFilterMode, filterColumns 타입 추가
- PopSearchComponent: filterColumns 배열을 이벤트 payload에 포함
- useConnectionResolver: filterColumns를 targetColumns로 전달,
  auto-match에서 filter_value 타입 매칭 + filterConfig 자동 추론
2026-03-06 11:00:31 +09:00
156 changed files with 4342 additions and 16190 deletions

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

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"]

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`

File diff suppressed because it is too large Load Diff

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"
}
}

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;

View File

@ -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);
}
};

View File

@ -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);
}
};

View File

@ -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: '로그아웃되었습니다.',
},
});
};

View File

@ -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();
}
}

View File

@ -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);
}
};

View File

@ -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);
}
};

View File

@ -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);
}
};

View File

@ -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);
}
};

View File

@ -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);
};

View File

@ -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();
};

View File

@ -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();
};

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;
};

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,
};

View File

@ -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;
};

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;
};

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;
};

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;

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;

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;

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;

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;

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;

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;

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;

View File

@ -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, {});
},
};

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,
};

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;

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: 권한 없음
*/

View File

@ -22,7 +22,6 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"http-proxy-middleware": "^3.0.5",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
@ -3319,15 +3318,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-proxy": {
"version": "1.17.17",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/imap": {
"version": "0.8.42",
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
@ -4429,6 +4419,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@ -6163,6 +6154,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@ -6895,20 +6887,6 @@
"node": ">= 0.8"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@ -6922,29 +6900,6 @@
"node": ">= 14"
}
},
"node_modules/http-proxy-middleware": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.15",
"debug": "^4.3.6",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.3",
"is-plain-object": "^5.0.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/http-proxy/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@ -7253,6 +7208,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -7282,6 +7238,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@ -7312,6 +7269,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@ -7336,15 +7294,6 @@
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
@ -8617,6 +8566,7 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@ -9438,6 +9388,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -9995,12 +9946,6 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@ -10879,6 +10824,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"

View File

@ -36,7 +36,6 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"http-proxy-middleware": "^3.0.5",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",

View File

@ -16,17 +16,14 @@ import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// ============================================
// 처리되지 않은 Promise 거부 핸들러
process.on(
"unhandledRejection",
(reason: Error | any, promise: Promise<any>) => {
process.on("unhandledRejection", (reason: Error | any, promise: Promise<any>) => {
logger.error("⚠️ Unhandled Promise Rejection:", {
reason: reason?.message || reason,
stack: reason?.stack,
});
// 프로세스를 종료하지 않고 로깅만 수행
// 심각한 에러의 경우 graceful shutdown 고려
},
);
});
// 처리되지 않은 예외 핸들러
process.on("uncaughtException", (error: Error) => {
@ -41,16 +38,13 @@ process.on("uncaughtException", (error: Error) => {
// SIGTERM 시그널 처리 (Docker/Kubernetes 환경)
process.on("SIGTERM", () => {
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
const { stopAiAssistant } = require("./utils/startAiAssistant");
stopAiAssistant();
// 여기서 연결 풀 정리 등 cleanup 로직 추가 가능
process.exit(0);
});
// SIGINT 시그널 처리 (Ctrl+C)
process.on("SIGINT", () => {
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
const { stopAiAssistant } = require("./utils/startAiAssistant");
stopAiAssistant();
process.exit(0);
});
@ -118,9 +112,7 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes, {
entityOptionsRouter,
} from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
@ -136,9 +128,7 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -162,7 +152,7 @@ app.use(
], // 프론트엔드 도메인 허용
},
},
}),
})
);
app.use(compression());
app.use(express.json({ limit: "10mb" }));
@ -185,13 +175,13 @@ app.use(
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
"Content-Type, Authorization"
);
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
res.setHeader("Cache-Control", "public, max-age=3600");
next();
},
express.static(path.join(process.cwd(), "uploads")),
express.static(path.join(process.cwd(), "uploads"))
);
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
@ -211,7 +201,7 @@ app.use(
],
preflightContinue: false,
optionsSuccessStatus: 200,
}),
})
);
// Rate Limiting (개발 환경에서는 완화)
@ -327,9 +317,7 @@ app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
app.use("/api/mold", moldRoutes); // 금형 관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
app.use("/api/approval", approvalRoutes); // 결재 시스템
// app.use("/api/collections", collectionRoutes); // 임시 주석
@ -418,14 +406,6 @@ app.listen(PORT, HOST, async () => {
} catch (error) {
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
}
// AI 어시스턴트 서비스 함께 기동 (한 번에 킬 가능)
try {
const { startAiAssistant } = await import("./utils/startAiAssistant");
startAiAssistant();
} catch (error) {
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
}
});
export default app;

View File

@ -108,6 +108,46 @@ export async function getUserMenus(
}
}
/**
* POP
* [POP] L1 active
*/
export async function getPopMenus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userCompanyCode = req.user?.companyCode || "ILSHIN";
const userType = req.user?.userType;
const result = await AdminService.getPopMenuList({
userCompanyCode,
userType,
});
const response: ApiResponse<any> = {
success: true,
message: "POP 메뉴 목록 조회 성공",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("POP 메뉴 목록 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.",
error: {
code: "POP_MENU_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/

View File

@ -50,9 +50,7 @@ export class AuthController {
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
// 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null;
try {
// 메뉴 조회를 위한 공통 파라미터
const { AdminService } = await import("../services/adminService");
const paramMap = {
userId: loginResult.userInfo.userId,
@ -61,18 +59,15 @@ export class AuthController {
userLang: "ko",
};
// 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null;
try {
const menuList = await AdminService.getUserMenuList(paramMap);
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
// 접근 가능한 첫 번째 메뉴 찾기
// 조건:
// 1. LEV (레벨)이 2 이상 (최상위 폴더 제외)
// 2. MENU_URL이 있고 비어있지 않음
// 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴
const firstMenu = menuList.find((menu: any) => {
const level = menu.lev || menu.level;
const url = menu.menu_url || menu.url;
return level >= 2 && url && url.trim() !== "" && url !== "#";
});
@ -86,13 +81,30 @@ export class AuthController {
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
}
// POP 랜딩 경로 조회
let popLandingPath: string | null = null;
try {
const popResult = await AdminService.getPopMenuList(paramMap);
if (popResult.landingMenu?.menu_url) {
popLandingPath = popResult.landingMenu.menu_url;
} else if (popResult.childMenus.length === 1) {
popLandingPath = popResult.childMenus[0].menu_url;
} else if (popResult.childMenus.length > 1) {
popLandingPath = "/pop";
}
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);
} catch (popError) {
logger.warn("POP 메뉴 조회 중 오류 (무시):", popError);
}
res.status(200).json({
success: true,
message: "로그인 성공",
data: {
userInfo,
token: loginResult.token,
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
firstMenuPath,
popLandingPath,
},
});
} else {

View File

@ -818,13 +818,13 @@ export const getCategoryValueCascadingParentOptions = async (
const group = groupResult.rows[0];
// 부모 카테고리 값 조회 (category_values에서)
// 부모 카테고리 값 조회 (table_column_category_values에서)
let optionsQuery = `
SELECT
value_code as value,
value_label as label,
value_order as display_order
FROM category_values
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true
@ -916,13 +916,13 @@ export const getCategoryValueCascadingChildOptions = async (
const group = groupResult.rows[0];
// 자식 카테고리 값 조회 (category_values에서)
// 자식 카테고리 값 조회 (table_column_category_values에서)
let optionsQuery = `
SELECT
value_code as value,
value_label as label,
value_order as display_order
FROM category_values
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true

View File

@ -417,10 +417,10 @@ export class EntityJoinController {
// 1. 현재 테이블의 Entity 조인 설정 조회
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
// 🆕 화면 디자이너용: category_values는 카테고리 드롭다운용이므로 제외
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
const joinConfigs = allJoinConfigs.filter(
(config) => config.referenceTable !== "category_values"
(config) => config.referenceTable !== "table_column_category_values"
);
if (joinConfigs.length === 0) {

View File

@ -1,497 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query } from "../database/db";
import { logger } from "../utils/logger";
// ============================================
// 금형 마스터 CRUD
// ============================================
export async function getMoldList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { mold_code, mold_name, mold_type, operation_status } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (mold_code) {
conditions.push(`mold_code ILIKE $${paramIndex}`);
params.push(`%${mold_code}%`);
paramIndex++;
}
if (mold_name) {
conditions.push(`mold_name ILIKE $${paramIndex}`);
params.push(`%${mold_name}%`);
paramIndex++;
}
if (mold_type) {
conditions.push(`mold_type = $${paramIndex}`);
params.push(mold_type);
paramIndex++;
}
if (operation_status) {
conditions.push(`operation_status = $${paramIndex}`);
params.push(operation_status);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `SELECT * FROM mold_mng ${whereClause} ORDER BY created_date DESC`;
const result = await query(sql, params);
logger.info("금형 목록 조회", { companyCode, count: result.length });
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("금형 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function getMoldDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `SELECT * FROM mold_mng WHERE mold_code = $1 LIMIT 1`;
params = [moldCode];
} else {
sql = `SELECT * FROM mold_mng WHERE mold_code = $1 AND company_code = $2 LIMIT 1`;
params = [moldCode, companyCode];
}
const result = await query(sql, params);
if (result.length === 0) {
res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." });
return;
}
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("금형 상세 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createMold(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
mold_code, mold_name, mold_type, category, manufacturer,
manufacturing_number, manufacturing_date, cavity_count,
shot_count, mold_quantity, base_input_qty, operation_status,
remarks, image_path, memo,
} = req.body;
if (!mold_code || !mold_name) {
res.status(400).json({ success: false, message: "금형코드와 금형명은 필수입니다." });
return;
}
const sql = `
INSERT INTO mold_mng (
company_code, mold_code, mold_name, mold_type, category,
manufacturer, manufacturing_number, manufacturing_date,
cavity_count, shot_count, mold_quantity, base_input_qty,
operation_status, remarks, image_path, memo, writer
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
RETURNING *
`;
const params = [
companyCode, mold_code, mold_name, mold_type || null, category || null,
manufacturer || null, manufacturing_number || null, manufacturing_date || null,
cavity_count || 0, shot_count || 0, mold_quantity || 1, base_input_qty || 0,
operation_status || "ACTIVE", remarks || null, image_path || null, memo || null, userId,
];
const result = await query(sql, params);
logger.info("금형 생성", { companyCode, moldCode: mold_code });
res.json({ success: true, data: result[0], message: "금형이 등록되었습니다." });
} catch (error: any) {
if (error.code === "23505") {
res.status(409).json({ success: false, message: "이미 존재하는 금형코드입니다." });
return;
}
logger.error("금형 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateMold(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
const {
mold_name, mold_type, category, manufacturer,
manufacturing_number, manufacturing_date, cavity_count,
shot_count, mold_quantity, base_input_qty, operation_status,
remarks, image_path, memo,
} = req.body;
const sql = `
UPDATE mold_mng SET
mold_name = COALESCE($1, mold_name),
mold_type = $2, category = $3, manufacturer = $4,
manufacturing_number = $5, manufacturing_date = $6,
cavity_count = COALESCE($7, cavity_count),
shot_count = COALESCE($8, shot_count),
mold_quantity = COALESCE($9, mold_quantity),
base_input_qty = COALESCE($10, base_input_qty),
operation_status = COALESCE($11, operation_status),
remarks = $12, image_path = $13, memo = $14,
updated_date = NOW()
WHERE mold_code = $15 AND company_code = $16
RETURNING *
`;
const params = [
mold_name, mold_type, category, manufacturer,
manufacturing_number, manufacturing_date,
cavity_count, shot_count, mold_quantity, base_input_qty,
operation_status, remarks, image_path, memo,
moldCode, companyCode,
];
const result = await query(sql, params);
if (result.length === 0) {
res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." });
return;
}
logger.info("금형 수정", { companyCode, moldCode });
res.json({ success: true, data: result[0], message: "금형이 수정되었습니다." });
} catch (error: any) {
logger.error("금형 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMold(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
// 관련 데이터 먼저 삭제
await query(`DELETE FROM mold_serial WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]);
await query(`DELETE FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]);
await query(`DELETE FROM mold_part WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]);
const result = await query(
`DELETE FROM mold_mng WHERE mold_code = $1 AND company_code = $2 RETURNING id`,
[moldCode, companyCode]
);
if (result.length === 0) {
res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." });
return;
}
logger.info("금형 삭제", { companyCode, moldCode });
res.json({ success: true, message: "금형이 삭제되었습니다." });
} catch (error: any) {
logger.error("금형 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 일련번호 CRUD
// ============================================
export async function getMoldSerials(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
const sql = `SELECT * FROM mold_serial WHERE mold_code = $1 AND company_code = $2 ORDER BY serial_number`;
const result = await query(sql, [moldCode, companyCode]);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("일련번호 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { moldCode } = req.params;
const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body;
let finalSerialNumber = serial_number;
// 일련번호가 비어있으면 채번 규칙으로 자동 생성
if (!finalSerialNumber) {
try {
const { numberingRuleService } = await import("../services/numberingRuleService");
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode,
"mold_serial",
"serial_number"
);
if (rule) {
// formData에 mold_code를 포함 (reference 파트에서 참조)
const formData = { mold_code: moldCode, ...req.body };
finalSerialNumber = await numberingRuleService.allocateCode(
rule.ruleId,
companyCode,
formData
);
logger.info("일련번호 자동 채번 완료", { serialNumber: finalSerialNumber, ruleId: rule.ruleId });
}
} catch (numError: any) {
logger.error("일련번호 자동 채번 실패", { error: numError.message });
}
}
if (!finalSerialNumber) {
res.status(400).json({ success: false, message: "일련번호를 생성할 수 없습니다. 채번 규칙을 확인해주세요." });
return;
}
const sql = `
INSERT INTO mold_serial (company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING *
`;
const params = [
companyCode, moldCode, finalSerialNumber, status || "STORED",
progress || 0, work_description || null, manager || null,
completion_date || null, remarks || null, userId,
];
const result = await query(sql, params);
res.json({ success: true, data: result[0], message: "일련번호가 등록되었습니다." });
} catch (error: any) {
if (error.code === "23505") {
res.status(409).json({ success: false, message: "이미 존재하는 일련번호입니다." });
return;
}
logger.error("일련번호 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const result = await query(
`DELETE FROM mold_serial WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.length === 0) {
res.status(404).json({ success: false, message: "일련번호를 찾을 수 없습니다." });
return;
}
res.json({ success: true, message: "일련번호가 삭제되었습니다." });
} catch (error: any) {
logger.error("일련번호 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 점검항목 CRUD
// ============================================
export async function getMoldInspections(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
const sql = `SELECT * FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`;
const result = await query(sql, [moldCode, companyCode]);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("점검항목 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createMoldInspection(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { moldCode } = req.params;
const {
inspection_item, inspection_cycle, inspection_method,
inspection_content, lower_limit, upper_limit, unit,
is_active, checklist, remarks,
} = req.body;
if (!inspection_item) {
res.status(400).json({ success: false, message: "점검항목명은 필수입니다." });
return;
}
const sql = `
INSERT INTO mold_inspection_item (
company_code, mold_code, inspection_item, inspection_cycle,
inspection_method, inspection_content, lower_limit, upper_limit,
unit, is_active, checklist, remarks, writer
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *
`;
const params = [
companyCode, moldCode, inspection_item, inspection_cycle || null,
inspection_method || null, inspection_content || null,
lower_limit || null, upper_limit || null, unit || null,
is_active || "Y", checklist || null, remarks || null, userId,
];
const result = await query(sql, params);
res.json({ success: true, data: result[0], message: "점검항목이 등록되었습니다." });
} catch (error: any) {
logger.error("점검항목 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMoldInspection(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const result = await query(
`DELETE FROM mold_inspection_item WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.length === 0) {
res.status(404).json({ success: false, message: "점검항목을 찾을 수 없습니다." });
return;
}
res.json({ success: true, message: "점검항목이 삭제되었습니다." });
} catch (error: any) {
logger.error("점검항목 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 부품 CRUD
// ============================================
export async function getMoldParts(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
const sql = `SELECT * FROM mold_part WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`;
const result = await query(sql, [moldCode, companyCode]);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("부품 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createMoldPart(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { moldCode } = req.params;
const {
part_name, replacement_cycle, unit, specification,
manufacturer, manufacturer_code, image_path, remarks,
} = req.body;
if (!part_name) {
res.status(400).json({ success: false, message: "부품명은 필수입니다." });
return;
}
const sql = `
INSERT INTO mold_part (
company_code, mold_code, part_name, replacement_cycle,
unit, specification, manufacturer, manufacturer_code,
image_path, remarks, writer
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *
`;
const params = [
companyCode, moldCode, part_name, replacement_cycle || null,
unit || null, specification || null, manufacturer || null,
manufacturer_code || null, image_path || null, remarks || null, userId,
];
const result = await query(sql, params);
res.json({ success: true, data: result[0], message: "부품이 등록되었습니다." });
} catch (error: any) {
logger.error("부품 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMoldPart(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const result = await query(
`DELETE FROM mold_part WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.length === 0) {
res.status(404).json({ success: false, message: "부품을 찾을 수 없습니다." });
return;
}
res.json({ success: true, message: "부품이 삭제되었습니다." });
} catch (error: any) {
logger.error("부품 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 일련번호 현황 집계
// ============================================
export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
const sql = `
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use,
COUNT(*) FILTER (WHERE status = 'REPAIR') as repair,
COUNT(*) FILTER (WHERE status = 'STORED') as stored,
COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed
FROM mold_serial
WHERE mold_code = $1 AND company_code = $2
`;
const result = await query(sql, [moldCode, companyCode]);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("일련번호 현황 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -405,30 +405,6 @@ router.post(
}
);
// 테이블+컬럼 기반 채번 규칙 조회 (메인 API)
router.get(
"/by-column/:tableName/:columnName",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode,
tableName,
columnName
);
return res.json({ success: true, data: rule });
} catch (error: any) {
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", {
error: error.message,
});
return res.status(500).json({ success: false, error: error.message });
}
}
);
// ==================== 테스트 테이블용 API ====================
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회

View File

@ -3019,72 +3019,3 @@ export async function toggleColumnUnique(
});
}
}
/**
* ( )
*
* @route GET /api/table-management/numbering-columns
*/
export async function getNumberingColumnsByCompany(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
logger.info("회사별 채번 컬럼 조회 요청", { companyCode });
if (!companyCode) {
res.status(400).json({
success: false,
message: "회사 코드를 확인할 수 없습니다.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
const targetCompanyCode = companyCode === "*" ? "*" : companyCode;
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'numbering'
AND ttc.company_code = $1
ORDER BY ttc.table_name, ttc.column_name
`;
const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]);
logger.info("채번 컬럼 조회 완료", {
companyCode,
rowCount: columnsResult.rows.length,
});
res.json({
success: true,
data: columnsResult.rows,
});
} catch (error: any) {
logger.error("채번 컬럼 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "채번 컬럼 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}

View File

@ -2,6 +2,7 @@ import { Router } from "express";
import {
getAdminMenus,
getUserMenus,
getPopMenus,
getMenuInfo,
saveMenu, // 메뉴 추가
updateMenu, // 메뉴 수정
@ -40,6 +41,7 @@ router.use(authenticateToken);
// 메뉴 관련 API
router.get("/menus", getAdminMenus);
router.get("/user-menus", getUserMenus);
router.get("/pop-menus", getPopMenus);
router.get("/menus/:menuId", getMenuInfo);
router.post("/menus", saveMenu); // 메뉴 추가
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)

View File

@ -1,31 +0,0 @@
/**
* AI API
* - /api/ai/v1/* AI ( 3100 )
* - VEXPLOR와 쓰려면: 프론트(9771) (8080) 3100
*/
import { createProxyMiddleware } from "http-proxy-middleware";
import type { RequestHandler } from "express";
const AI_SERVICE_URL =
process.env.AI_ASSISTANT_SERVICE_URL || "http://127.0.0.1:3100";
const aiAssistantProxy: RequestHandler = createProxyMiddleware({
target: AI_SERVICE_URL,
changeOrigin: true,
pathRewrite: { "^/api/ai/v1": "/api/v1" },
// 대상 서비스 미기동 시 502 등 에러 처리 (v3 타입에 없을 수 있음)
onError: (_err, _req, res) => {
if (!res.headersSent) {
res.status(502).json({
success: false,
error: {
code: "AI_SERVICE_UNAVAILABLE",
message:
"AI 어시스턴트 서비스를 사용할 수 없습니다. AI 서비스(기본 3100 포트)를 기동한 뒤 다시 시도하세요.",
},
});
}
},
} as Parameters<typeof createProxyMiddleware>[0]);
export default aiAssistantProxy;

View File

@ -1,7 +1,6 @@
import express from "express";
import { dataService } from "../services/dataService";
import { masterDetailExcelService } from "../services/masterDetailExcelService";
import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import { auditLogService } from "../services/auditLogService";
@ -261,117 +260,6 @@ router.post(
}
);
// ================================
// 다중 테이블 엑셀 업로드 API
// ================================
/**
*
* GET /api/data/multi-table/auto-detect?rootTable=customer_mng
*
* FK
* TableChainConfig를 .
*/
router.get(
"/multi-table/auto-detect",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const rootTable = req.query.rootTable as string;
const screenId = req.query.screenId ? Number(req.query.screenId) : undefined;
const companyCode = req.user?.companyCode || "*";
if (!rootTable) {
return res.status(400).json({
success: false,
message: "rootTable 파라미터가 필요합니다.",
});
}
const config = await multiTableExcelService.autoDetectTableChain(
rootTable,
companyCode,
screenId
);
return res.json({ success: true, data: config });
} catch (error: any) {
console.error("다중 테이블 자동 감지 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "자동 감지 중 오류가 발생했습니다.",
});
}
}
);
/**
*
* POST /api/data/multi-table/upload
*
* Body: { config: TableChainConfig, modeId: string, rows: Record<string, any>[] }
*/
router.post(
"/multi-table/upload",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { config, modeId, rows } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
if (!config || !modeId || !rows || !Array.isArray(rows)) {
return res.status(400).json({
success: false,
message: "config, modeId, rows 배열이 필요합니다.",
});
}
if (rows.length === 0) {
return res.status(400).json({
success: false,
message: "업로드할 데이터가 없습니다.",
});
}
console.log(`다중 테이블 엑셀 업로드:`, {
configId: config.id,
modeId,
rowCount: rows.length,
companyCode,
userId,
});
const result = await multiTableExcelService.uploadMultiTable(
config as TableChainConfig,
modeId,
rows,
companyCode,
userId
);
const summaryParts = result.results.map(
(r) => `${r.tableName}: 신규 ${r.inserted}건, 수정 ${r.updated}`
);
return res.json({
success: result.success,
data: result,
message: result.success
? summaryParts.join(" / ")
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
console.error("다중 테이블 업로드 오류:", error);
return res.status(500).json({
success: false,
message: "다중 테이블 업로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
// ================================
// 기존 데이터 API
// ================================

View File

@ -1,49 +0,0 @@
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getMoldList,
getMoldDetail,
createMold,
updateMold,
deleteMold,
getMoldSerials,
createMoldSerial,
deleteMoldSerial,
getMoldInspections,
createMoldInspection,
deleteMoldInspection,
getMoldParts,
createMoldPart,
deleteMoldPart,
getMoldSerialSummary,
} from "../controllers/moldController";
const router = express.Router();
router.use(authenticateToken);
// 금형 마스터
router.get("/", getMoldList);
router.get("/:moldCode", getMoldDetail);
router.post("/", createMold);
router.put("/:moldCode", updateMold);
router.delete("/:moldCode", deleteMold);
// 일련번호
router.get("/:moldCode/serials", getMoldSerials);
router.post("/:moldCode/serials", createMoldSerial);
router.delete("/serials/:id", deleteMoldSerial);
// 일련번호 현황 집계
router.get("/:moldCode/serial-summary", getMoldSerialSummary);
// 점검항목
router.get("/:moldCode/inspections", getMoldInspections);
router.post("/:moldCode/inspections", createMoldInspection);
router.delete("/inspections/:id", deleteMoldInspection);
// 부품
router.get("/:moldCode/parts", getMoldParts);
router.post("/:moldCode/parts", createMoldPart);
router.delete("/parts/:id", deleteMoldPart);
export default router;

View File

@ -17,6 +17,7 @@ interface AutoGenMappingInfo {
numberingRuleId: string;
targetColumn: string;
showResultModal?: boolean;
shareAcrossItems?: boolean;
}
interface HiddenMappingInfo {
@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
const sharedCodes: Record<string, string> = {};
for (const ag of allAutoGen) {
if (!ag.shareAcrossItems) continue;
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
try {
const code = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
);
sharedCodes[ag.targetColumn] = code;
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 일괄 채번 완료", {
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
});
} catch (err: any) {
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
@ -225,14 +251,15 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(value);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
columns.push(`"${ag.targetColumn}"`);
values.push(sharedCodes[ag.targetColumn]);
} else if (!ag.shareAcrossItems) {
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
@ -244,6 +271,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
@ -448,6 +476,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
const sharedCodes: Record<string, string> = {};
for (const ag of allAutoGen) {
if (!ag.shareAcrossItems) continue;
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
try {
const code = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
);
sharedCodes[ag.targetColumn] = code;
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 일괄 채번 완료", {
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
});
} catch (err: any) {
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
@ -467,7 +520,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
}
}
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
const allHidden = [
...(fieldMapping?.hiddenMappings ?? []),
...(cardMapping?.hiddenMappings ?? []),
@ -494,34 +546,28 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(value);
}
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
columns.push(`"${ag.targetColumn}"`);
values.push(sharedCodes[ag.targetColumn]);
} else if (!ag.shareAcrossItems) {
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId,
companyCode,
{ ...fieldValues, ...item },
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 채번 완료", {
ruleId: ag.numberingRuleId,
targetColumn: ag.targetColumn,
generatedCode,
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode,
});
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", {
ruleId: ag.numberingRuleId,
error: err.message,
});
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
}

View File

@ -25,7 +25,6 @@ import {
toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
@ -255,12 +254,6 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
*/
router.get("/category-columns", getCategoryColumnsByCompany);
/**
*
* GET /api/table-management/numbering-columns
*/
router.get("/numbering-columns", getNumberingColumnsByCompany);
/**
*
* GET /api/table-management/menu/:menuObjid/category-columns

View File

@ -621,6 +621,74 @@ export class AdminService {
}
}
/**
* POP
* menu_name_kor에 'POP' menu_desc에 [POP] L1 active
* [POP_LANDING] landingMenu로
*/
static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> {
try {
const { userCompanyCode, userType } = paramMap;
logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType });
let queryParams: any[] = [];
let paramIndex = 1;
let companyFilter = "";
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
companyFilter = `AND COMPANY_CODE = '*'`;
} else {
companyFilter = `AND COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
}
// POP L1 메뉴 조회
const parentMenus = await query<any>(
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
FROM MENU_INFO
WHERE PARENT_OBJ_ID = 0
AND MENU_TYPE = 1
AND (
MENU_DESC LIKE '%[POP]%'
OR UPPER(MENU_NAME_KOR) LIKE '%POP%'
)
${companyFilter}
ORDER BY SEQ
LIMIT 1`,
queryParams
);
if (parentMenus.length === 0) {
logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)");
return { parentMenu: null, childMenus: [], landingMenu: null };
}
const parentMenu = parentMenus[0];
// 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링)
const childMenus = await query<any>(
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
FROM MENU_INFO
WHERE PARENT_OBJ_ID = $1
AND STATUS = 'active'
AND COMPANY_CODE = $2
ORDER BY SEQ`,
[parentMenu.objid, parentMenu.company_code]
);
// [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정
const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null;
logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`);
return { parentMenu, childMenus, landingMenu };
} catch (error) {
logger.error("AdminService.getPopMenuList 오류:", error);
throw error;
}
}
/**
*
*/

View File

@ -92,7 +92,7 @@ export class EntityJoinService {
if (column.input_type === "category") {
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
referenceTable = referenceTable || "category_values";
referenceTable = referenceTable || "table_column_category_values";
referenceColumn = referenceColumn || "value_code";
displayColumn = displayColumn || "value_label";
@ -308,7 +308,7 @@ export class EntityJoinService {
const usedAliasesForColumns = new Set<string>();
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
// (category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
// (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if (
!acc.some(
@ -336,7 +336,7 @@ export class EntityJoinService {
counter++;
}
usedAliasesForColumns.add(alias);
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (category_values 대응)
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias);
logger.info(
@ -455,10 +455,9 @@ export class EntityJoinService {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// category_values는 특별한 조인 조건 필요 (회사별 필터링)
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (config.referenceTable === "category_values") {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`;
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
if (config.referenceTable === "table_column_category_values") {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
@ -529,10 +528,10 @@ export class EntityJoinService {
return "join";
}
// category_values는 특수 조인 조건이 필요하므로 캐시 불가
if (config.referenceTable === "category_values") {
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
if (config.referenceTable === "table_column_category_values") {
logger.info(
`🎯 category_values는 캐시 전략 불가: ${config.sourceColumn}`
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
);
return "join";
}
@ -724,10 +723,10 @@ export class EntityJoinService {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// category_values는 특별한 조인 조건 필요 (회사별 필터링만)
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (config.referenceTable === "category_values") {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`;
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === "table_column_category_values") {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;

View File

@ -494,7 +494,7 @@ class MasterDetailExcelService {
/**
* , ID를
* numbering_rules table_name + column_name + company_code로
* , (*) fallback
*/
private async detectNumberingRuleForColumn(
tableName: string,
@ -502,60 +502,34 @@ class MasterDetailExcelService {
companyCode?: string
): Promise<{ numberingRuleId: string } | null> {
try {
// 1. table_type_columns에서 numbering 타입인지 확인
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($3, '*')`
: `AND company_code = '*'`;
const ttcParams = companyCode && companyCode !== "*"
const params = companyCode && companyCode !== "*"
? [tableName, columnName, companyCode]
: [tableName, columnName];
const ttcResult = await query<any>(
`SELECT input_type FROM table_type_columns
const result = await query<any>(
`SELECT input_type, detail_settings, company_code
FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
AND input_type = 'numbering' LIMIT 1`,
ttcParams
);
if (ttcResult.length === 0) return null;
// 2. numbering_rules에서 table_name + column_name으로 규칙 조회
const ruleCompanyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($3, '*')`
: `AND company_code = '*'`;
const ruleParams = companyCode && companyCode !== "*"
? [tableName, columnName, companyCode]
: [tableName, columnName];
const ruleResult = await query<any>(
`SELECT rule_id FROM numbering_rules
WHERE table_name = $1 AND column_name = $2 ${ruleCompanyCondition}
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
LIMIT 1`,
ruleParams
);
if (ruleResult.length > 0) {
return { numberingRuleId: ruleResult[0].rule_id };
}
// 3. fallback: detail_settings.numberingRuleId (하위 호환)
const fallbackResult = await query<any>(
`SELECT detail_settings FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
AND input_type = 'numbering'
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
ttcParams
params
);
for (const row of fallbackResult) {
// 채번 타입인 행 찾기 (회사별 우선)
for (const row of result) {
if (row.input_type === "numbering") {
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
return { numberingRuleId: settings.numberingRuleId };
}
}
}
return null;
} catch (error) {
@ -566,7 +540,7 @@ class MasterDetailExcelService {
/**
*
* numbering_rules table_name + column_name으로
* , (*) fallback
* @returns Map<columnName, numberingRuleId>
*/
private async detectAllNumberingColumns(
@ -575,7 +549,6 @@ class MasterDetailExcelService {
): Promise<Map<string, string>> {
const numberingCols = new Map<string, string>();
try {
// 1. table_type_columns에서 numbering 타입 컬럼 목록 조회
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($2, '*')`
: `AND company_code = '*'`;
@ -583,26 +556,22 @@ class MasterDetailExcelService {
? [tableName, companyCode]
: [tableName];
const ttcResult = await query<any>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`,
const result = await query<any>(
`SELECT column_name, detail_settings, company_code
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
params
);
// 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회
for (const row of ttcResult) {
const ruleResult = await query<any>(
`SELECT rule_id FROM numbering_rules
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
LIMIT 1`,
companyCode && companyCode !== "*"
? [tableName, row.column_name, companyCode]
: [tableName, row.column_name]
);
if (ruleResult.length > 0) {
numberingCols.set(row.column_name, ruleResult[0].rule_id);
// 컬럼별로 회사 설정 우선 적용
for (const row of result) {
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
numberingCols.set(row.column_name, settings.numberingRuleId);
}
}

View File

@ -3098,7 +3098,7 @@ export class MenuCopyService {
}
const allValuesResult = await client.query(
`SELECT * FROM category_values
`SELECT * FROM table_column_category_values
WHERE company_code = $1
AND (${columnConditions.join(" OR ")})
ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`,
@ -3115,7 +3115,7 @@ export class MenuCopyService {
// 5. 대상 회사에 이미 존재하는 값 한 번에 조회
const existingValuesResult = await client.query(
`SELECT value_id, table_name, column_name, value_code
FROM category_values WHERE company_code = $1`,
FROM table_column_category_values WHERE company_code = $1`,
[targetCompanyCode]
);
const existingValueKeys = new Map(
@ -3194,7 +3194,7 @@ export class MenuCopyService {
});
const insertResult = await client.query(
`INSERT INTO category_values (
`INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon,
is_active, is_default, created_at, created_by, company_code, menu_objid

File diff suppressed because it is too large Load Diff

View File

@ -172,16 +172,6 @@ class NumberingRuleService {
break;
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
prefixParts.push(String(formData[refColumn]));
} else {
prefixParts.push("");
}
break;
}
default:
break;
}
@ -1255,14 +1245,6 @@ class NumberingRuleService {
return "";
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "REF";
}
default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
@ -1393,13 +1375,6 @@ class NumberingRuleService {
return catMapping2?.format || "CATEGORY";
}
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default:
return "";
}
@ -1549,15 +1524,6 @@ class NumberingRuleService {
return "";
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
return "";
}
default:
return "";
}
@ -1781,53 +1747,7 @@ class NumberingRuleService {
`;
const params = [companyCode, tableName, columnName];
let result = await pool.query(query, params);
// fallback: column_name이 비어있는 레거시 규칙 검색
if (result.rows.length === 0) {
const fallbackQuery = `
SELECT
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
cv.value_label AS "categoryValueLabel",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules r
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND (r.column_name IS NULL OR r.column_name = '')
AND r.category_value_id IS NULL
ORDER BY r.updated_at DESC
LIMIT 1
`;
result = await pool.query(fallbackQuery, [companyCode, tableName]);
// 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션)
if (result.rows.length > 0) {
const foundRule = result.rows[0];
await pool.query(
`UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`,
[columnName, foundRule.ruleId, companyCode]
);
result.rows[0].columnName = columnName;
logger.info("레거시 채번 규칙 자동 매핑 완료", {
ruleId: foundRule.ruleId,
tableName,
columnName,
});
}
}
const result = await pool.query(query, params);
if (result.rows.length === 0) {
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
@ -1840,6 +1760,7 @@ class NumberingRuleService {
const rule = result.rows[0];
// 파트 정보 조회 (테스트 테이블)
const partsQuery = `
SELECT
id,
@ -1858,7 +1779,7 @@ class NumberingRuleService {
]);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", {
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
});

View File

@ -31,7 +31,7 @@ class TableCategoryValueService {
tc.column_name AS "columnLabel",
COUNT(cv.value_id) AS "valueCount"
FROM table_type_columns tc
LEFT JOIN category_values cv
LEFT JOIN table_column_category_values cv
ON tc.table_name = cv.table_name
AND tc.column_name = cv.column_name
AND cv.is_active = true
@ -50,7 +50,7 @@ class TableCategoryValueService {
tc.column_name AS "columnLabel",
COUNT(cv.value_id) AS "valueCount"
FROM table_type_columns tc
LEFT JOIN category_values cv
LEFT JOIN table_column_category_values cv
ON tc.table_name = cv.table_name
AND tc.column_name = cv.column_name
AND cv.is_active = true
@ -110,7 +110,7 @@ class TableCategoryValueService {
) tc
LEFT JOIN (
SELECT table_name, column_name, COUNT(*) as cnt
FROM category_values
FROM table_column_category_values
WHERE is_active = true
GROUP BY table_name, column_name
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
@ -133,7 +133,7 @@ class TableCategoryValueService {
) tc
LEFT JOIN (
SELECT table_name, column_name, COUNT(*) as cnt
FROM category_values
FROM table_column_category_values
WHERE is_active = true AND company_code = $1
GROUP BY table_name, column_name
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
@ -207,7 +207,7 @@ class TableCategoryValueService {
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
NULL::numeric AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
@ -289,7 +289,7 @@ class TableCategoryValueService {
// 최고 관리자: 모든 회사에서 중복 체크
duplicateQuery = `
SELECT value_id
FROM category_values
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND value_code = $3
@ -300,7 +300,7 @@ class TableCategoryValueService {
// 일반 회사: 자신의 회사에서만 중복 체크
duplicateQuery = `
SELECT value_id
FROM category_values
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND value_code = $3
@ -316,41 +316,8 @@ class TableCategoryValueService {
throw new Error("이미 존재하는 코드입니다");
}
// 라벨 중복 체크 (같은 테이블+컬럼+회사에서 동일한 라벨명 방지)
let labelDupQuery: string;
let labelDupParams: any[];
if (companyCode === "*") {
labelDupQuery = `
SELECT value_id
FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_label = $3
AND is_active = true
`;
labelDupParams = [value.tableName, value.columnName, value.valueLabel];
} else {
labelDupQuery = `
SELECT value_id
FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_label = $3
AND company_code = $4
AND is_active = true
`;
labelDupParams = [value.tableName, value.columnName, value.valueLabel, companyCode];
}
const labelDupResult = await pool.query(labelDupQuery, labelDupParams);
if (labelDupResult.rows.length > 0) {
throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${value.valueLabel}"`);
}
const insertQuery = `
INSERT INTO category_values (
INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon,
is_active, is_default, company_code, menu_objid, created_by
@ -458,32 +425,6 @@ class TableCategoryValueService {
values.push(updates.isDefault);
}
// 라벨 수정 시 중복 체크 (자기 자신 제외)
if (updates.valueLabel !== undefined) {
const currentRow = await pool.query(
`SELECT table_name, column_name, company_code FROM category_values WHERE value_id = $1`,
[valueId]
);
if (currentRow.rows.length > 0) {
const { table_name, column_name, company_code } = currentRow.rows[0];
const labelDupResult = await pool.query(
`SELECT value_id FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_label = $3
AND company_code = $4
AND is_active = true
AND value_id != $5`,
[table_name, column_name, updates.valueLabel, company_code, valueId]
);
if (labelDupResult.rows.length > 0) {
throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${updates.valueLabel}"`);
}
}
}
setClauses.push(`updated_at = NOW()`);
setClauses.push(`updated_by = $${paramIndex++}`);
values.push(userId);
@ -495,7 +436,7 @@ class TableCategoryValueService {
// 최고 관리자: 모든 카테고리 값 수정 가능
values.push(valueId);
updateQuery = `
UPDATE category_values
UPDATE table_column_category_values
SET ${setClauses.join(", ")}
WHERE value_id = $${paramIndex++}
RETURNING
@ -518,7 +459,7 @@ class TableCategoryValueService {
// 일반 회사: 자신의 카테고리 값만 수정 가능
values.push(valueId, companyCode);
updateQuery = `
UPDATE category_values
UPDATE table_column_category_values
SET ${setClauses.join(", ")}
WHERE value_id = $${paramIndex++}
AND company_code = $${paramIndex++}
@ -575,14 +516,14 @@ class TableCategoryValueService {
if (companyCode === "*") {
valueQuery = `
SELECT table_name, column_name, value_code
FROM category_values
FROM table_column_category_values
WHERE value_id = $1
`;
valueParams = [valueId];
} else {
valueQuery = `
SELECT table_name, column_name, value_code
FROM category_values
FROM table_column_category_values
WHERE value_id = $1
AND company_code = $2
`;
@ -694,10 +635,10 @@ class TableCategoryValueService {
if (companyCode === "*") {
query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM category_values WHERE parent_value_id = $1
SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1
UNION ALL
SELECT cv.value_id
FROM category_values cv
FROM table_column_category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
)
SELECT value_id FROM category_tree
@ -706,11 +647,11 @@ class TableCategoryValueService {
} else {
query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM category_values
SELECT value_id FROM table_column_category_values
WHERE parent_value_id = $1 AND company_code = $2
UNION ALL
SELECT cv.value_id
FROM category_values cv
FROM table_column_category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
WHERE cv.company_code = $2
)
@ -756,10 +697,10 @@ class TableCategoryValueService {
let labelParams: any[];
if (companyCode === "*") {
labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1`;
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`;
labelParams = [id];
} else {
labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1 AND company_code = $2`;
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
labelParams = [id, companyCode];
}
@ -789,10 +730,10 @@ class TableCategoryValueService {
let deleteParams: any[];
if (companyCode === "*") {
deleteQuery = `DELETE FROM category_values WHERE value_id = $1`;
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`;
deleteParams = [id];
} else {
deleteQuery = `DELETE FROM category_values WHERE value_id = $1 AND company_code = $2`;
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
deleteParams = [id, companyCode];
}
@ -829,7 +770,7 @@ class TableCategoryValueService {
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 일괄 삭제 가능
deleteQuery = `
UPDATE category_values
UPDATE table_column_category_values
SET is_active = false, updated_at = NOW(), updated_by = $2
WHERE value_id = ANY($1::int[])
`;
@ -837,7 +778,7 @@ class TableCategoryValueService {
} else {
// 일반 회사: 자신의 카테고리 값만 일괄 삭제 가능
deleteQuery = `
UPDATE category_values
UPDATE table_column_category_values
SET is_active = false, updated_at = NOW(), updated_by = $3
WHERE value_id = ANY($1::int[])
AND company_code = $2
@ -878,7 +819,7 @@ class TableCategoryValueService {
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 순서 변경 가능
updateQuery = `
UPDATE category_values
UPDATE table_column_category_values
SET value_order = $1, updated_at = NOW()
WHERE value_id = $2
`;
@ -886,7 +827,7 @@ class TableCategoryValueService {
} else {
// 일반 회사: 자신의 카테고리 값만 순서 변경 가능
updateQuery = `
UPDATE category_values
UPDATE table_column_category_values
SET value_order = $1, updated_at = NOW()
WHERE value_id = $2
AND company_code = $3
@ -1438,23 +1379,48 @@ class TableCategoryValueService {
let query: string;
let params: any[];
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (companyCode === "*") {
// 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합)
// 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n
const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", ");
query = `
SELECT DISTINCT value_code, value_label
FROM category_values
SELECT value_code, value_label FROM (
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders1})
AND is_active = true
UNION ALL
SELECT value_code, value_label
FROM category_values
WHERE value_code IN (${placeholders2})
AND is_active = true
) combined
`;
params = [...valueCodes];
params = [...valueCodes, ...valueCodes];
} else {
const companyIdx = n + 1;
// 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회
// 첫 번째: $1~$n (valueCodes), $n+1 (companyCode)
// 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode)
const companyIdx1 = n + 1;
const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", ");
const companyIdx2 = 2 * n + 2;
query = `
SELECT DISTINCT value_code, value_label
FROM category_values
SELECT value_code, value_label FROM (
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders1})
AND (company_code = $${companyIdx} OR company_code = '*')
AND is_active = true
AND (company_code = $${companyIdx1} OR company_code = '*')
UNION ALL
SELECT value_code, value_label
FROM category_values
WHERE value_code IN (${placeholders2})
AND is_active = true
AND (company_code = $${companyIdx2} OR company_code = '*')
) combined
`;
params = [...valueCodes, companyCode];
params = [...valueCodes, companyCode, ...valueCodes, companyCode];
}
const result = await pool.query(query, params);
@ -1522,7 +1488,7 @@ class TableCategoryValueService {
// 최고 관리자: 모든 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM category_values
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true
@ -1532,7 +1498,7 @@ class TableCategoryValueService {
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM category_values
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true

View File

@ -2691,32 +2691,6 @@ export class TableManagementService {
logger.info(`created_date 자동 추가: ${data.created_date}`);
}
// 채번 자동 적용: input_type = 'numbering'인 컬럼에 값이 비어있으면 자동 채번
try {
const companyCode = data.company_code || "*";
const numberingColsResult = await query<any>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering'
AND company_code IN ($2, '*')`,
[tableName, companyCode]
);
for (const row of numberingColsResult) {
const col = row.column_name;
if (!data[col] || data[col] === "" || data[col] === "자동 생성됩니다") {
const { numberingRuleService } = await import("./numberingRuleService");
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, col);
if (rule) {
const generatedCode = await numberingRuleService.allocateCode(rule.ruleId, companyCode, data);
data[col] = generatedCode;
logger.info(`채번 자동 적용: ${tableName}.${col} = ${generatedCode}`);
}
}
}
} catch (numErr: any) {
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
}
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const skippedColumns: string[] = [];
const existingColumns = Object.keys(data).filter((col) => {
@ -3463,12 +3437,10 @@ export class TableManagementService {
}
// ORDER BY 절 구성
// sortBy가 메인 테이블 컬럼이면 main. 접두사, 조인 별칭이면 접두사 없이 사
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적
const hasCreatedDateColumn = selectColumns.includes("created_date");
const orderBy = options.sortBy
? selectColumns.includes(options.sortBy)
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: hasCreatedDateColumn
? `main."created_date" DESC`
: "";
@ -3533,7 +3505,7 @@ export class TableManagementService {
const referenceTableColumns = new Map<string, string[]>();
const uniqueRefTables = new Set(
joinConfigs
.filter((c) => c.referenceTable !== "category_values") // 카테고리는 제외
.filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외
.map((c) => `${c.referenceTable}:${c.sourceColumn}`)
);
@ -3712,9 +3684,7 @@ export class TableManagementService {
selectColumns,
"", // WHERE 절은 나중에 추가
options.sortBy
? selectColumns.includes(options.sortBy)
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
: `"${options.sortBy}" ${options.sortOrder || "ASC"}`
: hasCreatedDateForSearch
? `main."created_date" DESC`
: undefined,
@ -3813,15 +3783,15 @@ export class TableManagementService {
);
}
} else if (operator === "equals") {
// 🔧 equals 연산자: 메인 테이블의 FK 컬럼에서 직접 매칭 (연결 필터용)
// 🔧 equals 연산자: 정확히 일치
whereConditions.push(
`main.${joinConfig.sourceColumn}::text = '${safeValue}'`
`${alias}.${joinConfig.displayColumn}::text = '${safeValue}'`
);
entitySearchColumns.push(
`${key} (main.${joinConfig.sourceColumn})`
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 직접 FK 매칭: ${key} → main.${joinConfig.sourceColumn} = '${safeValue}'`
`🎯 Entity 조인 정확히 일치 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})`
);
} else {
// 기본: 부분 일치 (ILIKE)
@ -3905,9 +3875,7 @@ export class TableManagementService {
const whereClause = whereConditions.join(" AND ");
const hasCreatedDateForOrder = selectColumns.includes("created_date");
const orderBy = options.sortBy
? selectColumns.includes(options.sortBy)
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: hasCreatedDateForOrder
? `main."created_date" DESC`
: "";
@ -4342,8 +4310,8 @@ export class TableManagementService {
];
for (const config of joinConfigs) {
// category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === "category_values") {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === "table_column_category_values") {
dbJoins.push(config);
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
continue;
@ -4500,26 +4468,30 @@ export class TableManagementService {
const rawColumns = await query<any>(
`SELECT
column_name as "columnName",
column_name as "displayName",
data_type as "dataType",
udt_name as "dbType",
is_nullable as "isNullable",
column_default as "defaultValue",
character_maximum_length as "maxLength",
numeric_precision as "numericPrecision",
numeric_scale as "numericScale",
c.column_name as "columnName",
c.column_name as "displayName",
c.data_type as "dataType",
c.udt_name as "dbType",
c.is_nullable as "isNullable",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
c.numeric_precision as "numericPrecision",
c.numeric_scale as "numericScale",
CASE
WHEN column_name IN (
SELECT column_name FROM information_schema.key_column_usage
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
WHEN c.column_name IN (
SELECT kcu.column_name FROM information_schema.key_column_usage kcu
WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey'
) THEN true
ELSE false
END as "isPrimaryKey"
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
END as "isPrimaryKey",
col_description(
(SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')),
c.ordinal_position
) as "columnComment"
FROM information_schema.columns c
WHERE c.table_name = $1
AND c.table_schema = 'public'
ORDER BY c.ordinal_position`,
[tableName]
);
@ -4529,10 +4501,10 @@ export class TableManagementService {
displayName: col.displayName,
dataType: col.dataType,
dbType: col.dbType,
webType: "text", // 기본값
webType: "text",
inputType: "direct",
detailSettings: "{}",
description: "", // 필수 필드 추가
description: col.columnComment || "",
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
@ -4543,6 +4515,7 @@ export class TableManagementService {
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
displayOrder: 0,
isVisible: true,
columnComment: col.columnComment || "",
}));
logger.info(

View File

@ -1,65 +0,0 @@
/**
* AI
* - backend-node , ( )
*/
import path from "path";
import { spawn, ChildProcess } from "child_process";
import { logger } from "./logger";
const AI_PORT = process.env.AI_ASSISTANT_SERVICE_PORT || "3100";
let aiAssistantProcess: ChildProcess | null = null;
/** ERP-node/ai-assistant 경로 (backend-node 기준 상대) */
function getAiAssistantDir(): string {
return path.resolve(process.cwd(), "..", "ai-assistant");
}
/**
* AI ( , backend는 )
*/
export function startAiAssistant(): void {
const aiDir = getAiAssistantDir();
const appPath = path.join(aiDir, "src", "app.js");
try {
const fs = require("fs");
if (!fs.existsSync(appPath)) {
logger.info(`⏭️ AI 어시스턴트 스킵 (경로 없음: ${appPath})`);
return;
}
} catch {
return;
}
aiAssistantProcess = spawn("node", ["src/app.js"], {
cwd: aiDir,
stdio: "inherit",
env: { ...process.env, PORT: AI_PORT },
shell: true, // Windows에서 node 경로 인식
});
aiAssistantProcess.on("error", (err) => {
logger.warn(`⚠️ AI 어시스턴트 프로세스 에러: ${err.message}`);
});
aiAssistantProcess.on("exit", (code, signal) => {
aiAssistantProcess = null;
if (code != null && code !== 0) {
logger.warn(`⚠️ AI 어시스턴트 종료 (code=${code}, signal=${signal})`);
}
});
logger.info(`🤖 AI 어시스턴트 서비스 기동 (포트 ${AI_PORT}, cwd: ${aiDir})`);
}
/**
* AI (SIGTERM/SIGINT )
*/
export function stopAiAssistant(): void {
if (aiAssistantProcess && aiAssistantProcess.kill) {
aiAssistantProcess.kill("SIGTERM");
aiAssistantProcess = null;
logger.info("🤖 AI 어시스턴트 프로세스 종료");
}
}

View File

@ -1,194 +0,0 @@
# 다중 테이블 엑셀 업로드 범용 시스템
## 개요
하나의 플랫 엑셀 파일로 계층적 다중 테이블(2~N개)에 데이터를 일괄 등록하는 범용 시스템.
거래처 관리(customer_mng → customer_item_mapping → customer_item_prices)를 첫 번째 적용 대상으로 하되,
공급업체, BOM 등 다른 화면에서도 재사용 가능하도록 설계한다.
## 핵심 기능
1. 모드 선택: 어느 레벨까지 등록할지 사용자가 선택
2. 템플릿 다운로드: 모드에 맞는 엑셀 양식 자동 생성
3. 파일 업로드: 플랫 엑셀 → 계층 그룹핑 → 트랜잭션 UPSERT
4. 컬럼 매핑: 엑셀 헤더 ↔ DB 컬럼 자동/수동 매핑
## DB 테이블 관계 (거래처 관리)
```
customer_mng (Level 1 - 루트)
PK: id (SERIAL)
UNIQUE: customer_code
└─ customer_item_mapping (Level 2)
PK: id (UUID)
FK: customer_id → customer_mng.id
UPSERT키: customer_id + customer_item_code
└─ customer_item_prices (Level 3)
PK: id (UUID)
FK: mapping_id → customer_item_mapping.id
항상 INSERT (기간별 단가 이력)
```
## 범용 설정 구조 (TableChainConfig)
```typescript
interface TableLevel {
tableName: string;
label: string;
// 부모와의 관계
parentFkColumn?: string; // 이 테이블에서 부모를 참조하는 FK 컬럼
parentRefColumn?: string; // 부모 테이블에서 참조되는 컬럼 (PK 또는 UNIQUE)
// UPSERT 설정
upsertMode: 'upsert' | 'insert'; // upsert: 기존 데이터 있으면 UPDATE, insert: 항상 신규
upsertKeyColumns?: string[]; // UPSERT 매칭 키 (예: ['customer_code'])
// 엑셀 매핑 컬럼
columns: Array<{
dbColumn: string;
excelHeader: string;
required: boolean;
defaultValue?: any;
}>;
}
interface TableChainConfig {
id: string;
name: string;
description: string;
levels: TableLevel[]; // 0 = 루트, 1 = 자식, 2 = 손자...
uploadModes: Array<{
id: string;
label: string;
description: string;
activeLevels: number[]; // 이 모드에서 활성화되는 레벨 인덱스
}>;
}
```
## 거래처 관리 설정 예시
```typescript
const customerChainConfig: TableChainConfig = {
id: 'customer_management',
name: '거래처 관리',
description: '거래처, 품목매핑, 단가 일괄 등록',
levels: [
{
tableName: 'customer_mng',
label: '거래처',
upsertMode: 'upsert',
upsertKeyColumns: ['customer_code'],
columns: [
{ dbColumn: 'customer_code', excelHeader: '거래처코드', required: true },
{ dbColumn: 'customer_name', excelHeader: '거래처명', required: true },
{ dbColumn: 'division', excelHeader: '구분', required: false },
{ dbColumn: 'contact_person', excelHeader: '담당자', required: false },
{ dbColumn: 'contact_phone', excelHeader: '연락처', required: false },
{ dbColumn: 'email', excelHeader: '이메일', required: false },
{ dbColumn: 'business_number', excelHeader: '사업자번호', required: false },
{ dbColumn: 'address', excelHeader: '주소', required: false },
],
},
{
tableName: 'customer_item_mapping',
label: '품목매핑',
parentFkColumn: 'customer_id',
parentRefColumn: 'id',
upsertMode: 'upsert',
upsertKeyColumns: ['customer_id', 'customer_item_code'],
columns: [
{ dbColumn: 'customer_item_code', excelHeader: '거래처품번', required: true },
{ dbColumn: 'customer_item_name', excelHeader: '거래처품명', required: true },
{ dbColumn: 'item_id', excelHeader: '품목ID', required: false },
],
},
{
tableName: 'customer_item_prices',
label: '단가',
parentFkColumn: 'mapping_id',
parentRefColumn: 'id',
upsertMode: 'insert',
columns: [
{ dbColumn: 'base_price', excelHeader: '기준단가', required: true },
{ dbColumn: 'discount_type', excelHeader: '할인유형', required: false },
{ dbColumn: 'discount_value', excelHeader: '할인값', required: false },
{ dbColumn: 'start_date', excelHeader: '적용시작일', required: false },
{ dbColumn: 'end_date', excelHeader: '적용종료일', required: false },
{ dbColumn: 'currency_code', excelHeader: '통화', required: false },
],
},
],
uploadModes: [
{ id: 'customer_only', label: '거래처만 등록', description: '거래처 기본정보만', activeLevels: [0] },
{ id: 'customer_item', label: '거래처 + 품목정보', description: '거래처와 품목매핑', activeLevels: [0, 1] },
{ id: 'customer_item_price', label: '거래처 + 품목 + 단가', description: '전체 등록', activeLevels: [0, 1, 2] },
],
};
```
## 처리 로직 (백엔드)
### 1단계: 그룹핑
엑셀의 플랫 행을 계층별 그룹으로 변환:
- Level 0 (거래처): customer_code 기준 그룹핑
- Level 1 (품목매핑): customer_code + customer_item_code 기준 그룹핑
- Level 2 (단가): 매 행마다 INSERT
### 2단계: 계단식 UPSERT (트랜잭션)
```
BEGIN TRANSACTION
FOR EACH unique customer_code:
1. customer_mng UPSERT → 결과에서 id 획득 (returnedId)
FOR EACH unique customer_item_code (해당 거래처):
2. customer_item_mapping의 customer_id = returnedId 주입
UPSERT → 결과에서 id 획득 (mappingId)
FOR EACH price row (해당 품목매핑):
3. customer_item_prices의 mapping_id = mappingId 주입
INSERT
COMMIT (전체 성공) or ROLLBACK (하나라도 실패)
```
### 3단계: 결과 반환
```json
{
"success": true,
"results": {
"customer_mng": { "inserted": 2, "updated": 1 },
"customer_item_mapping": { "inserted": 5, "updated": 2 },
"customer_item_prices": { "inserted": 12 }
},
"errors": []
}
```
## 테스트 계획
### 1단계: 백엔드 서비스
- [x] plan.md 작성
- [ ] multiTableExcelService.ts 기본 구조 작성
- [ ] 그룹핑 로직 구현
- [ ] 계단식 UPSERT 로직 구현
- [ ] 트랜잭션 처리
- [ ] 에러 핸들링
### 2단계: API 엔드포인트
- [ ] POST /api/data/multi-table/upload 추가
- [ ] POST /api/data/multi-table/template 추가 (템플릿 다운로드)
- [ ] 입력값 검증
### 3단계: 프론트엔드
- [ ] MultiTableExcelUploadModal.tsx 컴포넌트 작성
- [ ] 모드 선택 UI
- [ ] 템플릿 다운로드 버튼
- [ ] 파일 업로드 + 미리보기
- [ ] 컬럼 매핑 UI
- [ ] 업로드 결과 표시
### 4단계: 통합
- [ ] 거래처 관리 화면에 연결
- [ ] 실제 데이터로 테스트
## 진행 상태
- 완료된 테스트는 [x]로 표시
- 현재 진행 중인 테스트는 [진행중]으로 표시

View File

@ -10,8 +10,17 @@ import { LoginFooter } from "@/components/auth/LoginFooter";
* useLogin , UI
*/
export default function LoginPage() {
const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } =
useLogin();
const {
formData,
isLoading,
error,
showPassword,
isPopMode,
handleInputChange,
handleLogin,
togglePasswordVisibility,
togglePopMode,
} = useLogin();
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
@ -23,9 +32,11 @@ export default function LoginPage() {
isLoading={isLoading}
error={error}
showPassword={showPassword}
isPopMode={isPopMode}
onInputChange={handleInputChange}
onSubmit={handleLogin}
onTogglePassword={togglePasswordVisibility}
onTogglePop={togglePopMode}
/>
<LoginFooter />

View File

@ -1,299 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { aiAssistantApi } from "@/lib/api/aiAssistant";
import type { ApiKeyItem } from "@/lib/api/aiAssistant";
import {
Key,
Plus,
Copy,
Trash2,
Loader2,
Check,
Eye,
EyeOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "sonner";
export default function AiAssistantApiKeysPage() {
const [loading, setLoading] = useState(true);
const [apiKeys, setApiKeys] = useState<ApiKeyItem[]>([]);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newKeyDialogOpen, setNewKeyDialogOpen] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKey, setNewKey] = useState("");
const [creating, setCreating] = useState(false);
const [showKey, setShowKey] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
loadApiKeys();
}, []);
const loadApiKeys = async () => {
setLoading(true);
try {
const res = await aiAssistantApi.get("/api-keys");
setApiKeys(res.data?.data ?? []);
} catch {
toast.error("API 키 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
const createApiKey = async () => {
if (!newKeyName.trim()) {
toast.error("키 이름을 입력해주세요.");
return;
}
setCreating(true);
try {
const res = await aiAssistantApi.post("/api-keys", { name: newKeyName });
setNewKey((res.data?.data as { key?: string })?.key ?? "");
setCreateDialogOpen(false);
setNewKeyDialogOpen(true);
setNewKeyName("");
loadApiKeys();
toast.success("API 키가 생성되었습니다.");
} catch (err: unknown) {
const msg =
err && typeof err === "object" && "response" in err
? (err as { response?: { data?: { error?: { message?: string } } } }).response?.data
?.error?.message
: null;
toast.error(msg ?? "API 키 생성에 실패했습니다.");
} finally {
setCreating(false);
}
};
const revokeApiKey = async (id: number) => {
if (!confirm("이 API 키를 폐기하시겠습니까?")) return;
try {
await aiAssistantApi.delete(`/api-keys/${id}`);
loadApiKeys();
toast.success("API 키가 폐기되었습니다.");
} catch {
toast.error("API 키 폐기에 실패했습니다.");
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success("클립보드에 복사되었습니다.");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("복사에 실패했습니다.");
}
};
const baseUrl =
typeof window !== "undefined"
? process.env.NEXT_PUBLIC_AI_ASSISTANT_API_URL || "http://localhost:3100/api/v1"
: "";
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">API </h1>
<p className="text-muted-foreground mt-1">
AI Assistant API를 .
</p>
</div>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
API
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle> API </DialogTitle>
<DialogDescription>
API . .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="keyName"> </Label>
<Input
id="keyName"
placeholder="예: Production Server"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
</Button>
<Button onClick={createApiKey} disabled={creating}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Dialog open={newKeyDialogOpen} onOpenChange={setNewKeyDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>API </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-2">
<Input
type={showKey ? "text" : "password"}
value={newKey}
readOnly
className="font-mono"
/>
<Button variant="outline" size="icon" onClick={() => setShowKey(!showKey)}>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button variant="outline" size="icon" onClick={() => copyToClipboard(newKey)}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<DialogFooter>
<Button onClick={() => setNewKeyDialogOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Card>
<CardHeader>
<CardTitle>API </CardTitle>
<CardDescription> API .</CardDescription>
</CardHeader>
<CardContent>
{apiKeys.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Key className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="text-lg font-medium">API </h3>
<p className="text-muted-foreground mt-1 text-sm"> API .</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-medium">{key.name}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="bg-muted rounded px-2 py-1 text-sm">
{key.keyPrefix}...
</code>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => copyToClipboard(key.keyPrefix + "...")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</TableCell>
<TableCell>
<Badge variant={key.status === "active" ? "success" : "secondary"}>
{key.status === "active" ? "활성" : "폐기됨"}
</Badge>
</TableCell>
<TableCell>{(key.usageCount ?? 0).toLocaleString()} </TableCell>
<TableCell>
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleDateString("ko-KR")
: "-"}
</TableCell>
<TableCell>{new Date(key.createdAt).toLocaleDateString("ko-KR")}</TableCell>
<TableCell className="text-right">
{key.status === "active" && (
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-8 w-8"
onClick={() => revokeApiKey(key.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API </CardTitle>
<CardDescription>
API Authorization .
</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-x-auto rounded-lg p-4 text-sm">
{`curl -X POST ${baseUrl}/chat/completions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-d '{"model": "gemini-2.0-flash", "messages": [{"role": "user", "content": "Hello!"}]}'`}
</pre>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,180 +0,0 @@
"use client";
import { useState } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
const DEFAULT_BASE = "http://localhost:3100/api/v1";
const PRESETS = [
{ name: "채팅 완성", method: "POST", endpoint: "/chat/completions", body: '{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"안녕하세요!"}],"temperature":0.7}' },
{ name: "모델 목록", method: "GET", endpoint: "/models", body: "" },
{ name: "사용량", method: "GET", endpoint: "/usage", body: "" },
{ name: "API 키 목록", method: "GET", endpoint: "/api-keys", body: "" },
];
export default function AiAssistantApiTestPage() {
const [baseUrl, setBaseUrl] = useState(
typeof window !== "undefined" ? (process.env.NEXT_PUBLIC_AI_ASSISTANT_API_URL || DEFAULT_BASE) : DEFAULT_BASE
);
const [apiKey, setApiKey] = useState("");
const [method, setMethod] = useState("POST");
const [endpoint, setEndpoint] = useState("/chat/completions");
const [body, setBody] = useState(PRESETS[0].body);
const [loading, setLoading] = useState(false);
const [response, setResponse] = useState<{ status: number; statusText: string; data: unknown } | null>(null);
const [responseTime, setResponseTime] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const apply = (p: (typeof PRESETS)[0]) => {
setMethod(p.method);
setEndpoint(p.endpoint);
setBody(p.body);
};
const send = async () => {
setLoading(true);
setResponse(null);
setResponseTime(null);
const start = Date.now();
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const opt: RequestInit = { method, headers };
if (method !== "GET" && body.trim()) {
try {
JSON.parse(body);
opt.body = body;
} catch {
toast.error("JSON 형식 오류");
setLoading(false);
return;
}
}
const res = await fetch(`${baseUrl}${endpoint}`, opt);
const elapsed = Date.now() - start;
setResponseTime(elapsed);
const ct = res.headers.get("content-type");
const data = ct?.includes("json") ? await res.json() : await res.text();
setResponse({ status: res.status, statusText: res.statusText, data });
toast.success(res.ok ? `성공 ${res.status}` : `실패 ${res.status}`);
} catch (e) {
setResponseTime(Date.now() - start);
setResponse({ status: 0, statusText: "Network Error", data: { error: String(e) } });
toast.error("네트워크 오류");
} finally {
setLoading(false);
}
};
const copyRes = () => {
navigator.clipboard.writeText(JSON.stringify(response?.data, null, 2));
setCopied(true);
toast.success("복사됨");
setTimeout(() => setCopied(false), 2000);
};
const statusV = (s: number) => (s >= 200 && s < 300 ? "success" : s >= 400 ? "destructive" : "secondary");
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">API </h1>
<p className="text-muted-foreground mt-1">API를 .</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">API </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Base URL</Label>
<Input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
</div>
<div className="space-y-2">
<Label>API JWT</Label>
<Input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-xxx" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p, i) => (
<Button key={i} variant="outline" size="sm" onClick={() => apply(p)}>
<Badge variant="secondary" className="mr-2 text-xs">{p.method}</Badge>
{p.name}
</Button>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Select value={method} onValueChange={setMethod}>
<SelectTrigger className="w-[100px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
<Input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} className="flex-1" />
</div>
{method !== "GET" && (
<div className="space-y-2">
<Label>Body (JSON)</Label>
<Textarea value={body} onChange={(e) => setBody(e.target.value)} className="font-mono text-sm min-h-[180px]" />
</div>
)}
<Button className="w-full" onClick={send} disabled={loading}>
{loading ? "요청 중..." : "요청 보내기"}
</Button>
</CardContent>
</Card>
</div>
<Card className="h-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
{response && (
<div className="flex items-center gap-2">
<Badge variant={statusV(response.status)}>{response.status} {response.statusText}</Badge>
{responseTime != null && <Badge variant="outline">{responseTime}ms</Badge>}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={copyRes}>
{copied ? "✓" : "복사"}
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
{!response ? (
<p className="text-muted-foreground py-12 text-center"> .</p>
) : (
<pre className="bg-muted max-h-[500px] overflow-auto rounded-lg p-4 text-sm font-mono whitespace-pre-wrap">
{typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2)}
</pre>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,142 +0,0 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { aiAssistantApi } from "@/lib/api/aiAssistant";
import { Send, Loader2, Bot, User, Trash2, Settings2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
type ChatMessage = { role: "user" | "assistant"; content: string };
type ModelItem = { id: string };
export default function AiAssistantChatPage() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [models, setModels] = useState<ModelItem[]>([]);
const [selectedModel, setSelectedModel] = useState("gemini-2.0-flash");
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
aiAssistantApi.get("/models").then((res) => {
const list = (res.data?.data as ModelItem[]) ?? [];
setModels(list);
if (list.length && !list.some((m) => m.id === selectedModel)) setSelectedModel(list[0].id);
}).catch(() => {});
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || loading) return;
const userMsg: ChatMessage = { role: "user", content: input.trim() };
setMessages((prev) => [...prev, userMsg]);
setInput("");
setLoading(true);
try {
const res = await aiAssistantApi.post("/chat/completions", {
model: selectedModel,
messages: [...messages, userMsg].map((m) => ({ role: m.role, content: m.content })),
});
const content = (res.data as { choices?: Array<{ message?: { content?: string } }> })?.choices?.[0]?.message?.content ?? "";
setMessages((prev) => [...prev, { role: "assistant", content }]);
} catch (err: unknown) {
const msg = err && typeof err === "object" && "response" in err
? (err as { response?: { data?: { error?: { message?: string } } } }).response?.data?.error?.message
: null;
toast.error(msg ?? "AI 응답 실패");
setMessages((prev) => prev.slice(0, -1));
} finally {
setLoading(false);
}
};
return (
<div className="flex h-[calc(100vh-8rem)] flex-col">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">AI </h1>
<p className="text-muted-foreground mt-1">AI Assistant와 .</p>
</div>
<div className="flex items-center gap-2">
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-[200px]">
<Settings2 className="mr-2 h-4 w-4" />
<SelectValue placeholder="모델 선택" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.id} value={m.id}>{m.id}</SelectItem>
))}
{models.length === 0 && <SelectItem value="gemini-2.0-flash">gemini-2.0-flash</SelectItem>}
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={() => setMessages([])}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<Card className="flex flex-1 flex-col overflow-hidden">
<ScrollArea className="flex-1 p-4">
{messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="bg-primary/10 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<Bot className="text-primary h-8 w-8" />
</div>
<h3 className="text-lg font-medium">AI Assistant</h3>
<p className="text-muted-foreground mt-1 max-w-sm"> .</p>
</div>
) : (
<div className="space-y-4">
{messages.map((msg, i) => (
<div key={i} className={cn("flex gap-3", msg.role === "user" && "flex-row-reverse")}>
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className={cn(msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted")}>
{msg.role === "user" ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
</AvatarFallback>
</Avatar>
<div className={cn("max-w-[80%] rounded-lg px-4 py-2", msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted")}>
<p className="whitespace-pre-wrap text-sm">{msg.content}</p>
</div>
</div>
))}
{loading && (
<div className="flex gap-3">
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className="bg-muted"><Bot className="h-4 w-4" /></AvatarFallback>
</Avatar>
<div className="rounded-lg bg-muted px-4 py-2"><Loader2 className="h-4 w-4 animate-spin" /></div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</ScrollArea>
<CardContent className="border-t p-4">
<form onSubmit={handleSubmit} className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), handleSubmit(e as unknown as React.FormEvent))}
placeholder="메시지 입력 (Shift+Enter 줄바꿈)"
className="max-h-[200px] min-h-[60px] resize-none"
disabled={loading}
/>
<Button type="submit" size="icon" className="h-[60px] w-[60px]" disabled={loading || !input.trim()}>
{loading ? <Loader2 className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5" />}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,190 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { getAiAssistantAuth, aiAssistantApi } from "@/lib/api/aiAssistant";
import type { UsageSummary, ApiKeyItem, AdminStats } from "@/lib/api/aiAssistant";
import { BarChart3, Key, Zap, TrendingUp, Loader2, AlertCircle, Users, Cpu } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { toast } from "sonner";
export default function AiAssistantDashboardPage() {
const auth = getAiAssistantAuth();
const user = auth?.user;
const isAdmin = user?.role === "admin";
const [loading, setLoading] = useState(true);
const [usage, setUsage] = useState<UsageSummary | null>(null);
const [apiKeys, setApiKeys] = useState<ApiKeyItem[]>([]);
const [stats, setStats] = useState<AdminStats | null>(null);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const usageRes = await aiAssistantApi.get("/usage");
setUsage(usageRes.data?.data ?? null);
const keysRes = await aiAssistantApi.get("/api-keys");
setApiKeys(keysRes.data?.data ?? []);
if (isAdmin) {
const statsRes = await aiAssistantApi.get("/admin/stats");
setStats(statsRes.data?.data ?? null);
}
} catch {
toast.error("데이터를 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
</div>
);
}
const monthlyTokens = usage?.usage?.monthly?.totalTokens ?? 0;
const monthlyLimit = usage?.limit?.monthly ?? 0;
const usagePercent = monthlyLimit > 0 ? Math.round((monthlyTokens / monthlyLimit) * 100) : 0;
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-1">, {user?.name || user?.email}!</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<Zap className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(usage?.usage?.today?.tokens ?? 0).toLocaleString()}
</div>
<p className="text-muted-foreground text-xs"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<BarChart3 className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{monthlyTokens.toLocaleString()}</div>
<p className="text-muted-foreground mb-2 text-xs">
/ {monthlyLimit.toLocaleString()}
</p>
<Progress value={usagePercent} className="h-2" />
<p className="text-muted-foreground mt-1 text-right text-xs">{usagePercent}% </p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<TrendingUp className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(usage?.usage?.today?.requests ?? 0).toLocaleString()}
</div>
<p className="text-muted-foreground text-xs"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> API </CardTitle>
<Key className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{apiKeys.filter((k) => k.status === "active").length}
</div>
<p className="text-muted-foreground text-xs"></p>
</CardContent>
</Card>
</div>
{isAdmin && stats && (
<Card className="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription className="text-primary-foreground/70"> </CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Users className="h-4 w-4" />
<span className="text-sm opacity-80"> </span>
</div>
<p className="text-2xl font-bold">{stats.users?.total ?? 0}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Users className="h-4 w-4" />
<span className="text-sm opacity-80"> </span>
</div>
<p className="text-2xl font-bold">{stats.users?.active ?? 0}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Key className="h-4 w-4" />
<span className="text-sm opacity-80"> API </span>
</div>
<p className="text-2xl font-bold">{stats.apiKeys?.total ?? 0}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4" />
<span className="text-sm opacity-80"> </span>
</div>
<p className="text-2xl font-bold">{stats.providers?.active ?? 0}</p>
</div>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle> API </CardTitle>
<CardDescription> API </CardDescription>
</CardHeader>
<CardContent>
{apiKeys.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="text-muted-foreground mb-3 h-10 w-10" />
<p className="text-muted-foreground">API .</p>
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="space-y-3">
{apiKeys.slice(0, 5).map((key) => (
<div
key={key.id}
className="bg-card flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{key.name}</p>
<p className="text-muted-foreground font-mono text-sm">{key.keyPrefix}...</p>
</div>
<Badge variant={key.status === "active" ? "success" : "secondary"}>
{key.status === "active" ? "활성" : "비활성"}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,157 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { aiAssistantApi } from "@/lib/api/aiAssistant";
import type { UsageLogItem } from "@/lib/api/aiAssistant";
import { History, Loader2, MessageSquare, Clock, Zap, CheckCircle, XCircle } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
export default function AiAssistantHistoryPage() {
const [loading, setLoading] = useState(true);
const [logs, setLogs] = useState<UsageLogItem[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
loadLogs();
}, [page]);
const loadLogs = async () => {
setLoading(true);
try {
const res = await aiAssistantApi.get(`/usage/logs?page=${page}&limit=20`);
const data = res.data?.data as { logs?: UsageLogItem[]; pagination?: { totalPages?: number } };
setLogs(data?.logs ?? []);
setTotalPages(data?.pagination?.totalPages ?? 1);
} catch {
toast.error("대화 이력을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
if (loading && logs.length === 0) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground mt-1">AI Assistant와의 .</p>
</div>
<Card>
<CardHeader>
<CardTitle>API </CardTitle>
<CardDescription> API </CardDescription>
</CardHeader>
<CardContent>
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<History className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="text-lg font-medium"> </h3>
<p className="text-muted-foreground mt-1 text-sm">AI .</p>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell>
{log.success ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
</Badge>
)}
</TableCell>
<TableCell>
<Badge variant="outline">{log.providerName}</Badge>
</TableCell>
<TableCell className="font-mono text-sm">{log.modelName}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Zap className="text-muted-foreground h-3 w-3" />
<span>{(log.totalTokens ?? 0).toLocaleString()}</span>
</div>
<div className="text-muted-foreground text-xs">
: {log.promptTokens ?? 0} / : {log.completionTokens ?? 0}
</div>
</TableCell>
<TableCell>${(log.costUsd ?? 0).toFixed(6)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Clock className="text-muted-foreground h-3 w-3" />
<span>{log.responseTimeMs ?? 0}ms</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(log.createdAt).toLocaleString("ko-KR")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
</Button>
<span className="text-muted-foreground text-sm">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,128 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import {
getAiAssistantAuth,
setAiAssistantAuth,
loginAiAssistant,
} from "@/lib/api/aiAssistant";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function AIAssistantLayout({
children,
}: {
children: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
const [auth, setAuth] = useState<ReturnType<typeof getAiAssistantAuth>>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
setAuth(getAiAssistantAuth());
setMounted(true);
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await loginAiAssistant(email, password);
setAuth(getAiAssistantAuth());
} catch (err: unknown) {
const msg =
err && typeof err === "object" && "response" in err
? (err as { response?: { data?: { error?: { message?: string } } } }).response?.data
?.error?.message
: null;
setError(msg || "로그인에 실패했습니다.");
} finally {
setLoading(false);
}
};
const handleLogout = () => {
setAiAssistantAuth(null);
setAuth(null);
};
if (!mounted) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground text-sm"> ...</p>
</div>
);
}
if (!auth) {
return (
<div className="mx-auto flex min-h-[60vh] max-w-sm flex-col justify-center p-6">
<Card>
<CardHeader>
<CardTitle>AI </CardTitle>
<CardDescription>
AI (API , , ) .
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@admin.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<p className="text-destructive text-sm">{error}</p>
)}
<div className="flex gap-2">
<Button type="submit" className="flex-1" disabled={loading}>
{loading ? "로그인 중..." : "로그인"}
</Button>
<Button asChild variant="outline">
<Link href="/admin"></Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-end gap-2 border-b pb-2 text-sm">
<span className="text-muted-foreground">
{auth.user?.name || auth.user?.email} (AI )
</span>
<Button variant="ghost" size="sm" onClick={handleLogout}>
</Button>
</div>
{children}
</div>
);
}

View File

@ -1,6 +0,0 @@
import { redirect } from "next/navigation";
/** AI 어시스턴트 진입 시 대시보드로 이동 */
export default function AIAssistantPage() {
redirect("/admin/aiAssistant/dashboard");
}

View File

@ -1,195 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { aiAssistantApi } from "@/lib/api/aiAssistant";
import type { UsageSummary } from "@/lib/api/aiAssistant";
import { BarChart3, Calendar, Loader2, TrendingUp, Zap, DollarSign } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
interface DailyUsageItem {
date?: string;
totalTokens?: number;
requestCount?: number;
}
export default function AiAssistantUsagePage() {
const [loading, setLoading] = useState(true);
const [usage, setUsage] = useState<UsageSummary | null>(null);
const [dailyUsage, setDailyUsage] = useState<DailyUsageItem[]>([]);
const [period, setPeriod] = useState("7");
useEffect(() => {
loadUsage();
}, [period]);
const loadUsage = async () => {
setLoading(true);
try {
const [usageRes, dailyRes] = await Promise.all([
aiAssistantApi.get("/usage"),
aiAssistantApi.get(`/usage/daily?days=${period}`),
]);
setUsage(usageRes.data?.data ?? null);
setDailyUsage((dailyRes.data?.data as { usage?: DailyUsageItem[] })?.usage ?? []);
} catch {
toast.error("사용량 데이터를 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
</div>
);
}
const todayTokens = usage?.usage?.today?.tokens ?? 0;
const todayRequests = usage?.usage?.today?.requests ?? 0;
const monthlyTokens = usage?.usage?.monthly?.totalTokens ?? 0;
const monthlyCost = usage?.usage?.monthly?.totalCost ?? 0;
const monthlyLimit = usage?.limit?.monthly ?? 0;
const usagePercent = monthlyLimit > 0 ? Math.round((monthlyTokens / monthlyLimit) * 100) : 0;
const maxTokens = Math.max(...dailyUsage.map((d) => d.totalTokens ?? 0), 1);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-1">API .</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<Zap className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{todayTokens.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<TrendingUp className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{todayRequests.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<BarChart3 className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{monthlyTokens.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<DollarSign className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${monthlyCost.toFixed(4)}</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</div>
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="w-[140px]">
<Calendar className="mr-2 h-4 w-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7"> 7</SelectItem>
<SelectItem value="14"> 14</SelectItem>
<SelectItem value="30"> 30</SelectItem>
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{dailyUsage.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="text-muted-foreground mb-4 h-12 w-12" />
<p className="text-muted-foreground"> .</p>
</div>
) : (
<div className="space-y-3">
{dailyUsage.map((day, idx) => (
<div key={day.date ?? idx} className="flex items-center gap-4">
<div className="text-muted-foreground w-20 text-sm">
{day.date
? new Date(day.date).toLocaleDateString("ko-KR", {
month: "short",
day: "numeric",
})
: "-"}
</div>
<div className="flex-1">
<div className="bg-muted h-8 overflow-hidden rounded-lg">
<div
className="bg-primary h-full rounded-lg transition-all duration-500"
style={{
width: `${((day.totalTokens ?? 0) / maxTokens) * 100}%`,
}}
/>
</div>
</div>
<div className="w-28 text-right">
<span className="text-sm font-medium">
{(day.totalTokens ?? 0).toLocaleString()}
</span>
<span className="text-muted-foreground ml-1 text-xs"></span>
</div>
<div className="text-muted-foreground w-16 text-right text-sm">
{day.requestCount ?? 0}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card className="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground">
<CardContent className="pt-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">
: {(usage?.plan ?? "FREE").toUpperCase()}
</h3>
<p className="text-primary-foreground/70">
: {monthlyLimit > 0 ? monthlyLimit.toLocaleString() : "무제한"}
</p>
</div>
<div className="text-right">
<p className="text-3xl font-bold">{usagePercent}%</p>
<p className="text-primary-foreground/70 text-sm"></p>
</div>
</div>
<Progress value={usagePercent} className="bg-primary-foreground/20 h-3" />
</CardContent>
</Card>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2, Bot } from "lucide-react";
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2 } from "lucide-react";
import Link from "next/link";
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
@ -80,20 +80,6 @@ export default function AdminPage() {
</div>
</div>
</Link>
<Link href="/admin/aiAssistant" className="block">
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Bot className="text-primary h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold">AI </h3>
<p className="text-muted-foreground text-sm">AI LLM </p>
</div>
</div>
</div>
</Link>
</div>
</div>

View File

@ -6,15 +6,12 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { BarcodeListTable } from "@/components/barcode/BarcodeListTable";
import { BarcodeScanModal } from "@/components/common/BarcodeScanModal";
import { Plus, Search, RotateCcw, Scan } from "lucide-react";
import { Plus, Search, RotateCcw } from "lucide-react";
import { useBarcodeList } from "@/hooks/useBarcodeList";
export default function BarcodeLabelManagementPage() {
const router = useRouter();
const [searchText, setSearchText] = useState("");
const [scanModalOpen, setScanModalOpen] = useState(false);
const [scannedBarcode, setScannedBarcode] = useState<string | null>(null);
const { labels, total, page, limit, isLoading, refetch, setPage, handleSearch } = useBarcodeList();
@ -77,33 +74,6 @@ export default function BarcodeLabelManagementPage() {
</CardContent>
</Card>
{/* 카메라 스캔: 바코드 값을 텍스트로 추출해 표시 */}
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">
<Scan className="h-5 w-5" />
</CardTitle>
<p className="text-muted-foreground text-sm">
.
</p>
</CardHeader>
<CardContent className="space-y-4">
<Button onClick={() => setScanModalOpen(true)} variant="outline" className="gap-2">
<Scan className="h-4 w-4" />
</Button>
{scannedBarcode ? (
<div className="rounded-lg border bg-muted/30 p-4">
<p className="text-muted-foreground mb-1 text-sm"> </p>
<p className="font-mono text-lg font-semibold break-all">{scannedBarcode}</p>
</div>
) : (
<p className="text-muted-foreground text-sm"> . .</p>
)}
</CardContent>
</Card>
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center justify-between">
@ -125,18 +95,6 @@ export default function BarcodeLabelManagementPage() {
/>
</CardContent>
</Card>
<BarcodeScanModal
open={scanModalOpen}
onOpenChange={setScanModalOpen}
targetField="바코드 값"
barcodeFormat="all"
autoSubmit={false}
onScanSuccess={(barcode) => {
setScannedBarcode(barcode);
setScanModalOpen(false);
}}
/>
</div>
</div>
);

View File

@ -669,6 +669,38 @@ export default function TableManagementPage() {
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
}
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
console.log("🔍 Numbering 저장 체크:", {
inputType: column.inputType,
numberingRuleId: column.numberingRuleId,
hasNumberingRuleId: !!column.numberingRuleId,
});
if (column.inputType === "numbering") {
let existingSettings: Record<string, unknown> = {};
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(finalDetailSettings);
} catch {
existingSettings = {};
}
}
// numberingRuleId가 있으면 저장, 없으면 제거
if (column.numberingRuleId) {
const numberingSettings = {
...existingSettings,
numberingRuleId: column.numberingRuleId,
};
finalDetailSettings = JSON.stringify(numberingSettings);
console.log("🔧 Numbering 설정 JSON 생성:", numberingSettings);
} else {
// numberingRuleId가 없으면 빈 객체
finalDetailSettings = JSON.stringify(existingSettings);
console.log("🔧 Numbering 규칙 없이 저장:", existingSettings);
}
}
const columnSetting = {
columnName: column.columnName,
columnLabel: column.displayName,
@ -812,6 +844,28 @@ export default function TableManagementPage() {
// detailSettings 계산
let finalDetailSettings = column.detailSettings || "";
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
if (column.inputType === "numbering" && column.numberingRuleId) {
let existingSettings: Record<string, unknown> = {};
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(finalDetailSettings);
} catch {
existingSettings = {};
}
}
const numberingSettings = {
...existingSettings,
numberingRuleId: column.numberingRuleId,
};
finalDetailSettings = JSON.stringify(numberingSettings);
console.log("🔧 전체저장 - Numbering 설정 JSON 생성:", {
columnName: column.columnName,
numberingRuleId: column.numberingRuleId,
finalDetailSettings,
});
}
// 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함
if (column.inputType === "entity" && column.referenceTable) {
let existingSettings: Record<string, unknown> = {};
@ -1933,7 +1987,118 @@ export default function TableManagementPage() {
)}
</>
)}
{/* 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요) */}
{/* 입력 타입이 'numbering'인 경우 채번규칙 선택 */}
{column.inputType === "numbering" && (
<div className="w-64">
<label className="text-muted-foreground mb-1 block text-xs"></label>
<Popover
open={numberingComboboxOpen[column.columnName] || false}
onOpenChange={(open) =>
setNumberingComboboxOpen((prev) => ({
...prev,
[column.columnName]: open,
}))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={numberingComboboxOpen[column.columnName] || false}
disabled={numberingRulesLoading}
className="bg-background h-8 w-full justify-between text-xs"
>
<span className="truncate">
{numberingRulesLoading
? "로딩 중..."
: column.numberingRuleId
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)
?.ruleName || column.numberingRuleId
: "채번규칙 선택..."}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
const columnIndex = columns.findIndex(
(c) => c.columnName === column.columnName,
);
handleColumnChange(columnIndex, "numberingRuleId", undefined);
setNumberingComboboxOpen((prev) => ({
...prev,
[column.columnName]: false,
}));
// 자동 저장 제거 - 전체 저장 버튼으로 저장
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!column.numberingRuleId ? "opacity-100" : "opacity-0",
)}
/>
-- --
</CommandItem>
{numberingRules.map((rule) => (
<CommandItem
key={rule.ruleId}
value={`${rule.ruleName} ${rule.ruleId}`}
onSelect={() => {
const columnIndex = columns.findIndex(
(c) => c.columnName === column.columnName,
);
// 상태 업데이트만 (자동 저장 제거)
handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId);
setNumberingComboboxOpen((prev) => ({
...prev,
[column.columnName]: false,
}));
// 전체 저장 버튼으로 저장
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.numberingRuleId === rule.ruleId
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{rule.ruleName}</span>
{rule.tableName && (
<span className="text-muted-foreground text-[10px]">
{rule.tableName}.{rule.columnName}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{column.numberingRuleId && (
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-0.5 text-[10px]">
<Check className="h-2.5 w-2.5" />
<span> </span>
</div>
)}
</div>
)}
</div>
</div>
<div className="pl-4">

View File

@ -3,7 +3,7 @@
import React, { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { useRouter } from "next/navigation";
@ -285,15 +285,24 @@ function PopScreenViewPage() {
</div>
)}
{/* POP 화면 컨텐츠 */}
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
{/* 현재 모드 표시 (일반 모드) */}
{/* 일반 모드 네비게이션 바 */}
{!isPreviewMode && (
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
{currentModeKey.replace("_", " ")}
<div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
<Button variant="ghost" size="sm" onClick={() => router.push("/pop")} className="gap-1 text-xs">
<LayoutGrid className="h-3.5 w-3.5" />
POP
</Button>
<span className="text-xs text-gray-500">{screen.screenName}</span>
<Button variant="ghost" size="sm" onClick={() => router.push("/")} className="gap-1 text-xs">
<Monitor className="h-3.5 w-3.5" />
PC
</Button>
</div>
)}
{/* POP 화면 컨텐츠 */}
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
<div
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`}
style={isPreviewMode ? {

View File

@ -82,12 +82,19 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
});
// 화면 할당 관련 상태
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당)
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [screenSearchText, setScreenSearchText] = useState("");
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
// POP 화면 할당 관련 상태
const [selectedPopScreen, setSelectedPopScreen] = useState<ScreenDefinition | null>(null);
const [popScreenSearchText, setPopScreenSearchText] = useState("");
const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false);
const [isPopLanding, setIsPopLanding] = useState(false);
const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false);
// 대시보드 할당 관련 상태
const [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
const [dashboards, setDashboards] = useState<any[]>([]);
@ -196,8 +203,27 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`);
};
// POP 화면 선택 시 URL 자동 설정
const handlePopScreenSelect = (screen: ScreenDefinition) => {
const actualScreenId = screen.screenId || screen.id;
if (!actualScreenId) {
toast.error("화면 ID를 찾을 수 없습니다.");
return;
}
setSelectedPopScreen(screen);
setIsPopScreenDropdownOpen(false);
const popUrl = `/pop/screens/${actualScreenId}`;
setFormData((prev) => ({
...prev,
menuUrl: popUrl,
}));
};
// URL 타입 변경 시 처리
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => {
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => {
// console.log("🔄 URL 타입 변경:", {
// from: urlType,
// to: type,
@ -208,36 +234,53 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setUrlType(type);
if (type === "direct") {
// 직접 입력 모드로 변경 시 선택된 화면 초기화
setSelectedScreen(null);
// URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록)
setSelectedPopScreen(null);
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined, // 화면 코드도 함께 초기화
screenCode: undefined,
}));
} else if (type === "pop") {
setSelectedScreen(null);
if (selectedPopScreen) {
const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id;
setFormData((prev) => ({
...prev,
menuUrl: `/pop/screens/${actualScreenId}`,
}));
} else {
// 화면 할당 모드로 변경 시
// 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지
setFormData((prev) => ({
...prev,
menuUrl: "",
}));
}
} else if (type === "screen") {
setSelectedPopScreen(null);
if (selectedScreen) {
console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName);
// 현재 선택된 화면으로 URL 재생성
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
let screenUrl = `/screens/${actualScreenId}`;
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
if (isAdminMenu) {
screenUrl += "?mode=admin";
}
setFormData((prev) => ({
...prev,
menuUrl: screenUrl,
screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지
screenCode: selectedScreen.screenCode,
}));
} else {
// 선택된 화면이 없으면 URL과 screenCode 초기화
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined,
}));
}
} else {
// dashboard
setSelectedScreen(null);
setSelectedPopScreen(null);
if (!selectedDashboard) {
setFormData((prev) => ({
...prev,
menuUrl: "",
@ -297,8 +340,8 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
const menuUrl = menu.menu_url || menu.MENU_URL || "";
// URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정)
const isScreenUrl = menuUrl.startsWith("/screens/");
const isPopScreenUrl = menuUrl.startsWith("/pop/screens/");
const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/");
setFormData({
objid: menu.objid || menu.OBJID,
@ -360,10 +403,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
}, 500);
}
}
} else if (isPopScreenUrl) {
setUrlType("pop");
setSelectedScreen(null);
// [POP_LANDING] 태그 감지
const menuDesc = menu.menu_desc || menu.MENU_DESC || "";
setIsPopLanding(menuDesc.includes("[POP_LANDING]"));
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
if (popScreenId) {
const setPopScreenFromId = () => {
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
if (screen) {
setSelectedPopScreen(screen);
}
};
if (screens.length > 0) {
setPopScreenFromId();
} else {
setTimeout(setPopScreenFromId, 500);
}
}
} else if (menuUrl.startsWith("/dashboard/")) {
setUrlType("dashboard");
setSelectedScreen(null);
// 대시보드 ID 추출 및 선택은 useEffect에서 처리됨
} else {
setUrlType("direct");
setSelectedScreen(null);
@ -408,6 +472,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
} else {
console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType);
setIsEdit(false);
setIsPopLanding(false);
// 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1)
let defaultMenuType = "1"; // 기본값은 사용자
@ -470,6 +535,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
}
}, [isOpen, formData.companyCode]);
// POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인
useEffect(() => {
if (!isOpen) return;
const checkOtherPopLanding = async () => {
try {
const res = await menuApi.getPopMenus();
if (res.success && res.data?.landingMenu) {
const landingObjId = res.data.landingMenu.objid?.toString();
const currentObjId = formData.objid?.toString();
// 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복
setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId);
} else {
setHasOtherPopLanding(false);
}
} catch {
setHasOtherPopLanding(false);
}
};
if (urlType === "pop") {
checkOtherPopLanding();
}
}, [isOpen, urlType, formData.objid]);
// 화면 목록 및 대시보드 목록 로드
useEffect(() => {
if (isOpen) {
@ -517,6 +607,22 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
}
}, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]);
// POP 화면 목록 로드 완료 후 기존 할당 설정
useEffect(() => {
if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") {
const menuUrl = formData.menuUrl;
if (menuUrl.startsWith("/pop/screens/")) {
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
if (popScreenId && !selectedPopScreen) {
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
if (screen) {
setSelectedPopScreen(screen);
}
}
}
}
}, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -533,16 +639,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setIsDashboardDropdownOpen(false);
setDashboardSearchText("");
}
if (!target.closest(".pop-screen-dropdown")) {
setIsPopScreenDropdownOpen(false);
setPopScreenSearchText("");
}
};
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) {
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isLangKeyDropdownOpen, isScreenDropdownOpen]);
}, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]);
const loadCompanies = async () => {
try {
@ -590,10 +700,17 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
try {
setLoading(true);
// POP 기본 화면 태그 처리
let finalMenuDesc = formData.menuDesc;
if (urlType === "pop") {
const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim();
finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag;
}
// 백엔드에 전송할 데이터 변환
const submitData = {
...formData,
// 상태를 소문자로 변환 (백엔드에서 소문자 기대)
menuDesc: finalMenuDesc,
status: formData.status.toLowerCase(),
};
@ -853,7 +970,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
{/* URL 타입 선택 */}
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex space-x-6">
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex flex-wrap gap-x-6 gap-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="screen" id="screen" />
<Label htmlFor="screen" className="cursor-pointer">
@ -866,6 +983,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pop" id="pop" />
<Label htmlFor="pop" className="cursor-pointer">
POP
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="direct" id="direct" />
<Label htmlFor="direct" className="cursor-pointer">
@ -1031,6 +1154,106 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</div>
)}
{/* POP 화면 할당 */}
{urlType === "pop" && (
<div className="space-y-2">
<div className="relative">
<Button
type="button"
variant="outline"
onClick={() => setIsPopScreenDropdownOpen(!isPopScreenDropdownOpen)}
className="w-full justify-between"
>
<span className="text-left">
{selectedPopScreen ? selectedPopScreen.screenName : "POP 화면을 선택하세요"}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
{isPopScreenDropdownOpen && (
<div className="pop-screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
<div className="sticky top-0 border-b bg-white p-2">
<div className="relative">
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="POP 화면 검색..."
value={popScreenSearchText}
onChange={(e) => setPopScreenSearchText(e.target.value)}
className="pl-8"
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto">
{screens
.filter(
(screen) =>
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
)
.map((screen, index) => (
<div
key={`pop-screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
onClick={() => handlePopScreenSelect(screen)}
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{screen.screenName}</div>
<div className="text-xs text-gray-500">{screen.screenCode}</div>
</div>
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
</div>
</div>
))}
{screens.filter(
(screen) =>
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500"> .</div>}
</div>
</div>
)}
</div>
{selectedPopScreen && (
<div className="bg-accent rounded-md border p-3">
<div className="text-sm font-medium text-blue-900">{selectedPopScreen.screenName}</div>
<div className="text-primary text-xs">: {selectedPopScreen.screenCode}</div>
<div className="text-primary text-xs"> URL: {formData.menuUrl}</div>
</div>
)}
{/* POP 기본 화면 설정 */}
<div className="flex items-center space-x-2 rounded-md border p-3">
<input
type="checkbox"
id="popLanding"
checked={isPopLanding}
disabled={!isPopLanding && hasOtherPopLanding}
onChange={(e) => setIsPopLanding(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<label
htmlFor="popLanding"
className={`text-sm font-medium ${!isPopLanding && hasOtherPopLanding ? "text-muted-foreground" : ""}`}
>
POP
</label>
{!isPopLanding && hasOtherPopLanding && (
<span className="text-xs text-muted-foreground">
( )
</span>
)}
</div>
{isPopLanding && (
<p className="text-xs text-muted-foreground">
POP .
</p>
)}
</div>
)}
{/* URL 직접 입력 */}
{urlType === "direct" && (
<Input

View File

@ -2,7 +2,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Eye, EyeOff, Loader2, Monitor } from "lucide-react";
import { LoginFormData } from "@/types/auth";
import { ErrorMessage } from "./ErrorMessage";
@ -11,9 +12,11 @@ interface LoginFormProps {
isLoading: boolean;
error: string;
showPassword: boolean;
isPopMode: boolean;
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSubmit: (e: React.FormEvent) => void;
onTogglePassword: () => void;
onTogglePop: () => void;
}
/**
@ -24,9 +27,11 @@ export function LoginForm({
isLoading,
error,
showPassword,
isPopMode,
onInputChange,
onSubmit,
onTogglePassword,
onTogglePop,
}: LoginFormProps) {
return (
<Card className="border-0 shadow-xl">
@ -82,6 +87,19 @@ export function LoginForm({
</div>
</div>
{/* POP 모드 토글 */}
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-slate-500" />
<span className="text-sm text-slate-600">POP </span>
</div>
<Switch
checked={isPopMode}
onCheckedChange={onTogglePop}
disabled={isLoading}
/>
</div>
{/* 로그인 버튼 */}
<Button
type="submit"

View File

@ -6,10 +6,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BarcodeLabelComponent } from "@/types/barcode";
import { v4 as uuidv4 } from "uuid";
const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.ReactNode; barcodeType?: string }[] = [
const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.ReactNode }[] = [
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
{ type: "barcode", label: "바코드", icon: <Barcode className="h-4 w-4" /> },
{ type: "barcode", label: "QR 코드", icon: <Barcode className="h-4 w-4" />, barcodeType: "QR" },
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
{ type: "line", label: "선", icon: <Minus className="h-4 w-4" /> },
{ type: "rectangle", label: "사각형", icon: <Square className="h-4 w-4" /> },
@ -17,24 +16,22 @@ const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.R
const MM_TO_PX = 4;
function defaultComponent(type: BarcodeLabelComponent["type"], barcodeType?: string): BarcodeLabelComponent {
function defaultComponent(type: BarcodeLabelComponent["type"]): BarcodeLabelComponent {
const id = `comp_${uuidv4()}`;
const base = { id, type, x: 10 * MM_TO_PX, y: 10 * MM_TO_PX, width: 80, height: 24, zIndex: 0 };
switch (type) {
case "text":
return { ...base, content: "텍스트", fontSize: 10, fontColor: "#000000" };
case "barcode": {
const isQR = barcodeType === "QR";
case "barcode":
return {
...base,
width: isQR ? 100 : 120,
height: isQR ? 100 : 40,
barcodeType: barcodeType || "CODE128",
barcodeValue: isQR ? "" : "123456789",
showBarcodeText: !isQR,
width: 120,
height: 40,
barcodeType: "CODE128",
barcodeValue: "123456789",
showBarcodeText: true,
};
}
case "image":
return { ...base, width: 60, height: 60, imageUrl: "", objectFit: "contain" };
case "line":
@ -50,16 +47,14 @@ function DraggableItem({
type,
label,
icon,
barcodeType,
}: {
type: BarcodeLabelComponent["type"];
label: string;
icon: React.ReactNode;
barcodeType?: string;
}) {
const [{ isDragging }, drag] = useDrag(() => ({
type: "barcode-component",
item: { component: defaultComponent(type, barcodeType) },
item: { component: defaultComponent(type) },
collect: (m) => ({ isDragging: m.isDragging() }),
}));
@ -83,14 +78,8 @@ export function BarcodeComponentPalette() {
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{ITEMS.map((item, idx) => (
<DraggableItem
key={item.barcodeType ? `${item.type}_${item.barcodeType}` : `${item.type}_${idx}`}
type={item.type}
label={item.label}
icon={item.icon}
barcodeType={item.barcodeType}
/>
{ITEMS.map((item) => (
<DraggableItem key={item.type} type={item.type} label={item.label} icon={item.icon} />
))}
</CardContent>
</Card>

View File

@ -1,20 +1,14 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { useRef } from "react";
import { useDrop } from "react-dnd";
import { useBarcodeDesigner, MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent";
import { BarcodeLabelComponent } from "@/types/barcode";
import { v4 as uuidv4 } from "uuid";
/** 작업 영역에 라벨이 들어가도록 스케일 (최소 0.5=작게 맞춤, 최대 3) */
const MIN_SCALE = 0.5;
const MAX_SCALE = 3;
export function BarcodeDesignerCanvas() {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const {
widthMm,
heightMm,
@ -28,45 +22,17 @@ export function BarcodeDesignerCanvas() {
const widthPx = widthMm * MM_TO_PX;
const heightPx = heightMm * MM_TO_PX;
// 컨테이너 크기에 맞춰 캔버스 스케일 계산 (라벨이 너무 작게 보이지 않도록)
useEffect(() => {
const el = containerRef.current;
if (!el || widthPx <= 0 || heightPx <= 0) return;
const observer = new ResizeObserver(() => {
const w = el.clientWidth - 48;
const h = el.clientHeight - 48;
if (w <= 0 || h <= 0) return;
const scaleX = w / widthPx;
const scaleY = h / heightPx;
const fitScale = Math.min(scaleX, scaleY);
const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fitScale));
setScale(s);
});
observer.observe(el);
const w = el.clientWidth - 48;
const h = el.clientHeight - 48;
if (w > 0 && h > 0) {
const scaleX = w / widthPx;
const scaleY = h / heightPx;
const fitScale = Math.min(scaleX, scaleY);
const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fitScale));
setScale(s);
}
return () => observer.disconnect();
}, [widthPx, heightPx]);
const [{ isOver }, drop] = useDrop(() => ({
accept: "barcode-component",
drop: (item: { component: BarcodeLabelComponent }, monitor) => {
const canvasEl = canvasRef.current;
if (!canvasEl) return;
if (!canvasRef.current) return;
const offset = monitor.getClientOffset();
const rect = canvasEl.getBoundingClientRect();
const rect = canvasRef.current.getBoundingClientRect();
if (!offset) return;
// 스케일 적용된 좌표 → 실제 캔버스 좌표
const s = scale;
let x = (offset.x - rect.left) / s;
let y = (offset.y - rect.top) / s;
let x = offset.x - rect.left;
let y = offset.y - rect.top;
// 드롭 시 요소 중앙이 커서에 오도록 보정
x -= item.component.width / 2;
y -= item.component.height / 2;
x = Math.max(0, Math.min(x, widthPx - item.component.width));
@ -82,32 +48,14 @@ export function BarcodeDesignerCanvas() {
addComponent(newComp);
},
collect: (m) => ({ isOver: m.isOver() }),
}), [widthPx, heightPx, scale, components.length, addComponent, snapValueToGrid]);
// 스케일된 캔버스가 컨테이너 안에 들어가도록 fit (하단 잘림 방지)
const scaledW = widthPx * scale;
const scaledH = heightPx * scale;
}), [widthPx, heightPx, components.length, addComponent, snapValueToGrid]);
return (
<div
ref={containerRef}
className="flex min-h-0 flex-1 items-center justify-center overflow-auto bg-gray-100 p-6"
>
{/* 래퍼: 스케일된 크기만큼 차지해서 flex로 정확히 가운데 + 하단 잘림 방지 */}
<div
className="flex shrink-0 items-center justify-center"
style={{ width: scaledW, height: scaledH }}
>
<div
style={{
transform: `scale(${scale})`,
transformOrigin: "0 0",
}}
>
<div className="flex flex-1 items-center justify-center overflow-auto bg-gray-100 p-6">
<div
key={`canvas-${widthMm}-${heightMm}`}
ref={(r) => {
(canvasRef as { current: HTMLDivElement | null }).current = r;
(canvasRef as any).current = r;
drop(r);
}}
className="relative bg-white shadow-lg"
@ -132,7 +80,5 @@ export function BarcodeDesignerCanvas() {
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -6,7 +6,7 @@ import { BarcodeComponentPalette } from "./BarcodeComponentPalette";
export function BarcodeDesignerLeftPanel() {
return (
<div className="flex min-h-0 w-72 shrink-0 flex-col overflow-hidden border-r bg-white">
<div className="flex min-h-0 w-64 shrink-0 flex-col overflow-hidden border-r bg-white">
<div className="min-h-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<div className="space-y-4 p-4">

View File

@ -1,125 +1,14 @@
"use client";
import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Trash2, Plus } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Trash2 } from "lucide-react";
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
import { BarcodeLabelComponent } from "@/types/barcode";
// QR 기본 키: 품번, 품명, 규격
const DEFAULT_QR_JSON_KEYS = ["part_no", "part_name", "spec"];
function parseQRJsonValue(str: string): Record<string, string> {
const trimmed = (str || "").trim();
if (!trimmed) return {};
try {
const o = JSON.parse(trimmed);
if (o && typeof o === "object" && !Array.isArray(o)) {
return Object.fromEntries(
Object.entries(o).map(([k, v]) => [String(k), v != null ? String(v) : ""])
);
}
} catch {
// ignore
}
return {};
}
function QRJsonFields({
selected,
update,
}: {
selected: BarcodeLabelComponent;
update: (u: Partial<BarcodeLabelComponent>) => void;
}) {
const [pairs, setPairs] = useState<{ key: string; value: string }[]>(() => {
const parsed = parseQRJsonValue(selected.barcodeValue || "");
if (Object.keys(parsed).length > 0) {
return Object.entries(parsed).map(([key, value]) => ({ key, value }));
}
return DEFAULT_QR_JSON_KEYS.map((key) => ({ key, value: "" }));
});
// 바코드 값이 바깥에서 바뀌면 파싱해서 동기화
useEffect(() => {
const parsed = parseQRJsonValue(selected.barcodeValue || "");
if (Object.keys(parsed).length > 0) {
setPairs(Object.entries(parsed).map(([key, value]) => ({ key, value: String(value ?? "") })));
}
}, [selected.barcodeValue]);
const applyJson = () => {
const obj: Record<string, string> = {};
pairs.forEach(({ key, value }) => {
const k = key.trim();
if (k) obj[k] = value.trim();
});
update({ barcodeValue: JSON.stringify(obj) });
};
const setPair = (index: number, field: "key" | "value", val: string) => {
setPairs((prev) => {
const next = [...prev];
if (!next[index]) next[index] = { key: "", value: "" };
next[index] = { ...next[index], [field]: val };
return next;
});
};
const addRow = () => setPairs((prev) => [...prev, { key: "", value: "" }]);
const removeRow = (index: number) =>
setPairs((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)));
return (
<div className="rounded-md border border-primary/30 bg-muted/20 p-3">
<Label className="text-xs font-medium"> JSON으로 QR </Label>
<p className="text-muted-foreground mt-0.5 text-[10px]"> , QR에 .</p>
<div className="mt-2 space-y-2">
{pairs.map((p, i) => (
<div key={i} className="flex gap-1 items-center">
<Input
className="h-8 flex-1 min-w-0 text-xs"
placeholder="키 (예: part_no)"
value={p.key}
onChange={(e) => setPair(i, "key", e.target.value)}
/>
<Input
className="h-8 flex-1 min-w-0 text-xs"
placeholder="값"
value={p.value}
onChange={(e) => setPair(i, "value", e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive"
onClick={() => removeRow(i)}
disabled={pairs.length <= 1}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<div className="mt-2 flex gap-1">
<Button type="button" size="sm" variant="outline" className="flex-1 gap-1" onClick={addRow}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button type="button" size="sm" className="flex-1" onClick={applyJson}>
JSON으로 QR
</Button>
</div>
</div>
);
}
export function BarcodeDesignerRightPanel() {
const {
components,
@ -167,8 +56,8 @@ export function BarcodeDesignerRightPanel() {
updateComponent(selected.id, updates);
return (
<div className="flex w-72 flex-col border-l bg-white overflow-hidden">
<div className="shrink-0 border-b p-2 flex items-center justify-between">
<div className="w-72 border-l bg-white">
<div className="border-b p-2 flex items-center justify-between">
<span className="text-sm font-medium"></span>
<Button
variant="ghost"
@ -182,7 +71,6 @@ export function BarcodeDesignerRightPanel() {
<Trash2 className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-4 p-4">
<div className="grid grid-cols-2 gap-2">
<div>
@ -273,15 +161,12 @@ export function BarcodeDesignerRightPanel() {
</SelectContent>
</Select>
</div>
{selected.barcodeType === "QR" && (
<QRJsonFields selected={selected} update={update} />
)}
<div>
<Label className="text-xs">{selected.barcodeType === "QR" ? "값 (직접 JSON 입력)" : "값"}</Label>
<Label className="text-xs"></Label>
<Input
value={selected.barcodeValue || ""}
onChange={(e) => update({ barcodeValue: e.target.value })}
placeholder={selected.barcodeType === "QR" ? '{"part_no":"","part_name":"","spec":""}' : "123456789"}
placeholder="123456789"
/>
</div>
<div className="flex items-center gap-2">
@ -361,7 +246,6 @@ export function BarcodeDesignerRightPanel() {
</div>
)}
</div>
</ScrollArea>
</div>
);
}

View File

@ -114,13 +114,11 @@ export function BarcodeTemplatePalette() {
key={t.template_id}
variant="outline"
size="sm"
className="h-auto w-full justify-start px-2 py-1.5 text-left"
className="h-auto w-full justify-start py-1.5 text-left"
onClick={() => applyTemplate(t.template_id)}
>
<span className="block break-words text-left text-xs leading-tight">
{t.template_name_kor}
</span>
<span className="text-muted-foreground mt-0.5 block text-[10px]">
<span className="truncate">{t.template_name_kor}</span>
<span className="text-muted-foreground ml-1 shrink-0 text-xs">
{t.width_mm}×{t.height_mm}
</span>
</Button>

View File

@ -42,9 +42,12 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
// 바코드 리더 초기화
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
useEffect(() => {
if (open) {
setScannedCode("");
setError("");
setIsScanning(false);
codeReaderRef.current = new BrowserMultiFormatReader();
}
@ -183,7 +186,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
.
{targetField && ` (대상 필드: ${targetField})`}
</DialogDescription>
</DialogHeader>
@ -276,7 +279,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
{/* 스캔 가이드 오버레이 */}
{isScanning && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
<div className="h-3/5 w-4/5 rounded-lg border-4 border-primary/70 animate-pulse" />
<div className="absolute bottom-4 left-0 right-0 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
<Scan className="h-4 w-4 animate-pulse text-primary" />
@ -355,6 +358,20 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
</Button>
)}
{scannedCode && (
<Button
variant="outline"
onClick={() => {
setScannedCode("");
startScanning();
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Camera className="mr-2 h-4 w-4" />
</Button>
)}
{scannedCode && !autoSubmit && (
<Button
onClick={handleConfirm}

View File

@ -27,7 +27,6 @@ import {
ArrowRight,
Zap,
Copy,
Loader2,
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
@ -36,8 +35,6 @@ import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
import { cn } from "@/lib/utils";
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { getTableColumns } from "@/lib/api/tableManagement";
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
export interface MasterDetailExcelConfig {
@ -136,19 +133,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
// 중복 처리 방법 (전역 설정)
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
// 카테고리 검증 관련
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
// { [columnName]: { invalidValue: string, replacement: string | null, validOptions: {code: string, label: string}[], rowIndices: number[] }[] }
const [categoryMismatches, setCategoryMismatches] = useState<
Record<string, Array<{
invalidValue: string;
replacement: string | null;
validOptions: Array<{ code: string; label: string }>;
rowIndices: number[];
}>>
>({});
// 3단계: 확인
const [isUploading, setIsUploading] = useState(false);
@ -617,177 +601,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
// 중복 체크 설정된 컬럼 수
const duplicateCheckCount = columnMappings.filter((m) => m.checkDuplicate && m.systemColumn).length;
// 카테고리 컬럼 검증: 엑셀 데이터에서 유효하지 않은 카테고리 값 감지
const validateCategoryColumns = async () => {
try {
setIsCategoryValidating(true);
const targetTableName = isMasterDetail && masterDetailRelation
? masterDetailRelation.detailTable
: tableName;
// 테이블의 카테고리 타입 컬럼 조회
const colResponse = await getTableColumns(targetTableName);
if (!colResponse.success || !colResponse.data?.columns) {
return null;
}
const categoryColumns = colResponse.data.columns.filter(
(col: any) => col.inputType === "category"
);
if (categoryColumns.length === 0) {
return null;
}
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
const mappedCategoryColumns: Array<{
systemCol: string;
excelCol: string;
displayName: string;
}> = [];
for (const mapping of columnMappings) {
if (!mapping.systemColumn) continue;
const rawName = mapping.systemColumn.includes(".")
? mapping.systemColumn.split(".")[1]
: mapping.systemColumn;
const catCol = categoryColumns.find(
(cc: any) => (cc.columnName || cc.column_name) === rawName
);
if (catCol) {
mappedCategoryColumns.push({
systemCol: rawName,
excelCol: mapping.excelColumn,
displayName: catCol.displayName || catCol.display_name || rawName,
});
}
}
if (mappedCategoryColumns.length === 0) {
return null;
}
// 각 카테고리 컬럼의 유효값 조회 및 엑셀 데이터 검증
const mismatches: typeof categoryMismatches = {};
for (const catCol of mappedCategoryColumns) {
const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol);
if (!valuesResponse.success || !valuesResponse.data) continue;
const validValues = valuesResponse.data as Array<{
valueCode: string;
valueLabel: string;
}>;
// 유효한 코드와 라벨 Set 생성
const validCodes = new Set(validValues.map((v) => v.valueCode));
const validLabels = new Set(validValues.map((v) => v.valueLabel));
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
// 엑셀 데이터에서 유효하지 않은 값 수집
const invalidMap = new Map<string, number[]>();
allData.forEach((row, rowIdx) => {
const val = row[catCol.excelCol];
if (val === undefined || val === null || String(val).trim() === "") return;
const strVal = String(val).trim();
// 코드 매칭 → 라벨 매칭 → 소문자 라벨 매칭
if (validCodes.has(strVal)) return;
if (validLabels.has(strVal)) return;
if (validLabelsLower.has(strVal.toLowerCase())) return;
if (!invalidMap.has(strVal)) {
invalidMap.set(strVal, []);
}
invalidMap.get(strVal)!.push(rowIdx);
});
if (invalidMap.size > 0) {
const options = validValues.map((v) => ({
code: v.valueCode,
label: v.valueLabel,
}));
mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map(
([invalidValue, rowIndices]) => ({
invalidValue,
replacement: null,
validOptions: options,
rowIndices,
})
);
}
}
if (Object.keys(mismatches).length > 0) {
return mismatches;
}
return null;
} catch (error) {
console.error("카테고리 검증 실패:", error);
return null;
} finally {
setIsCategoryValidating(false);
}
};
// 카테고리 대체값 선택 후 데이터에 적용
const applyCategoryReplacements = () => {
// 모든 대체값이 선택되었는지 확인
for (const [key, items] of Object.entries(categoryMismatches)) {
for (const item of items) {
if (item.replacement === null) {
toast.error("모든 항목의 대체 값을 선택해주세요.");
return false;
}
}
}
// 엑셀 컬럼명 → 시스템 컬럼명 매핑 구축
const systemToExcelMap = new Map<string, string>();
for (const mapping of columnMappings) {
if (!mapping.systemColumn) continue;
const rawName = mapping.systemColumn.includes(".")
? mapping.systemColumn.split(".")[1]
: mapping.systemColumn;
systemToExcelMap.set(rawName, mapping.excelColumn);
}
const newData = allData.map((row) => ({ ...row }));
for (const [key, items] of Object.entries(categoryMismatches)) {
const systemCol = key.split("|||")[0];
const excelCol = systemToExcelMap.get(systemCol);
if (!excelCol) continue;
for (const item of items) {
if (!item.replacement) continue;
// 선택된 대체값의 라벨 찾기
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
const replacementLabel = selectedOption?.label || item.replacement;
for (const rowIdx of item.rowIndices) {
if (newData[rowIdx]) {
newData[rowIdx][excelCol] = replacementLabel;
}
}
}
}
setAllData(newData);
setDisplayData(newData);
setShowCategoryValidation(false);
setCategoryMismatches({});
toast.success("카테고리 값이 대체되었습니다.");
setCurrentStep(3);
return true;
};
// 다음 단계
const handleNext = async () => {
const handleNext = () => {
if (currentStep === 1 && !file) {
toast.error("파일을 선택해주세요.");
return;
@ -840,7 +655,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
}
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증 + 카테고리 검증
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증
if (currentStep === 2) {
// 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장)
const mappedSystemCols = new Set<string>();
@ -866,14 +681,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
toast.error(`필수(NOT NULL) 컬럼이 매핑되지 않았습니다: ${colNames}`);
return;
}
// 카테고리 컬럼 검증
const mismatches = await validateCategoryColumns();
if (mismatches) {
setCategoryMismatches(mismatches);
setShowCategoryValidation(true);
return;
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3));
@ -1301,17 +1108,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setSystemColumns([]);
setColumnMappings([]);
setDuplicateAction("skip");
// 카테고리 검증 초기화
setShowCategoryValidation(false);
setCategoryMismatches({});
setIsCategoryValidating(false);
// 🆕 마스터-디테일 모드 초기화
setMasterFieldValues({});
}
}, [open]);
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
@ -1948,17 +1750,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{currentStep < 3 ? (
<Button
onClick={handleNext}
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
disabled={isUploading || (currentStep === 1 && !file)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isCategoryValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"다음"
)}
</Button>
) : (
<Button
@ -1974,112 +1769,5 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</DialogFooter>
</DialogContent>
</Dialog>
{/* 카테고리 대체값 선택 다이얼로그 */}
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
if (!open) {
setShowCategoryValidation(false);
setCategoryMismatches({});
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<AlertCircle className="h-5 w-5 text-warning" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
{Object.entries(categoryMismatches).map(([key, items]) => {
const [columnName, displayName] = key.split("|||");
return (
<div key={key} className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">
{displayName || columnName}
</h4>
{items.map((item, idx) => (
<div
key={`${key}-${idx}`}
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
>
<div className="flex flex-col">
<span className="text-xs font-medium text-destructive line-through">
{item.invalidValue}
</span>
<span className="text-[10px] text-muted-foreground">
{item.rowIndices.length}
</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Select
value={item.replacement || ""}
onValueChange={(val) => {
setCategoryMismatches((prev) => {
const updated = { ...prev };
updated[key] = updated[key].map((it, i) =>
i === idx ? { ...it, replacement: val } : it
);
return updated;
});
}}
>
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder="대체 값 선택" />
</SelectTrigger>
<SelectContent>
{item.validOptions.map((opt) => (
<SelectItem
key={opt.code}
value={opt.code}
className="text-xs sm:text-sm"
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
);
})}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowCategoryValidation(false);
setCategoryMismatches({});
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="outline"
onClick={() => {
setShowCategoryValidation(false);
setCategoryMismatches({});
setCurrentStep(3);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={applyCategoryReplacements}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -19,11 +19,12 @@ import {
User,
Building2,
FileCheck,
Monitor,
} from "lucide-react";
import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth";
import { useProfile } from "@/hooks/useProfile";
import { MenuItem } from "@/lib/api/menu";
import { MenuItem, menuApi } from "@/lib/api/menu";
import { menuScreenApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
@ -400,6 +401,30 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
};
// POP 모드 진입 핸들러
const handlePopModeClick = async () => {
try {
const response = await menuApi.getPopMenus();
if (response.success && response.data) {
const { childMenus, landingMenu } = response.data;
if (landingMenu?.menu_url) {
router.push(landingMenu.menu_url);
} else if (childMenus.length === 0) {
toast.info("설정된 POP 화면이 없습니다");
} else if (childMenus.length === 1) {
router.push(childMenus[0].menu_url);
} else {
router.push("/pop");
}
} else {
toast.info("설정된 POP 화면이 없습니다");
}
} catch (error) {
toast.error("POP 메뉴 조회 중 오류가 발생했습니다");
}
};
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
@ -529,6 +554,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
@ -702,6 +731,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />

View File

@ -6,13 +6,14 @@ interface MainHeaderProps {
user: any;
onSidebarToggle: () => void;
onProfileClick: () => void;
onPopModeClick?: () => void;
onLogout: () => void;
}
/**
*
*/
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
export function MainHeader({ user, onSidebarToggle, onProfileClick, onPopModeClick, onLogout }: MainHeaderProps) {
return (
<header className="bg-background/95 fixed top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
<div className="flex h-full w-full items-center justify-between px-6">
@ -27,7 +28,7 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }:
{/* Right side - Admin Button + User Menu */}
<div className="flex h-8 items-center gap-2">
<UserDropdown user={user} onProfileClick={onProfileClick} onLogout={onLogout} />
<UserDropdown user={user} onProfileClick={onProfileClick} onPopModeClick={onPopModeClick} onLogout={onLogout} />
</div>
</div>
</header>

View File

@ -1,4 +1,4 @@
import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog, GitBranch, Bot } from "lucide-react";
import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog, GitBranch } from "lucide-react";
import { cn } from "@/lib/utils";
import { MenuItem } from "@/types/menu";
import { MENU_ICONS, MESSAGES } from "@/constants/layout";
@ -38,9 +38,6 @@ const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
if (MENU_ICONS.DATAFLOW.some((keyword) => menuName.includes(keyword))) {
return <GitBranch className="h-4 w-4" />;
}
if (MENU_ICONS.AI.some((keyword) => menuName.includes(keyword))) {
return <Bot className="h-4 w-4" />;
}
return <FileText className="h-4 w-4" />;
};

View File

@ -8,19 +8,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LogOut, User, FileCheck } from "lucide-react";
import { LogOut, FileCheck, Monitor, User } from "lucide-react";
import { useRouter } from "next/navigation";
interface UserDropdownProps {
user: any;
onProfileClick: () => void;
onPopModeClick?: () => void;
onLogout: () => void;
}
/**
*
*/
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) {
const router = useRouter();
if (!user) return null;
@ -73,7 +74,6 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
? `${user.deptName}, ${user.positionName}`
: user.deptName || user.positionName || "부서 정보 없음"}
</p>
{/* 사진 상태 표시 */}
</div>
</div>
</DropdownMenuLabel>
@ -86,6 +86,12 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
{onPopModeClick && (
<DropdownMenuItem onClick={onPopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />

View File

@ -18,7 +18,6 @@ interface AutoConfigPanelProps {
config?: any;
onChange: (config: any) => void;
isPreview?: boolean;
tableName?: string;
}
interface TableInfo {
@ -38,7 +37,6 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
config = {},
onChange,
isPreview = false,
tableName,
}) => {
// 1. 순번 (자동 증가)
if (partType === "sequence") {
@ -163,18 +161,6 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
);
}
// 6. 참조 (마스터-디테일 분번)
if (partType === "reference") {
return (
<ReferenceConfigSection
config={config}
onChange={onChange}
isPreview={isPreview}
tableName={tableName}
/>
);
}
return null;
};
@ -1102,94 +1088,3 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
</div>
);
};
function ReferenceConfigSection({
config,
onChange,
isPreview,
tableName,
}: {
config: any;
onChange: (c: any) => void;
isPreview: boolean;
tableName?: string;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingCols, setLoadingCols] = useState(false);
useEffect(() => {
if (!tableName) return;
setLoadingCols(true);
const loadEntityColumns = async () => {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(
`/screen-management/tables/${tableName}/columns`
);
const allCols = response.data?.data || response.data || [];
const entityCols = allCols.filter(
(c: any) =>
(c.inputType || c.input_type) === "entity" ||
(c.inputType || c.input_type) === "numbering"
);
setColumns(
entityCols.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName:
c.columnLabel || c.column_label || c.columnName || c.column_name,
dataType: c.dataType || c.data_type || "",
inputType: c.inputType || c.input_type || "",
}))
);
} catch {
setColumns([]);
} finally {
setLoadingCols(false);
}
};
loadEntityColumns();
}, [tableName]);
return (
<div className="space-y-3">
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.referenceColumnName || ""}
onValueChange={(value) =>
onChange({ ...config, referenceColumnName: value })
}
disabled={isPreview || loadingCols}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue
placeholder={
loadingCols
? "로딩 중..."
: columns.length === 0
? "엔티티 컬럼 없음"
: "컬럼 선택"
}
/>
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
/
</p>
</div>
</div>
);
}

View File

@ -16,7 +16,6 @@ interface NumberingRuleCardProps {
onUpdate: (updates: Partial<NumberingRulePart>) => void;
onDelete: () => void;
isPreview?: boolean;
tableName?: string;
}
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
@ -24,7 +23,6 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
onUpdate,
onDelete,
isPreview = false,
tableName,
}) => {
return (
<Card className="border-border bg-card flex-1">
@ -59,7 +57,6 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "CODE" },
category: { categoryKey: "", categoryMappings: [] },
reference: { referenceColumnName: "" },
};
onUpdate({
partType: newPartType,
@ -108,7 +105,6 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
config={part.autoConfig}
onChange={(autoConfig) => onUpdate({ autoConfig })}
isPreview={isPreview}
tableName={tableName}
/>
) : (
<ManualConfigPanel

View File

@ -1,30 +1,36 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, FolderTree } from "lucide-react";
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { apiClient } from "@/lib/api/client";
import {
saveNumberingRuleToTest,
deleteNumberingRuleFromTest,
getNumberingRulesFromTest,
} from "@/lib/api/numberingRule";
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
interface NumberingColumn {
tableName: string;
tableLabel: string;
columnName: string;
columnLabel: string;
}
interface GroupedColumns {
tableLabel: string;
columns: NumberingColumn[];
// 카테고리 값 트리 노드 타입
interface CategoryValueNode {
valueId: number;
valueCode: string;
valueLabel: string;
depth: number;
path: string;
parentValueId: number | null;
children?: CategoryValueNode[];
}
interface NumberingRuleDesignerProps {
@ -48,100 +54,138 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
currentTableName,
menuObjid,
}) => {
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [loading, setLoading] = useState(false);
const [columnSearch, setColumnSearch] = useState("");
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록");
const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 좌측: 채번 타입 컬럼 목록 로드
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
interface CategoryOption {
tableName: string;
columnName: string;
displayName: string; // "테이블명.컬럼명" 형식
}
const [allCategoryOptions, setAllCategoryOptions] = useState<CategoryOption[]>([]);
const [selectedCategoryKey, setSelectedCategoryKey] = useState<string>(""); // "tableName.columnName"
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
const [categoryValueOpen, setCategoryValueOpen] = useState(false);
const [loadingCategories, setLoadingCategories] = useState(false);
useEffect(() => {
loadNumberingColumns();
loadRules();
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
}, []);
const loadNumberingColumns = async () => {
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화
useEffect(() => {
if (currentRule?.categoryColumn) {
setSelectedCategoryKey(currentRule.categoryColumn);
} else {
setSelectedCategoryKey("");
}
}, [currentRule?.categoryColumn]);
// 카테고리 키 선택 시 해당 카테고리 값 로드
useEffect(() => {
if (selectedCategoryKey) {
const [tableName, columnName] = selectedCategoryKey.split(".");
if (tableName && columnName) {
loadCategoryValues(tableName, columnName);
}
} else {
setCategoryValues([]);
}
}, [selectedCategoryKey]);
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
const loadAllCategoryOptions = async () => {
try {
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
const response = await getAllCategoryKeys();
if (response.success && response.data) {
const options: CategoryOption[] = response.data.map((item) => ({
tableName: item.tableName,
columnName: item.columnName,
displayName: `${item.tableName}.${item.columnName}`,
}));
setAllCategoryOptions(options);
console.log("전체 카테고리 옵션 로드:", options);
}
} catch (error) {
console.error("카테고리 옵션 목록 조회 실패:", error);
}
};
// 특정 카테고리 컬럼의 값 트리 조회
const loadCategoryValues = async (tableName: string, columnName: string) => {
setLoadingCategories(true);
try {
const response = await getCategoryTree(tableName, columnName);
if (response.success && response.data) {
setCategoryValues(response.data);
console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length });
} else {
setCategoryValues([]);
}
} catch (error) {
console.error("카테고리 값 트리 조회 실패:", error);
setCategoryValues([]);
} finally {
setLoadingCategories(false);
}
};
// 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용)
const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => {
for (const node of nodes) {
result.push(node);
if (node.children && node.children.length > 0) {
flattenCategoryValues(node.children, result);
}
}
return result;
};
const flatCategoryValues = flattenCategoryValues(categoryValues);
const loadRules = useCallback(async () => {
setLoading(true);
try {
const response = await apiClient.get("/table-management/numbering-columns");
if (response.data.success && response.data.data) {
setNumberingColumns(response.data.data);
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", {
menuObjid,
hasMenuObjid: !!menuObjid,
});
// test 테이블에서 조회
const response = await getNumberingRulesFromTest(menuObjid);
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", {
menuObjid,
success: response.success,
rulesCount: response.data?.length || 0,
rules: response.data,
});
if (response.success && response.data) {
setSavedRules(response.data);
} else {
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
}
} catch (error: any) {
console.error("채번 컬럼 목록 로드 실패:", error);
toast.error(`로딩 실패: ${error.message}`);
} finally {
setLoading(false);
}
};
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
const handleSelectColumn = async (tableName: string, columnName: string) => {
setSelectedColumn({ tableName, columnName });
setLoading(true);
try {
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
if (response.data.success && response.data.data) {
const rule = response.data.data as NumberingRuleConfig;
setCurrentRule(JSON.parse(JSON.stringify(rule)));
} else {
// 규칙 없으면 신규 생성 모드
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
}
} catch {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
} finally {
setLoading(false);
}
};
// 테이블별로 그룹화
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
if (!acc[col.tableName]) {
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
}
acc[col.tableName].columns.push(col);
return acc;
}, {});
// 검색 필터 적용
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
if (!columnSearch) return true;
const search = columnSearch.toLowerCase();
return (
tableName.toLowerCase().includes(search) ||
group.tableLabel.toLowerCase().includes(search) ||
group.columns.some(
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
)
);
});
}, [menuObjid]);
useEffect(() => {
if (currentRule) {
@ -299,20 +343,60 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return part;
});
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global");
const ruleToSave = {
...currentRule,
parts: partsWithDefaults,
scopeType: "table" as const,
tableName: selectedColumn?.tableName || currentRule.tableName || "",
columnName: selectedColumn?.columnName || currentRule.columnName || "",
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준)
};
console.log("💾 채번 규칙 저장:", {
currentTableName,
menuObjid,
"currentRule.tableName": currentRule.tableName,
"currentRule.menuObjid": currentRule.menuObjid,
"ruleToSave.tableName": ruleToSave.tableName,
"ruleToSave.menuObjid": ruleToSave.menuObjid,
"ruleToSave.scopeType": ruleToSave.scopeType,
ruleToSave,
});
// 테스트 테이블에 저장 (numbering_rules)
const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) {
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
// setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지)
setSavedRules((prev) => {
const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId);
console.log("🔍 [handleSave] setSavedRules:", {
ruleId: ruleToSave.ruleId,
existsInPrev,
prevCount: prev.length,
});
if (existsInPrev) {
// 기존 규칙 업데이트
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r));
} else {
// 새 규칙 추가
return [...prev, savedData];
}
});
setCurrentRule(currentData);
setSelectedRuleId(response.data.ruleId);
await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다");
} else {
@ -323,62 +407,143 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} finally {
setLoading(false);
}
}, [currentRule, onSave, selectedColumn]);
}, [currentRule, onSave, currentTableName, menuObjid]);
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
console.log("🔍 [handleSelectRule] 규칙 선택:", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
partsCount: rule.parts?.length || 0,
parts: rule.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
});
setSelectedRuleId(rule.ruleId);
// 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록)
const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig;
console.log("🔍 [handleSelectRule] 깊은 복사 후:", {
ruleId: ruleCopy.ruleId,
partsCount: ruleCopy.parts?.length || 0,
parts: ruleCopy.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
});
setCurrentRule(ruleCopy);
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
}, []);
const handleDeleteSavedRule = useCallback(
async (ruleId: string) => {
setLoading(true);
try {
const response = await deleteNumberingRuleFromTest(ruleId);
if (response.success) {
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
if (selectedRuleId === ruleId) {
setSelectedRuleId(null);
setCurrentRule(null);
}
toast.success("규칙이 삭제되었습니다");
} else {
showErrorToast("채번 규칙 삭제에 실패했습니다", response.error, { guidance: "잠시 후 다시 시도해 주세요." });
}
} catch (error: any) {
showErrorToast("채번 규칙 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally {
setLoading(false);
}
},
[selectedRuleId],
);
const handleNewRule = useCallback(() => {
console.log("📋 새 규칙 생성:", { currentTableName, menuObjid });
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 채번 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
};
console.log("📋 생성된 규칙 정보:", newRule);
setSelectedRuleId(newRule.ruleId);
setCurrentRule(newRule);
toast.success("새 규칙이 생성되었습니다");
}, [currentTableName, menuObjid]);
return (
<div className={`flex h-full gap-4 ${className}`}>
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
<h2 className="text-sm font-semibold sm:text-base"> </h2>
{/* 좌측: 저장된 규칙 목록 */}
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
<div className="flex items-center justify-between">
{editingLeftTitle ? (
<Input
value={columnSearch}
onChange={(e) => setColumnSearch(e.target.value)}
placeholder="검색..."
className="h-8 text-xs"
value={leftTitle}
onChange={(e) => setLeftTitle(e.target.value)}
onBlur={() => setEditingLeftTitle(false)}
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
className="h-8 text-sm font-semibold"
autoFocus
/>
) : (
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
)}
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
<Edit2 className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 space-y-1 overflow-y-auto">
{loading && numberingColumns.length === 0 ? (
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
<div className="flex-1 space-y-2 overflow-y-auto">
{loading ? (
<div className="flex h-32 items-center justify-center">
<p className="text-muted-foreground text-xs"> ...</p>
</div>
) : filteredGroups.length === 0 ? (
) : savedRules.length === 0 ? (
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-xs">
{numberingColumns.length === 0
? "채번 타입 컬럼이 없습니다"
: "검색 결과가 없습니다"}
</p>
<p className="text-muted-foreground text-xs"> </p>
</div>
) : (
filteredGroups.map(([tableName, group]) => (
<div key={tableName} className="mb-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
<FolderTree className="h-3 w-3" />
<span>{group.tableLabel}</span>
<span className="text-muted-foreground/60">({group.columns.length})</span>
</div>
{group.columns.map((col) => {
const isSelected =
selectedColumn?.tableName === col.tableName &&
selectedColumn?.columnName === col.columnName;
return (
<div
key={`${col.tableName}.${col.columnName}`}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
isSelected
? "bg-primary/10 text-primary border-primary border font-medium"
: "hover:bg-accent"
)}
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
savedRules.map((rule) => (
<Card
key={rule.ruleId}
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
}`}
onClick={() => handleSelectRule(rule)}
>
{col.columnLabel}
<CardHeader className="px-3 py-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
</div>
);
})}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteSavedRule(rule.ruleId);
}}
>
<Trash2 className="text-destructive h-3 w-3" />
</Button>
</div>
</CardHeader>
</Card>
))
)}
</div>
@ -392,9 +557,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
{!currentRule ? (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-center">
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
) : (
@ -460,7 +624,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview}
tableName={selectedColumn?.tableName}
/>
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
{index < currentRule.parts.length - 1 && (

View File

@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { Moon, Sun } from "lucide-react";
import { Moon, Sun, Monitor } from "lucide-react";
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
interface DashboardHeaderProps {
@ -11,6 +11,7 @@ interface DashboardHeaderProps {
company: CompanyInfo;
onThemeToggle: () => void;
onUserClick: () => void;
onPcModeClick?: () => void;
}
export function DashboardHeader({
@ -20,6 +21,7 @@ export function DashboardHeader({
company,
onThemeToggle,
onUserClick,
onPcModeClick,
}: DashboardHeaderProps) {
const [mounted, setMounted] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
@ -81,6 +83,17 @@ export function DashboardHeader({
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
</div>
{/* PC 모드 복귀 */}
{onPcModeClick && (
<button
className="pop-dashboard-theme-toggle"
onClick={onPcModeClick}
title="PC 모드로 돌아가기"
>
<Monitor size={16} />
</button>
)}
{/* 사용자 배지 */}
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
<div className="pop-dashboard-user-avatar">{user.avatar}</div>

View File

@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "./DashboardHeader";
import { NoticeBanner } from "./NoticeBanner";
import { KpiBar } from "./KpiBar";
@ -8,6 +9,8 @@ import { MenuGrid } from "./MenuGrid";
import { ActivityList } from "./ActivityList";
import { NoticeList } from "./NoticeList";
import { DashboardFooter } from "./DashboardFooter";
import { MenuItem as DashboardMenuItem } from "./types";
import { menuApi, PopMenuItem } from "@/lib/api/menu";
import {
KPI_ITEMS,
MENU_ITEMS,
@ -17,10 +20,31 @@ import {
} from "./data";
import "./dashboard.css";
export function PopDashboard() {
const [theme, setTheme] = useState<"dark" | "light">("dark");
const CATEGORY_COLORS: DashboardMenuItem["category"][] = [
"production",
"material",
"quality",
"equipment",
"safety",
];
function convertPopMenuToMenuItem(item: PopMenuItem, index: number): DashboardMenuItem {
return {
id: item.objid,
title: item.menu_name_kor,
count: 0,
description: item.menu_desc?.replace("[POP]", "").trim() || "",
status: "",
category: CATEGORY_COLORS[index % CATEGORY_COLORS.length],
href: item.menu_url || "#",
};
}
export function PopDashboard() {
const router = useRouter();
const [theme, setTheme] = useState<"dark" | "light">("dark");
const [menuItems, setMenuItems] = useState<DashboardMenuItem[]>(MENU_ITEMS);
// 로컬 스토리지에서 테마 로드
useEffect(() => {
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
if (savedTheme) {
@ -28,6 +52,22 @@ export function PopDashboard() {
}
}, []);
// API에서 POP 메뉴 로드
useEffect(() => {
const loadPopMenus = async () => {
try {
const response = await menuApi.getPopMenus();
if (response.success && response.data && response.data.childMenus.length > 0) {
const converted = response.data.childMenus.map(convertPopMenuToMenuItem);
setMenuItems(converted);
}
} catch {
// API 실패 시 기존 하드코딩 데이터 유지
}
};
loadPopMenus();
}, []);
const handleThemeToggle = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
@ -40,6 +80,10 @@ export function PopDashboard() {
}
};
const handlePcModeClick = () => {
router.push("/");
};
const handleActivityMore = () => {
alert("전체 활동 내역 화면으로 이동합니다.");
};
@ -58,13 +102,14 @@ export function PopDashboard() {
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
onThemeToggle={handleThemeToggle}
onUserClick={handleUserClick}
onPcModeClick={handlePcModeClick}
/>
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
<KpiBar items={KPI_ITEMS} />
<MenuGrid items={MENU_ITEMS} />
<MenuGrid items={menuItems} />
<div className="pop-dashboard-bottom-section">
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />

View File

@ -3,7 +3,7 @@
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
@ -69,6 +69,18 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: TextCursorInput,
description: "저장용 값 입력 (섹션별 멀티필드)",
},
{
type: "pop-scanner",
label: "스캐너",
icon: ScanLine,
description: "바코드/QR 카메라 스캔",
},
{
type: "pop-profile",
label: "프로필",
icon: UserCircle,
description: "사용자 프로필 / PC 전환 / 로그아웃",
},
];
// 드래그 가능한 컴포넌트 아이템

View File

@ -1,11 +1,9 @@
"use client";
import React from "react";
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@ -19,9 +17,7 @@ import {
} from "../types/pop-layout";
import {
PopComponentRegistry,
type ComponentConnectionMeta,
} from "@/lib/registry/PopComponentRegistry";
import { getTableColumns } from "@/lib/api/tableManagement";
// ========================================
// Props
@ -36,15 +32,6 @@ interface ConnectionEditorProps {
onRemoveConnection?: (connectionId: string) => void;
}
// ========================================
// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
// ========================================
function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
if (!meta?.sendable) return false;
return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
}
// ========================================
// ConnectionEditor
// ========================================
@ -84,17 +71,13 @@ export default function ConnectionEditor({
);
}
const isFilterSource = hasFilterSendable(meta);
return (
<div className="space-y-6">
{hasSendable && (
<SendSection
component={component}
meta={meta!}
allComponents={allComponents}
outgoing={outgoing}
isFilterSource={isFilterSource}
onAddConnection={onAddConnection}
onUpdateConnection={onUpdateConnection}
onRemoveConnection={onRemoveConnection}
@ -112,47 +95,14 @@ export default function ConnectionEditor({
);
}
// ========================================
// 대상 컴포넌트에서 정보 추출
// ========================================
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
if (!comp?.config) return [];
const cfg = comp.config as Record<string, unknown>;
const cols: string[] = [];
if (Array.isArray(cfg.listColumns)) {
(cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => {
if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName);
});
}
if (Array.isArray(cfg.selectedColumns)) {
(cfg.selectedColumns as string[]).forEach((c) => {
if (!cols.includes(c)) cols.push(c);
});
}
return cols;
}
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
if (!comp?.config) return "";
const cfg = comp.config as Record<string, unknown>;
const ds = cfg.dataSource as { tableName?: string } | undefined;
return ds?.tableName || "";
}
// ========================================
// 보내기 섹션
// ========================================
interface SendSectionProps {
component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
outgoing: PopDataConnection[];
isFilterSource: boolean;
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
onRemoveConnection?: (connectionId: string) => void;
@ -160,10 +110,8 @@ interface SendSectionProps {
function SendSection({
component,
meta,
allComponents,
outgoing,
isFilterSource,
onAddConnection,
onUpdateConnection,
onRemoveConnection,
@ -180,20 +128,6 @@ function SendSection({
{outgoing.map((conn) => (
<div key={conn.id}>
{editingId === conn.id ? (
isFilterSource ? (
<FilterConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
) : (
<SimpleConnectionForm
component={component}
allComponents={allComponents}
@ -205,7 +139,6 @@ function SendSection({
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
)
) : (
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
<span className="flex-1 truncate text-xs">
@ -230,22 +163,12 @@ function SendSection({
</div>
))}
{isFilterSource ? (
<FilterConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
) : (
<SimpleConnectionForm
component={component}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
)}
</div>
);
}
@ -350,328 +273,6 @@ function SimpleConnectionForm({
);
}
// ========================================
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
// ========================================
interface FilterConnectionFormProps {
component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
initial?: PopDataConnection;
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
onCancel?: () => void;
submitLabel: string;
}
function FilterConnectionForm({
component,
meta,
allComponents,
initial,
onSubmit,
onCancel,
submitLabel,
}: FilterConnectionFormProps) {
const [selectedOutput, setSelectedOutput] = React.useState(
initial?.sourceOutput || meta.sendable[0]?.key || ""
);
const [selectedTargetId, setSelectedTargetId] = React.useState(
initial?.targetComponent || ""
);
const [selectedTargetInput, setSelectedTargetInput] = React.useState(
initial?.targetInput || ""
);
const [filterColumns, setFilterColumns] = React.useState<string[]>(
initial?.filterConfig?.targetColumns ||
(initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : [])
);
const [filterMode, setFilterMode] = React.useState<
"equals" | "contains" | "starts_with" | "range"
>(initial?.filterConfig?.filterMode || "contains");
const targetCandidates = allComponents.filter((c) => {
if (c.id === component.id) return false;
const reg = PopComponentRegistry.getComponent(c.type);
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
});
const targetComp = selectedTargetId
? allComponents.find((c) => c.id === selectedTargetId)
: null;
const targetMeta = targetComp
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
: null;
React.useEffect(() => {
if (!selectedOutput || !targetMeta?.receivable?.length) return;
if (selectedTargetInput) return;
const receivables = targetMeta.receivable;
const exactMatch = receivables.find((r) => r.key === selectedOutput);
if (exactMatch) {
setSelectedTargetInput(exactMatch.key);
return;
}
if (receivables.length === 1) {
setSelectedTargetInput(receivables[0].key);
}
}, [selectedOutput, targetMeta, selectedTargetInput]);
const displayColumns = React.useMemo(
() => extractDisplayColumns(targetComp || undefined),
[targetComp]
);
const tableName = React.useMemo(
() => extractTableName(targetComp || undefined),
[targetComp]
);
const [allDbColumns, setAllDbColumns] = React.useState<string[]>([]);
const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
React.useEffect(() => {
if (!tableName) {
setAllDbColumns([]);
return;
}
let cancelled = false;
setDbColumnsLoading(true);
getTableColumns(tableName).then((res) => {
if (cancelled) return;
if (res.success && res.data?.columns) {
setAllDbColumns(res.data.columns.map((c) => c.columnName));
} else {
setAllDbColumns([]);
}
setDbColumnsLoading(false);
});
return () => { cancelled = true; };
}, [tableName]);
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
const dataOnlyColumns = React.useMemo(
() => allDbColumns.filter((c) => !displaySet.has(c)),
[allDbColumns, displaySet]
);
const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
const toggleColumn = (col: string) => {
setFilterColumns((prev) =>
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
);
};
const handleSubmit = () => {
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
onSubmit({
sourceComponent: component.id,
sourceField: "",
sourceOutput: selectedOutput,
targetComponent: selectedTargetId,
targetField: "",
targetInput: selectedTargetInput,
filterConfig:
!isEvent && filterColumns.length > 0
? {
targetColumn: filterColumns[0],
targetColumns: filterColumns,
filterMode,
}
: undefined,
label: buildConnectionLabel(
component,
selectedOutput,
allComponents.find((c) => c.id === selectedTargetId),
selectedTargetInput,
filterColumns
),
});
if (!initial) {
setSelectedTargetId("");
setSelectedTargetInput("");
setFilterColumns([]);
}
};
return (
<div className="space-y-2 rounded border border-dashed p-3">
{onCancel && (
<div className="flex items-center justify-between">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
</div>
)}
{!onCancel && (
<p className="text-[10px] font-medium text-muted-foreground"> </p>
)}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{meta.sendable.map((s) => (
<SelectItem key={s.key} value={s.key} className="text-xs">
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select
value={selectedTargetId}
onValueChange={(v) => {
setSelectedTargetId(v);
setSelectedTargetInput("");
setFilterColumns([]);
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{targetCandidates.map((c) => (
<SelectItem key={c.id} value={c.id} className="text-xs">
{c.label || c.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{targetMeta && (
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{targetMeta.receivable.map((r) => (
<SelectItem key={r.key} value={r.key} className="text-xs">
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
<div className="space-y-2 rounded bg-gray-50 p-2">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
{dbColumnsLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : hasAnyColumns ? (
<div className="space-y-2">
{displayColumns.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-green-600"> </p>
{displayColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs"
>
{col}
</label>
</div>
))}
</div>
)}
{dataOnlyColumns.length > 0 && (
<div className="space-y-1">
{displayColumns.length > 0 && (
<div className="my-1 h-px bg-gray-200" />
)}
<p className="text-[9px] font-medium text-amber-600"> </p>
{dataOnlyColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs text-muted-foreground"
>
{col}
</label>
</div>
))}
</div>
)}
</div>
) : (
<Input
value={filterColumns[0] || ""}
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
placeholder="컬럼명 입력"
className="h-7 text-xs"
/>
)}
{filterColumns.length > 0 && (
<p className="text-[10px] text-blue-600">
{filterColumns.length}
</p>
)}
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground"> </p>
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contains" className="text-xs"></SelectItem>
<SelectItem value="equals" className="text-xs"></SelectItem>
<SelectItem value="starts_with" className="text-xs"></SelectItem>
<SelectItem value="range" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<Button
size="sm"
variant="outline"
className="h-7 w-full text-xs"
disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput}
onClick={handleSubmit}
>
{!initial && <Plus className="mr-1 h-3 w-3" />}
{submitLabel}
</Button>
</div>
);
}
// ========================================
// 받기 섹션 (읽기 전용: 연결된 소스만 표시)
// ========================================
@ -722,32 +323,3 @@ function ReceiveSection({
);
}
// ========================================
// 유틸
// ========================================
function isEventTypeConnection(
sourceMeta: ComponentConnectionMeta | undefined,
outputKey: string,
targetMeta: ComponentConnectionMeta | null | undefined,
inputKey: string,
): boolean {
const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey);
const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey);
return sourceItem?.type === "event" || targetItem?.type === "event";
}
function buildConnectionLabel(
source: PopComponentDefinitionV5,
_outputKey: string,
target: PopComponentDefinitionV5 | undefined,
_inputKey: string,
columns?: string[]
): string {
const srcLabel = source.label || source.id;
const tgtLabel = target?.label || target?.id || "?";
const colInfo = columns && columns.length > 0
? ` [${columns.join(", ")}]`
: "";
return `${srcLabel}${tgtLabel}${colInfo}`;
}

View File

@ -76,6 +76,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-string-list": "리스트 목록",
"pop-search": "검색",
"pop-field": "입력",
"pop-profile": "프로필",
};
// ========================================

View File

@ -9,7 +9,7 @@
/**
* POP
*/
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field";
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field" | "pop-scanner" | "pop-profile";
/**
*
@ -360,8 +360,10 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
"pop-card-list": { colSpan: 4, rowSpan: 3 },
"pop-button": { colSpan: 2, rowSpan: 1 },
"pop-string-list": { colSpan: 4, rowSpan: 3 },
"pop-search": { colSpan: 4, rowSpan: 2 },
"pop-search": { colSpan: 2, rowSpan: 1 },
"pop-field": { colSpan: 6, rowSpan: 2 },
"pop-scanner": { colSpan: 1, rowSpan: 1 },
"pop-profile": { colSpan: 1, rowSpan: 1 },
};
/**

View File

@ -18,7 +18,7 @@ import { useAuth } from "@/hooks/useAuth";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { entityJoinApi } from "@/lib/api/entityJoin";
interface EditModalState {
isOpen: boolean;
screenId: number | null;
@ -244,92 +244,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
};
/**
* FK를
* - entity join FK
* - FK row
* - editData에 ( )
*/
const loadMasterDataForDetailRow = async (
editData: Record<string, any>,
targetScreenId: number,
eventTableName?: string,
): Promise<Record<string, any>> => {
try {
let detailTableName = eventTableName;
if (!detailTableName) {
const screenInfo = await screenApi.getScreen(targetScreenId);
detailTableName = screenInfo?.tableName;
}
if (!detailTableName) {
console.log("[EditModal:MasterLoad] 테이블명을 알 수 없음 - 스킵");
return {};
}
console.log("[EditModal:MasterLoad] 시작:", { detailTableName, editDataKeys: Object.keys(editData) });
const entityJoinRes = await entityJoinApi.getEntityJoinConfigs(detailTableName);
const joinConfigs = entityJoinRes?.joinConfigs || [];
if (joinConfigs.length === 0) {
console.log("[EditModal:MasterLoad] entity join 없음 - 스킵");
return {};
}
console.log("[EditModal:MasterLoad] entity join:", joinConfigs.map((c) => `${c.sourceColumn}${c.referenceTable}`));
const masterDataResult: Record<string, any> = {};
const processedTables = new Set<string>();
const { apiClient } = await import("@/lib/api/client");
for (const joinConfig of joinConfigs) {
const { sourceColumn, referenceTable, referenceColumn } = joinConfig;
if (processedTables.has(referenceTable)) continue;
const fkValue = editData[sourceColumn];
if (!fkValue) continue;
try {
const response = await apiClient.post(
`/table-management/tables/${referenceTable}/data`,
{
search: { [referenceColumn || "id"]: fkValue },
size: 1,
page: 1,
autoFilter: true,
},
);
const rows = response.data?.data?.data || response.data?.data?.rows || [];
if (rows.length > 0) {
const masterRow = rows[0];
for (const [col, val] of Object.entries(masterRow)) {
if (val !== undefined && val !== null && editData[col] === undefined) {
masterDataResult[col] = val;
}
}
console.log("[EditModal:MasterLoad] 조회 성공:", {
table: referenceTable,
fk: `${sourceColumn}=${fkValue}`,
loadedFields: Object.keys(masterDataResult),
});
}
} catch (queryError) {
console.warn("[EditModal:MasterLoad] 조회 실패:", referenceTable, queryError);
}
processedTables.add(referenceTable);
}
console.log("[EditModal:MasterLoad] 최종 결과:", Object.keys(masterDataResult));
return masterDataResult;
} catch (error) {
console.warn("[EditModal:MasterLoad] 전체 오류:", error);
return {};
}
};
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenEditModal = async (event: CustomEvent) => {
@ -380,8 +294,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
});
}
// editData로 formData를 즉시 세팅 (채번 컴포넌트가 빈 formData로 마운트되어 새 번호 생성하는 것 방지)
setFormData(enriched);
// originalData: changedData 계산(PATCH)에만 사용
// INSERT/UPDATE 판단에는 사용하지 않음
@ -390,21 +302,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE
setIsCreateModeFlag(!!isCreateMode);
// 마스터 데이터 자동 조회 (수정 모드일 때, formData 세팅 이후 비동기로 병합)
// 디테일 행 선택 시 마스터 테이블의 컬럼 데이터를 자동으로 가져와서 추가
if (!isCreateMode && editData && screenId) {
loadMasterDataForDetailRow(editData, screenId, tableName)
.then((masterData) => {
if (Object.keys(masterData).length > 0) {
setFormData((prev) => ({ ...prev, ...masterData }));
console.log("[EditModal] 마스터 데이터 비동기 병합 완료:", Object.keys(masterData));
}
})
.catch((masterError) => {
console.warn("[EditModal] 마스터 데이터 자동 조회 중 오류 (무시):", masterError);
});
}
console.log("[EditModal] 모달 열림:", {
mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)",
hasEditData: !!editData,
@ -679,7 +576,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
(targetComponent as any)?.componentConfig?.columnName ||
targetComponentId;
const currentFormData = groupData.length > 0 ? { ...formData, ...groupData[0] } : formData;
const currentFormData = groupData.length > 0 ? groupData[0] : formData;
const targetValue = currentFormData[fieldKey];
let isMatch = false;
@ -1632,10 +1529,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
const enrichedFormData = {
// 마스터 데이터(formData)를 기본으로 깔고, groupData[0]으로 덮어쓰기
// → 디테일 행 수정 시에도 마스터 폼 필드가 표시됨
...formData,
...(groupData.length > 0 ? groupData[0] : {}),
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
@ -1695,8 +1589,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
};
const enrichedFormData = {
...formData,
...(groupData.length > 0 ? groupData[0] : {}),
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};

View File

@ -378,7 +378,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
for (const col of categoryColumns) {
try {
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
const queryParams = menuObjid ? `?menuObjid=${menuObjid}&includeInactive=true` : "?includeInactive=true";
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
const response = await apiClient.get(
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
);

View File

@ -851,7 +851,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 엑셀 관련 */}
<SelectItem value="excel_download"> </SelectItem>
<SelectItem value="excel_upload"> </SelectItem>
<SelectItem value="multi_table_excel_upload"> </SelectItem>
{/* 고급 기능 */}
<SelectItem value="quickInsert"> </SelectItem>
@ -2431,8 +2430,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/>
)}
{/* 다중 테이블 엑셀 업로드: 설정 불필요 (버튼 클릭 시 화면 테이블에서 자동 감지) */}
{/* 바코드 스캔 액션 설정 */}
{localInputs.actionType === "barcode_scan" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
@ -4000,8 +3997,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
)}
</div>
{/* 제어 기능 섹션 - 엑셀 업로드 계열이 아닐 때만 표시 */}
{localInputs.actionType !== "excel_upload" && localInputs.actionType !== "multi_table_excel_upload" && (
{/* 제어 기능 섹션 - 엑셀 업로드 아닐 때만 표시 */}
{localInputs.actionType !== "excel_upload" && (
<div className="border-border mt-8 border-t pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
</div>
@ -4690,4 +4687,3 @@ const ExcelUploadConfigSection: React.FC<{
</div>
);
};

View File

@ -6,7 +6,7 @@
* -
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
ChevronRight,
ChevronDown,
@ -291,10 +291,6 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
// 추가 모달 input ref
const addNameRef = useRef<HTMLInputElement>(null);
const addDescRef = useRef<HTMLInputElement>(null);
// 폼 상태
const [formData, setFormData] = useState({
valueCode: "",
@ -512,15 +508,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const response = await createCategoryValue(input);
if (response.success) {
toast.success("카테고리가 추가되었습니다");
// 폼 초기화 (모달은 닫지 않고 연속 입력)
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
setIsAddModalOpen(false);
// 기존 펼침 상태 유지하면서 데이터 새로고침
await loadTree(true);
// 부모 노드만 펼치기 (하위 추가 시)
@ -758,17 +746,9 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
<span className="text-destructive">*</span>
</Label>
<Input
ref={addNameRef}
id="valueLabel"
value={formData.valueLabel}
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
addDescRef.current?.focus();
}
}}
placeholder="카테고리 이름을 입력하세요"
className="h-9 text-sm"
/>
@ -779,17 +759,9 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</Label>
<Input
ref={addDescRef}
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
handleAdd();
}
}}
placeholder="선택 사항"
className="h-9 text-sm"
/>
@ -812,7 +784,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
onClick={() => setIsAddModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none"
>
</Button>
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">

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