Compare commits

..

13 Commits

Author SHA1 Message Date
kjs c56f434ff1 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-05 19:27:33 +09:00
kjs 536982dd71 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-03-05 19:21:50 +09:00
kjs 0e8c68a9ff feat: Add multi-table Excel upload functionality
- Implemented new API endpoints for multi-table Excel upload and auto-detection of table chains.
- The GET endpoint `/api/data/multi-table/auto-detect` allows automatic detection of foreign key relationships based on the provided root table.
- The POST endpoint `/api/data/multi-table/upload` handles the upload of multi-table data, including validation and logging of the upload process.
- Updated the frontend to include options for multi-table Excel upload in the button configuration panel and integrated the corresponding action handler.

This feature enhances the data management capabilities by allowing users to upload and manage data across multiple related tables efficiently.
2026-03-05 19:17:35 +09:00
chpark bfd97c9717 바코드 업데이트 및 AI LLm 적용용 2026-03-05 19:08:08 +09:00
SeongHyun Kim a81cb7ca19 Merge branch 'ksh-v2-work' 2026-03-05 19:04:54 +09:00
SeongHyun Kim 12a8290873 feat(pop): 설정 패널 아코디언 접기/펼치기 일관성 + sessionStorage 상태 기억
설정 패널을 열 때 섹션이 일부는 펼쳐져 있고 일부는 접혀 있어
일관성이 없던 UX를 개선하고, 사용자가 펼친 섹션을 탭 세션 내에서 기억한다.
- useCollapsibleSections 커스텀 훅 생성 (sessionStorage 기반, 초기 모두 접힘)
- PopCardListConfig: CollapsibleSection에 sectionKey/sections prop 패턴 적용
- PopFieldConfig: SaveTabContent 5개 고정 섹션 훅 적용,
  SectionEditor 초기값 접힘으로 변경
- PopDashboardConfig: PageEditor 초기값 접힘으로 변경
2026-03-05 18:54:29 +09:00
SeongHyun Kim 7a9a705f19 feat(pop-card-list): 포장 요약 바 UI + 카드 레이아웃 flex column 개선
포장 입력 완료 시 카드 하단에 포장 내역 요약을 표시하여
디자이너가 포장 계산 결과를 즉시 확인할 수 있도록 한다.
- 카드 하단에 포장 요약 바 추가 (emerald 테마, 포장완료 뱃지)
- height(고정) -> minHeight(유동)으로 카드 자연 성장 허용
- gridAutoRows를 minmax(높이, auto)로 변경 (그리드 셀도 성장)
- 카드 flex flex-col + 본문 flex-1 overflow-hidden 구조
- 오른쪽 버튼 영역 justify-center -> justify-start (위쪽 정렬)
2026-03-05 18:34:45 +09:00
SeongHyun Kim 85bf4882a8 fix(pop-card-list): 미입고 formula 필드 입력값 연동 복원
설정 UI에서 formulaRightType 기본값을 "input"으로 표시하지만
DB에 명시적으로 저장하지 않아, 렌더링 시 undefined === "input"이
false가 되어 입력필드 연동이 작동하지 않던 버그를 수정한다.
- FieldRow: (field.formulaRightType || "input") === "input"으로
  기본값 fallback 추가
2026-03-05 18:00:17 +09:00
SeongHyun Kim b2b0b575df feat(pop): 버튼 v2 통합 아키텍처 + data-update 연산 확장 (BLOCK M + N)
버튼 컴포넌트의 실행 경로를 프리셋별 파편화에서 단일 작업 목록(task-list)
패턴으로 통합하고, 부분입고 시나리오 지원을 위해 data-update 연산을 확장한다.
[BLOCK M: 버튼 v2 통합 아키텍처]
- ButtonTask 타입 체계 정의 (10종 작업 타입 + UpdateOperation)
- PopButtonConfigV2 + migrateButtonConfig 자동 마이그레이션
- 설정 UI: 빠른 시작 + 외형 + 작업 목록 에디터
- executeTaskList 범용 실행 함수 (데이터 작업 일괄 백엔드 전달)
- collect_data 프로토콜에 cartChanges 포함
- 백엔드 tasks 배열 기반 처리 (data-save/update/delete/cart-save)
- useCartSync.getChanges() 추출 + 카드리스트 응답 포함
[BLOCK N: data-update 연산 확장]
- UpdateOperationType에 multiply, divide, db-conditional 추가
- ButtonTask에 db-conditional 전용 필드 5개 추가
  (compareColumn, compareOperator, compareWith, dbThenValue, dbElseValue)
- 설정 UI: 드롭다운 3개 옵션 + DB 컬럼 비교 설정 폼
- 백엔드 SQL: multiply, divide(0-division 방어),
  db-conditional(CASE WHEN 배치 UPDATE)
- 기존 add/subtract에 ::numeric 캐스팅 일관 적용
2026-03-05 17:22:30 +09:00
kjs f7bd2f6fa3 Merge pull request 'jskim-node' (#402) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/402
2026-03-05 13:32:16 +09:00
SeongHyun Kim 91c9dda6ae feat(pop-field): 숨은 필드 고정값 + Select 데이터 연동(linkedFilters) 구현
입고 확정 시 status/inbound_status가 빈 값으로 저장되는 문제(FIX-3)와
창고내 위치 셀렉트가 전체 위치를 보여주는 문제를 해결한다.
[FIX-3: 숨은 필드 고정값]
- types.ts: HiddenValueSource에 "static" 추가, staticValue 필드
- PopFieldConfig: 숨은 필드 설정 UI에 "고정값" 모드 추가
- PopFieldComponent: collected_data에 hiddenMappings 포함
- popActionRoutes: INSERT 시 hiddenMappings 값 주입
[Select 데이터 연동 - BLOCK L]
- types.ts: SelectLinkedFilter 인터페이스 + FieldSelectSource.linkedFilters
- PopFieldConfig: "데이터 연동" 토글 + LinkedFiltersEditor 컴포넌트
  (섹션 내 필드 선택 → 필터 컬럼 매핑)
- PopFieldComponent: fieldIdToName 맵으로 id-name 변환,
  SelectFieldInput에서 연동 필드 값 변경 시 동적 필터 재조회,
  상위 미선택 시 안내 메시지, 상위 변경 시 하위 자동 초기화
2026-03-05 12:13:07 +09:00
SeongHyun Kim a6c0ab5664 feat(pop): 입고 확정 시 자동 채번 실행 + 결과 모달 UX + 셀렉트 높이 통일
입고 확정(inbound-confirm) 실행 시 채번 규칙이 설정되어 있어도
inbound_number가 null로 저장되던 문제를 해결한다.
[채번 실행 (FIX-1)]
- types.ts: SaveMapping에 autoGenMappings 필드 추가 (numberingRuleId,
  targetColumn, showResultModal)
- PopFieldComponent: collect_data 응답에 autoGenMappings 포함하여
  백엔드에 채번 규칙 정보 전달
- popActionRoutes: INSERT 전 numberingRuleService.allocateCode() 호출,
  생성된 코드를 generatedCodes 배열로 응답에 포함
[결과 모달 UX]
- pop-button: showResultModal 토글에 따라 채번 결과 모달 표시 분기
- 모달이 열려 있는 동안 followUpActions(refresh/navigate) 지연하여
  사용자가 확인 버튼을 눌러야 후속 액션 실행
[셀렉트 높이 일관성]
- SelectTrigger hasCustomHeight에 /\bh-\d/ 패턴 추가하여
  className의 h-9 등이 기본 data-size="xs"(h-6)와 충돌하지 않도록 수정
[기타 수정]
- SelectFieldInput: Set 기반 dedup으로 React key 중복 방지
- PopFieldConfig: AutoNumberEditor 제거, 채번 규칙을 저장 탭에서 관리
- PopFieldConfig: 전체 채번 규칙 보기 토글 추가
- PopCardListComponent: 장바구니 목록 모드에서 수량 자동 초기화 방지
- PopCardListConfig: 수식 필드 매핑 노출 + 누락 필드 자동 추가
2026-03-04 19:12:22 +09:00
SeongHyun Kim e5abd93600 fix(pop): 카테고리 트리 접기/펼치기 상태를 sessionStorage로 유지
설계 화면에 진입했다 돌아올 때 카테고리 트리와 미분류 회사코드
접기/펼치기 상태가 초기화되는 문제를 수정한다.
expandedGroups, expandedCompanyCodes를 sessionStorage에 저장하여
같은 탭 세션 내에서 상태가 유지되도록 변경.
2026-03-04 14:40:48 +09:00
87 changed files with 14252 additions and 821 deletions

25
ai-assistant/.env.example Normal file
View File

@ -0,0 +1,25 @@
# 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

@ -0,0 +1,17 @@
# AI 어시스턴트 API - Docker (Windows 개발용)
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=development
EXPOSE 3100
CMD ["node", "src/app.js"]

43
ai-assistant/README.md Normal file
View File

@ -0,0 +1,43 @@
# 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`

3453
ai-assistant/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
ai-assistant/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "ai-assistant-api",
"version": "1.0.0",
"description": "AI Assistant API (VEXPLOR 내장) - 포트 3100에서 구동, backend-node가 /api/ai/v1 로 프록시",
"private": true,
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js"
},
"dependencies": {
"@google/genai": "^1.0.0",
"axios": "^1.6.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"pg-hstore": "^2.3.4",
"sequelize": "^6.35.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"zod": "^3.22.4"
},
"devDependencies": {
"nodemon": "^3.0.3"
},
"engines": {
"node": ">=18.0.0"
}
}

186
ai-assistant/src/app.js Normal file
View File

@ -0,0 +1,186 @@
// 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

@ -0,0 +1,474 @@
// 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

@ -0,0 +1,215 @@
// 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

@ -0,0 +1,195 @@
// 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

@ -0,0 +1,152 @@
// 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

@ -0,0 +1,67 @@
// 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

@ -0,0 +1,177 @@
// 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

@ -0,0 +1,113 @@
// 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

@ -0,0 +1,257 @@
// 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

@ -0,0 +1,80 @@
// 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

@ -0,0 +1,50 @@
// 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

@ -0,0 +1,30 @@
// 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

@ -0,0 +1,130 @@
// 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

@ -0,0 +1,55 @@
// 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

@ -0,0 +1,143 @@
// 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

@ -0,0 +1,164 @@
// 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

@ -0,0 +1,92 @@
// 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

@ -0,0 +1,151 @@
// 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

@ -0,0 +1,99 @@
// 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

@ -0,0 +1,76 @@
// 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

@ -0,0 +1,55 @@
// 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

@ -0,0 +1,45 @@
// 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

@ -0,0 +1,24 @@
// 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

@ -0,0 +1,81 @@
// 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

@ -0,0 +1,50 @@
// 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

@ -0,0 +1,74 @@
// 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

@ -0,0 +1,128 @@
// 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

@ -0,0 +1,385 @@
// 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

@ -0,0 +1,359 @@
// 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,6 +22,7 @@
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html-to-docx": "^1.8.0", "html-to-docx": "^1.8.0",
"http-proxy-middleware": "^3.0.5",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"joi": "^17.11.0", "joi": "^17.11.0",
@ -3318,6 +3319,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/imap": {
"version": "0.8.42", "version": "0.8.42",
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz", "resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
@ -4419,7 +4429,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@ -6154,7 +6163,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@ -6887,6 +6895,20 @@
"node": ">= 0.8" "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": { "node_modules/http-proxy-agent": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@ -6900,6 +6922,29 @@
"node": ">= 14" "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": { "node_modules/https-proxy-agent": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@ -7208,7 +7253,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -7238,7 +7282,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@ -7269,7 +7312,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@ -7294,6 +7336,15 @@
"node": ">=8" "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": { "node_modules/is-property": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
@ -8566,7 +8617,6 @@
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
@ -9388,7 +9438,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@ -9946,6 +9995,12 @@
"node": ">=0.10.0" "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": { "node_modules/resolve": {
"version": "1.22.10", "version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@ -10824,7 +10879,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"

View File

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

View File

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

View File

@ -0,0 +1,31 @@
/**
* 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,6 +1,7 @@
import express from "express"; import express from "express";
import { dataService } from "../services/dataService"; import { dataService } from "../services/dataService";
import { masterDetailExcelService } from "../services/masterDetailExcelService"; import { masterDetailExcelService } from "../services/masterDetailExcelService";
import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { auditLogService } from "../services/auditLogService"; import { auditLogService } from "../services/auditLogService";
@ -260,6 +261,117 @@ 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 // 기존 데이터 API
// ================================ // ================================

View File

@ -2,6 +2,7 @@ import { Router, Request, Response } from "express";
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import logger from "../utils/logger"; import logger from "../utils/logger";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
import { numberingRuleService } from "../services/numberingRuleService";
const router = Router(); const router = Router();
@ -12,9 +13,26 @@ function isSafeIdentifier(name: string): boolean {
return SAFE_IDENTIFIER.test(name); return SAFE_IDENTIFIER.test(name);
} }
interface AutoGenMappingInfo {
numberingRuleId: string;
targetColumn: string;
showResultModal?: boolean;
}
interface HiddenMappingInfo {
valueSource: "json_extract" | "db_column" | "static";
targetColumn: string;
staticValue?: string;
sourceJsonColumn?: string;
sourceJsonKey?: string;
sourceDbColumn?: string;
}
interface MappingInfo { interface MappingInfo {
targetTable: string; targetTable: string;
columnMapping: Record<string, string>; columnMapping: Record<string, string>;
autoGenMappings?: AutoGenMappingInfo[];
hiddenMappings?: HiddenMappingInfo[];
} }
interface StatusConditionRule { interface StatusConditionRule {
@ -44,7 +62,8 @@ interface StatusChangeRuleBody {
} }
interface ExecuteActionBody { interface ExecuteActionBody {
action: string; action?: string;
tasks?: TaskBody[];
data: { data: {
items?: Record<string, unknown>[]; items?: Record<string, unknown>[];
fieldValues?: Record<string, unknown>; fieldValues?: Record<string, unknown>;
@ -54,6 +73,36 @@ interface ExecuteActionBody {
field?: MappingInfo | null; field?: MappingInfo | null;
}; };
statusChanges?: StatusChangeRuleBody[]; statusChanges?: StatusChangeRuleBody[];
cartChanges?: {
toCreate?: Record<string, unknown>[];
toUpdate?: Record<string, unknown>[];
toDelete?: (string | number)[];
};
}
interface TaskBody {
id: string;
type: string;
targetTable?: string;
targetColumn?: string;
operationType?: "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional";
valueSource?: "fixed" | "linked" | "reference";
fixedValue?: string;
sourceField?: string;
referenceTable?: string;
referenceColumn?: string;
referenceJoinKey?: string;
conditionalValue?: ConditionalValueRule;
// db-conditional 전용 (DB 컬럼 간 비교 후 값 판정)
compareColumn?: string;
compareOperator?: "=" | "!=" | ">" | "<" | ">=" | "<=";
compareWith?: string;
dbThenValue?: string;
dbElseValue?: string;
lookupMode?: "auto" | "manual";
manualItemField?: string;
manualPkColumn?: string;
cartScreenId?: string;
} }
function resolveStatusValue( function resolveStatusValue(
@ -96,26 +145,300 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
} }
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody; const { action, tasks, data, mappings, statusChanges, cartChanges } = req.body as ExecuteActionBody;
const items = data?.items ?? []; const items = data?.items ?? [];
const fieldValues = data?.fieldValues ?? {}; const fieldValues = data?.fieldValues ?? {};
logger.info("[pop/execute-action] 요청", { logger.info("[pop/execute-action] 요청", {
action, action: action ?? "task-list",
companyCode, companyCode,
userId, userId,
itemCount: items.length, itemCount: items.length,
hasFieldValues: Object.keys(fieldValues).length > 0, hasFieldValues: Object.keys(fieldValues).length > 0,
hasMappings: !!mappings, hasMappings: !!mappings,
statusChangeCount: statusChanges?.length ?? 0, statusChangeCount: statusChanges?.length ?? 0,
taskCount: tasks?.length ?? 0,
hasCartChanges: !!cartChanges,
}); });
await client.query("BEGIN"); await client.query("BEGIN");
let processedCount = 0; let processedCount = 0;
let insertedCount = 0; let insertedCount = 0;
let deletedCount = 0;
const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = [];
if (action === "inbound-confirm") { // ======== v2: tasks 배열 기반 처리 ========
if (tasks && tasks.length > 0) {
for (const task of tasks) {
switch (task.type) {
case "data-save": {
// 매핑 기반 INSERT (기존 inbound-confirm INSERT 로직 재사용)
const cardMapping = mappings?.cardList;
const fieldMapping = mappings?.field;
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) {
if (!isSafeIdentifier(cardMapping.targetTable)) {
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
columns.push(`"${targetColumn}"`);
values.push(item[sourceField] ?? null);
}
if (fieldMapping?.targetTable === cardMapping.targetTable) {
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
if (columns.includes(`"${targetColumn}"`)) continue;
columns.push(`"${targetColumn}"`);
values.push(fieldValues[sourceField] ?? null);
}
}
const allHidden = [
...(fieldMapping?.hiddenMappings ?? []),
...(cardMapping?.hiddenMappings ?? []),
];
for (const hm of allHidden) {
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
if (columns.includes(`"${hm.targetColumn}"`)) continue;
let value: unknown = null;
if (hm.valueSource === "static") {
value = hm.staticValue ?? null;
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
const jsonCol = item[hm.sourceJsonColumn];
if (typeof jsonCol === "object" && jsonCol !== null) {
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
} else if (typeof jsonCol === "string") {
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
}
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
}
columns.push(`"${hm.targetColumn}"`);
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;
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
await client.query(
`INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`,
values,
);
insertedCount++;
}
}
}
break;
}
case "data-update": {
if (!task.targetTable || !task.targetColumn) break;
if (!isSafeIdentifier(task.targetTable) || !isSafeIdentifier(task.targetColumn)) break;
const opType = task.operationType ?? "assign";
const valSource = task.valueSource ?? "fixed";
const lookupMode = task.lookupMode ?? "auto";
let itemField: string;
let pkColumn: string;
if (lookupMode === "manual" && task.manualItemField && task.manualPkColumn) {
if (!isSafeIdentifier(task.manualPkColumn)) break;
itemField = task.manualItemField;
pkColumn = task.manualPkColumn;
} else if (task.targetTable === "cart_items") {
itemField = "__cart_id";
pkColumn = "id";
} else {
itemField = "__cart_row_key";
const pkResult = await client.query(
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[task.targetTable],
);
pkColumn = pkResult.rows[0]?.attname || "id";
}
const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean);
if (lookupValues.length === 0) break;
if (opType === "conditional" && task.conditionalValue) {
for (let i = 0; i < lookupValues.length; i++) {
const item = items[i] ?? {};
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolved, companyCode, lookupValues[i]],
);
processedCount++;
}
} else if (opType === "db-conditional") {
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
const thenVal = task.dbThenValue ?? "";
const elseVal = task.dbElseValue ?? "";
const op = task.compareOperator;
const validOps = ["=", "!=", ">", "<", ">=", "<="];
if (!validOps.includes(op)) break;
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
[thenVal, elseVal, companyCode, ...lookupValues],
);
processedCount += lookupValues.length;
} else {
for (let i = 0; i < lookupValues.length; i++) {
const item = items[i] ?? {};
let value: unknown;
if (valSource === "linked") {
value = item[task.sourceField ?? ""] ?? null;
} else {
value = task.fixedValue ?? "";
}
let setSql: string;
if (opType === "add") {
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) + $1::numeric`;
} else if (opType === "subtract") {
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) - $1::numeric`;
} else if (opType === "multiply") {
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) * $1::numeric`;
} else if (opType === "divide") {
setSql = `"${task.targetColumn}" = CASE WHEN $1::numeric = 0 THEN COALESCE("${task.targetColumn}"::numeric, 0) ELSE COALESCE("${task.targetColumn}"::numeric, 0) / $1::numeric END`;
} else {
setSql = `"${task.targetColumn}" = $1`;
}
await client.query(
`UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[value, companyCode, lookupValues[i]],
);
processedCount++;
}
}
logger.info("[pop/execute-action] data-update 실행", {
table: task.targetTable,
column: task.targetColumn,
opType,
count: lookupValues.length,
});
break;
}
case "data-delete": {
if (!task.targetTable) break;
if (!isSafeIdentifier(task.targetTable)) break;
const pkResult = await client.query(
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[task.targetTable],
);
const pkCol = pkResult.rows[0]?.attname || "id";
const deleteKeys = items.map((item) => item[pkCol] ?? item["id"]).filter(Boolean);
if (deleteKeys.length > 0) {
const placeholders = deleteKeys.map((_, i) => `$${i + 2}`).join(", ");
await client.query(
`DELETE FROM "${task.targetTable}" WHERE company_code = $1 AND "${pkCol}" IN (${placeholders})`,
[companyCode, ...deleteKeys],
);
deletedCount += deleteKeys.length;
}
break;
}
case "cart-save": {
// cartChanges 처리 (M-9에서 확장)
if (!cartChanges) break;
const { toCreate, toUpdate, toDelete } = cartChanges;
if (toCreate && toCreate.length > 0) {
for (const item of toCreate) {
const cols = Object.keys(item).filter(isSafeIdentifier);
if (cols.length === 0) continue;
const allCols = ["company_code", ...cols.map((c) => `"${c}"`)];
const allVals = [companyCode, ...cols.map((c) => item[c])];
const placeholders = allVals.map((_, i) => `$${i + 1}`).join(", ");
await client.query(
`INSERT INTO "cart_items" (${allCols.join(", ")}) VALUES (${placeholders})`,
allVals,
);
insertedCount++;
}
}
if (toUpdate && toUpdate.length > 0) {
for (const item of toUpdate) {
const id = item.id;
if (!id) continue;
const cols = Object.keys(item).filter((c) => c !== "id" && isSafeIdentifier(c));
if (cols.length === 0) continue;
const setClauses = cols.map((c, i) => `"${c}" = $${i + 3}`).join(", ");
await client.query(
`UPDATE "cart_items" SET ${setClauses} WHERE id = $1 AND company_code = $2`,
[id, companyCode, ...cols.map((c) => item[c])],
);
processedCount++;
}
}
if (toDelete && toDelete.length > 0) {
const placeholders = toDelete.map((_, i) => `$${i + 2}`).join(", ");
await client.query(
`DELETE FROM "cart_items" WHERE company_code = $1 AND id IN (${placeholders})`,
[companyCode, ...toDelete],
);
deletedCount += toDelete.length;
}
logger.info("[pop/execute-action] cart-save 실행", {
created: toCreate?.length ?? 0,
updated: toUpdate?.length ?? 0,
deleted: toDelete?.length ?? 0,
});
break;
}
default:
logger.warn("[pop/execute-action] 프론트 전용 작업 타입, 백엔드 무시", { type: task.type });
}
}
}
// ======== v1 레거시: action 기반 처리 ========
else if (action === "inbound-confirm") {
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블) // 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
const cardMapping = mappings?.cardList; const cardMapping = mappings?.cardList;
const fieldMapping = mappings?.field; const fieldMapping = mappings?.field;
@ -144,6 +467,64 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
} }
} }
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
const allHidden = [
...(fieldMapping?.hiddenMappings ?? []),
...(cardMapping?.hiddenMappings ?? []),
];
for (const hm of allHidden) {
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
if (columns.includes(`"${hm.targetColumn}"`)) continue;
let value: unknown = null;
if (hm.valueSource === "static") {
value = hm.staticValue ?? null;
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
const jsonCol = item[hm.sourceJsonColumn];
if (typeof jsonCol === "object" && jsonCol !== null) {
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
} else if (typeof jsonCol === "string") {
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
}
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
}
columns.push(`"${hm.targetColumn}"`);
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;
try {
const generatedCode = await numberingRuleService.allocateCode(
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,
});
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", {
ruleId: ag.numberingRuleId,
error: err.message,
});
}
}
if (columns.length > 1) { if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
@ -254,16 +635,17 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
await client.query("COMMIT"); await client.query("COMMIT");
logger.info("[pop/execute-action] 완료", { logger.info("[pop/execute-action] 완료", {
action, action: action ?? "task-list",
companyCode, companyCode,
processedCount, processedCount,
insertedCount, insertedCount,
deletedCount,
}); });
return res.json({ return res.json({
success: true, success: true,
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`, message: `${processedCount}건 처리${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}${deletedCount > 0 ? `, ${deletedCount}건 삭제` : ""}`,
data: { processedCount, insertedCount }, data: { processedCount, insertedCount, deletedCount, generatedCodes },
}); });
} catch (error: any) { } catch (error: any) {
await client.query("ROLLBACK"); await client.query("ROLLBACK");

File diff suppressed because it is too large Load Diff

View File

@ -3783,15 +3783,15 @@ export class TableManagementService {
); );
} }
} else if (operator === "equals") { } else if (operator === "equals") {
// 🔧 equals 연산자: 정확히 일치 // 🔧 equals 연산자: 메인 테이블의 FK 컬럼에서 직접 매칭 (연결 필터용)
whereConditions.push( whereConditions.push(
`${alias}.${joinConfig.displayColumn}::text = '${safeValue}'` `main.${joinConfig.sourceColumn}::text = '${safeValue}'`
); );
entitySearchColumns.push( entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` `${key} (main.${joinConfig.sourceColumn})`
); );
logger.info( logger.info(
`🎯 Entity 조인 정확히 일치 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})` `🎯 Entity 조인 직접 FK 매칭: ${key} → main.${joinConfig.sourceColumn} = '${safeValue}'`
); );
} else { } else {
// 기본: 부분 일치 (ILIKE) // 기본: 부분 일치 (ILIKE)

View File

@ -0,0 +1,65 @@
/**
* 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

@ -0,0 +1,194 @@
# 다중 테이블 엑셀 업로드 범용 시스템
## 개요
하나의 플랫 엑셀 파일로 계층적 다중 테이블(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

@ -0,0 +1,299 @@
"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

@ -0,0 +1,180 @@
"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

@ -0,0 +1,142 @@
"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

@ -0,0 +1,190 @@
"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

@ -0,0 +1,157 @@
"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

@ -0,0 +1,128 @@
"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

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

View File

@ -0,0 +1,195 @@
"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 } from "lucide-react"; import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2, Bot } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { GlobalFileViewer } from "@/components/GlobalFileViewer"; import { GlobalFileViewer } from "@/components/GlobalFileViewer";
@ -80,6 +80,20 @@ export default function AdminPage() {
</div> </div>
</div> </div>
</Link> </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>
</div> </div>

View File

@ -6,12 +6,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { BarcodeListTable } from "@/components/barcode/BarcodeListTable"; import { BarcodeListTable } from "@/components/barcode/BarcodeListTable";
import { Plus, Search, RotateCcw } from "lucide-react"; import { BarcodeScanModal } from "@/components/common/BarcodeScanModal";
import { Plus, Search, RotateCcw, Scan } from "lucide-react";
import { useBarcodeList } from "@/hooks/useBarcodeList"; import { useBarcodeList } from "@/hooks/useBarcodeList";
export default function BarcodeLabelManagementPage() { export default function BarcodeLabelManagementPage() {
const router = useRouter(); const router = useRouter();
const [searchText, setSearchText] = useState(""); 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(); const { labels, total, page, limit, isLoading, refetch, setPage, handleSearch } = useBarcodeList();
@ -74,6 +77,33 @@ export default function BarcodeLabelManagementPage() {
</CardContent> </CardContent>
</Card> </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"> <Card className="shadow-sm">
<CardHeader className="bg-gray-50/50"> <CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
@ -95,6 +125,18 @@ export default function BarcodeLabelManagementPage() {
/> />
</CardContent> </CardContent>
</Card> </Card>
<BarcodeScanModal
open={scanModalOpen}
onOpenChange={setScanModalOpen}
targetField="바코드 값"
barcodeFormat="all"
autoSubmit={false}
onScanSuccess={(barcode) => {
setScannedBarcode(barcode);
setScanModalOpen(false);
}}
/>
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

@ -46,7 +46,6 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
codeReaderRef.current = new BrowserMultiFormatReader(); codeReaderRef.current = new BrowserMultiFormatReader();
// 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청
} }
return () => { return () => {
@ -184,7 +183,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle> <DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">
. .
{targetField && ` (대상 필드: ${targetField})`} {targetField && ` (대상 필드: ${targetField})`}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -0,0 +1,786 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import {
Upload,
FileSpreadsheet,
AlertCircle,
CheckCircle2,
ArrowRight,
Zap,
Download,
Loader2,
} from "lucide-react";
import { importFromExcel, getExcelSheetNames, exportToExcel } from "@/lib/utils/excelExport";
import { cn } from "@/lib/utils";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
import {
TableChainConfig,
uploadMultiTableExcel,
} from "@/lib/api/multiTableExcel";
export interface MultiTableExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: TableChainConfig;
onSuccess?: () => void;
}
interface ColumnMapping {
excelColumn: string;
targetColumn: string | null;
}
export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProps> = ({
open,
onOpenChange,
config,
onSuccess,
}) => {
// 스텝: 1=모드선택+파일, 2=컬럼매핑, 3=확인
const [currentStep, setCurrentStep] = useState(1);
// 모드 선택
const [selectedModeId, setSelectedModeId] = useState<string>(
config.uploadModes[0]?.id || ""
);
// 파일
const [file, setFile] = useState<File | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState("");
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [allData, setAllData] = useState<Record<string, any>[]>([]);
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
const [excelColumns, setExcelColumns] = useState<string[]>([]);
// 매핑
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
// 업로드
const [isUploading, setIsUploading] = useState(false);
const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId);
// 선택된 모드에서 활성화되는 컬럼 목록
const activeColumns = React.useMemo(() => {
if (!selectedMode) return [];
const cols: Array<{ dbColumn: string; excelHeader: string; required: boolean; levelLabel: string }> = [];
for (const levelIdx of selectedMode.activeLevels) {
const level = config.levels[levelIdx];
if (!level) continue;
for (const col of level.columns) {
cols.push({
...col,
levelLabel: level.label,
});
}
}
return cols;
}, [selectedMode, config.levels]);
// 템플릿 다운로드
const handleDownloadTemplate = () => {
if (!selectedMode) return;
const headers: string[] = [];
const sampleRow: Record<string, string> = {};
const sampleRow2: Record<string, string> = {};
for (const levelIdx of selectedMode.activeLevels) {
const level = config.levels[levelIdx];
if (!level) continue;
for (const col of level.columns) {
headers.push(col.excelHeader);
sampleRow[col.excelHeader] = col.required ? "(필수)" : "";
sampleRow2[col.excelHeader] = "";
}
}
// 예시 데이터 생성 (config에 맞춰)
exportToExcel(
[sampleRow, sampleRow2],
`${config.name}_${selectedMode.label}_템플릿.xlsx`,
"Sheet1"
);
toast.success("템플릿 파일이 다운로드되었습니다.");
};
// 파일 처리
const processFile = async (selectedFile: File) => {
const ext = selectedFile.name.split(".").pop()?.toLowerCase();
if (!["xlsx", "xls", "csv"].includes(ext || "")) {
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
return;
}
setFile(selectedFile);
try {
const sheets = await getExcelSheetNames(selectedFile);
setSheetNames(sheets);
setSelectedSheet(sheets[0] || "");
const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data);
setDisplayData(data);
if (data.length > 0) {
setExcelColumns(Object.keys(data[0]));
}
toast.success(`파일 선택 완료: ${selectedFile.name}`);
} catch (error) {
console.error("파일 읽기 오류:", error);
toast.error("파일을 읽는 중 오류가 발생했습니다.");
setFile(null);
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) await processFile(selectedFile);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) await processFile(droppedFile);
};
const handleSheetChange = async (sheetName: string) => {
setSelectedSheet(sheetName);
if (!file) return;
try {
const data = await importFromExcel(file, sheetName);
setAllData(data);
setDisplayData(data);
if (data.length > 0) {
setExcelColumns(Object.keys(data[0]));
}
} catch (error) {
console.error("시트 읽기 오류:", error);
toast.error("시트를 읽는 중 오류가 발생했습니다.");
}
};
// 2단계 진입 시 자동 매핑 시도
useEffect(() => {
if (currentStep === 2 && excelColumns.length > 0) {
performAutoMapping();
}
}, [currentStep]);
const performAutoMapping = () => {
const newMappings: ColumnMapping[] = excelColumns.map((excelCol) => {
const normalizedExcel = excelCol.toLowerCase().trim();
const matched = activeColumns.find((ac) => {
return (
ac.excelHeader.toLowerCase().trim() === normalizedExcel ||
ac.dbColumn.toLowerCase().trim() === normalizedExcel
);
});
return {
excelColumn: excelCol,
targetColumn: matched ? matched.excelHeader : null,
};
});
setColumnMappings(newMappings);
const matchedCount = newMappings.filter((m) => m.targetColumn).length;
if (matchedCount > 0) {
toast.success(`${matchedCount}개 컬럼이 자동 매핑되었습니다.`);
}
};
const handleMappingChange = (excelColumn: string, targetColumn: string | null) => {
setColumnMappings((prev) =>
prev.map((m) =>
m.excelColumn === excelColumn ? { ...m, targetColumn } : m
)
);
};
// 업로드 실행
const handleUpload = async () => {
if (!file || !selectedMode) return;
setIsUploading(true);
try {
// 엑셀 데이터를 excelHeader 기준으로 변환
const mappedRows = allData.map((row) => {
const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => {
if (mapping.targetColumn) {
mappedRow[mapping.targetColumn] = row[mapping.excelColumn];
}
});
return mappedRow;
});
// 빈 행 필터링
const filteredRows = mappedRows.filter((row) =>
Object.values(row).some(
(v) => v !== undefined && v !== null && (typeof v !== "string" || v.trim() !== "")
)
);
console.log(`다중 테이블 업로드: ${filteredRows.length}`);
const result = await uploadMultiTableExcel({
config,
modeId: selectedModeId,
rows: filteredRows,
});
if (result.success && result.data) {
const { results, errors } = result.data;
const summaryParts = results
.filter((r) => r.inserted + r.updated > 0)
.map((r) => {
const parts: string[] = [];
if (r.inserted > 0) parts.push(`신규 ${r.inserted}`);
if (r.updated > 0) parts.push(`수정 ${r.updated}`);
return `${r.tableName}: ${parts.join(", ")}`;
});
const msg = summaryParts.join(" / ");
const errorMsg = errors.length > 0 ? ` (오류: ${errors.length}건)` : "";
toast.success(`업로드 완료: ${msg}${errorMsg}`);
if (errors.length > 0) {
console.warn("업로드 오류 목록:", errors);
}
onSuccess?.();
onOpenChange(false);
} else {
toast.error(result.message || "업로드에 실패했습니다.");
}
} catch (error) {
console.error("다중 테이블 업로드 실패:", error);
toast.error("업로드 중 오류가 발생했습니다.");
} finally {
setIsUploading(false);
}
};
// 다음/이전 단계
const handleNext = () => {
if (currentStep === 1) {
if (!file) {
toast.error("파일을 선택해주세요.");
return;
}
if (displayData.length === 0) {
toast.error("데이터가 없습니다.");
return;
}
}
if (currentStep === 2) {
// 필수 컬럼 매핑 확인
const mappedTargets = new Set(
columnMappings.filter((m) => m.targetColumn).map((m) => m.targetColumn)
);
const unmappedRequired = activeColumns
.filter((ac) => ac.required && !mappedTargets.has(ac.excelHeader))
.map((ac) => `${ac.excelHeader}`);
if (unmappedRequired.length > 0) {
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
return;
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3));
};
const handlePrevious = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
// 모달 닫기 시 초기화
useEffect(() => {
if (!open) {
setCurrentStep(1);
setSelectedModeId(config.uploadModes[0]?.id || "");
setFile(null);
setSheetNames([]);
setSelectedSheet("");
setAllData([]);
setDisplayData([]);
setExcelColumns([]);
setColumnMappings([]);
}
}, [open, config.uploadModes]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
style={{ width: "1000px", height: "700px", minWidth: "700px", minHeight: "500px", maxWidth: "1400px", maxHeight: "900px" }}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FileSpreadsheet className="h-5 w-5" />
{config.name} -
<span className="ml-2 rounded bg-indigo-100 px-2 py-0.5 text-xs font-normal text-indigo-700">
</span>
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{config.description}
</DialogDescription>
</DialogHeader>
{/* 스텝 인디케이터 */}
<div className="flex items-center justify-between">
{[
{ num: 1, label: "모드 선택 / 파일" },
{ num: 2, label: "컬럼 매핑" },
{ num: 3, label: "확인" },
].map((step, index) => (
<React.Fragment key={step.num}>
<div className="flex flex-col items-center gap-1">
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors sm:h-10 sm:w-10",
currentStep === step.num
? "bg-primary text-primary-foreground"
: currentStep > step.num
? "bg-success text-white"
: "bg-muted text-muted-foreground"
)}
>
{currentStep > step.num ? (
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
) : (
step.num
)}
</div>
<span
className={cn(
"text-[10px] font-medium sm:text-xs",
currentStep === step.num ? "text-primary" : "text-muted-foreground"
)}
>
{step.label}
</span>
</div>
{index < 2 && (
<div
className={cn(
"h-0.5 flex-1 transition-colors",
currentStep > step.num ? "bg-success" : "bg-muted"
)}
/>
)}
</React.Fragment>
))}
</div>
{/* 스텝별 컨텐츠 */}
<div className="max-h-[calc(95vh-200px)] space-y-4 overflow-y-auto">
{/* 1단계: 모드 선택 + 파일 선택 */}
{currentStep === 1 && (
<div className="space-y-4">
{/* 업로드 모드 선택 */}
<div>
<Label className="text-xs font-medium sm:text-sm"> *</Label>
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{config.uploadModes.map((mode) => (
<button
key={mode.id}
type="button"
onClick={() => {
setSelectedModeId(mode.id);
setFile(null);
setAllData([]);
setDisplayData([]);
setExcelColumns([]);
}}
className={cn(
"rounded-lg border p-3 text-left transition-all",
selectedModeId === mode.id
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<p className="text-xs font-semibold sm:text-sm">{mode.label}</p>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
{mode.description}
</p>
</button>
))}
</div>
</div>
{/* 템플릿 다운로드 */}
<div className="flex items-center justify-between rounded-md border border-muted bg-muted/30 p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:text-sm">
<Download className="h-4 w-4" />
<span> </span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownloadTemplate}
className="h-8 text-xs sm:text-sm"
>
<Download className="mr-1 h-3 w-3" />
릿
</Button>
</div>
{/* 파일 선택 */}
<div>
<Label htmlFor="multi-file-upload" className="text-xs sm:text-sm">
*
</Label>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
"mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
isDragOver
? "border-primary bg-primary/5"
: file
? "border-green-500 bg-green-50"
: "border-muted-foreground/25 hover:border-primary hover:bg-muted/50"
)}
>
{file ? (
<div className="flex items-center gap-3">
<FileSpreadsheet className="h-8 w-8 text-green-600" />
<div>
<p className="text-sm font-medium text-green-700">{file.name}</p>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
) : (
<>
<Upload
className={cn(
"mb-2 h-8 w-8",
isDragOver ? "text-primary" : "text-muted-foreground"
)}
/>
<p
className={cn(
"text-sm font-medium",
isDragOver ? "text-primary" : "text-muted-foreground"
)}
>
{isDragOver ? "파일을 놓으세요" : "파일을 드래그하거나 클릭하여 선택"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
형식: .xlsx, .xls, .csv
</p>
</>
)}
<input
ref={fileInputRef}
id="multi-file-upload"
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
{/* 미리보기 */}
{file && displayData.length > 0 && (
<>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground sm:text-sm">:</Label>
<Select value={selectedSheet} onValueChange={handleSheetChange}>
<SelectTrigger className="h-8 w-[140px] text-xs sm:h-9 sm:w-[180px] sm:text-sm">
<SelectValue placeholder="Sheet1" />
</SelectTrigger>
<SelectContent>
{sheetNames.map((name) => (
<SelectItem key={name} value={name} className="text-xs sm:text-sm">
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="text-xs text-muted-foreground">
{displayData.length}
</span>
</div>
<EditableSpreadsheet
columns={excelColumns}
data={displayData}
onColumnsChange={setExcelColumns}
onDataChange={(newData) => {
setDisplayData(newData);
setAllData(newData);
}}
maxHeight="250px"
/>
</>
)}
</div>
)}
{/* 2단계: 컬럼 매핑 */}
{currentStep === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<Button
type="button"
variant="default"
size="sm"
onClick={performAutoMapping}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Zap className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
<div> </div>
<div></div>
<div> </div>
</div>
<div className="max-h-[300px] space-y-2 overflow-y-auto">
{columnMappings.map((mapping, index) => (
<div
key={index}
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2"
>
<div className="rounded-md border border-border bg-muted px-3 py-2 text-xs font-medium sm:text-sm">
{mapping.excelColumn}
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Select
value={mapping.targetColumn || "none"}
onValueChange={(value) =>
handleMappingChange(
mapping.excelColumn,
value === "none" ? null : value
)
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="매핑 안함">
{mapping.targetColumn || "매핑 안함"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs sm:text-sm">
</SelectItem>
{activeColumns.map((ac) => (
<SelectItem
key={`${ac.levelLabel}-${ac.dbColumn}`}
value={ac.excelHeader}
className="text-xs sm:text-sm"
>
{ac.required && (
<span className="mr-1 text-destructive">*</span>
)}
[{ac.levelLabel}] {ac.excelHeader} ({ac.dbColumn})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
{/* 미매핑 필수 컬럼 경고 */}
{(() => {
const mappedTargets = new Set(
columnMappings.filter((m) => m.targetColumn).map((m) => m.targetColumn)
);
const missing = activeColumns.filter(
(ac) => ac.required && !mappedTargets.has(ac.excelHeader)
);
if (missing.length === 0) return null;
return (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
<div className="text-[10px] text-destructive sm:text-xs">
<p className="font-medium"> :</p>
<p className="mt-1">
{missing.map((m) => `[${m.levelLabel}] ${m.excelHeader}`).join(", ")}
</p>
</div>
</div>
</div>
);
})()}
{/* 모드 정보 */}
{selectedMode && (
<div className="rounded-md border border-muted bg-muted/30 p-3">
<div className="flex items-start gap-2">
<Zap className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="text-[10px] text-muted-foreground sm:text-xs">
<p className="font-medium">: {selectedMode.label}</p>
<p className="mt-1">
:{" "}
{selectedMode.activeLevels
.map((i) => config.levels[i]?.label)
.filter(Boolean)
.join(" → ")}
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* 3단계: 확인 */}
{currentStep === 3 && (
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/50 p-4">
<h3 className="text-sm font-medium sm:text-base"> </h3>
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
<p><span className="font-medium">:</span> {file?.name}</p>
<p><span className="font-medium">:</span> {selectedSheet}</p>
<p><span className="font-medium"> :</span> {allData.length}</p>
<p><span className="font-medium">:</span> {selectedMode?.label}</p>
<p>
<span className="font-medium"> :</span>{" "}
{selectedMode?.activeLevels
.map((i) => {
const level = config.levels[i];
return level
? `${level.label}(${level.tableName})`
: "";
})
.filter(Boolean)
.join(" → ")}
</p>
</div>
</div>
<div className="rounded-md border border-border bg-muted/50 p-4">
<h3 className="text-sm font-medium sm:text-base"> </h3>
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
{columnMappings
.filter((m) => m.targetColumn)
.map((mapping, idx) => {
const ac = activeColumns.find(
(c) => c.excelHeader === mapping.targetColumn
);
return (
<p key={idx}>
<span className="font-medium">{mapping.excelColumn}</span>{" "}
[{ac?.levelLabel}] {mapping.targetColumn}
</p>
);
})}
</div>
</div>
<div className="rounded-md border border-warning bg-warning/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-warning" />
<div className="text-[10px] text-warning sm:text-xs">
<p className="font-medium"></p>
<p className="mt-1">
.
.
</p>
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
disabled={isUploading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{currentStep === 1 ? "취소" : "이전"}
</Button>
{currentStep < 3 ? (
<Button
onClick={handleNext}
disabled={isUploading || (currentStep === 1 && !file)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
) : (
<Button
onClick={handleUpload}
disabled={isUploading || columnMappings.filter((m) => m.targetColumn).length === 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"업로드"
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

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

View File

@ -471,7 +471,15 @@ export function PopCategoryTree({
// 상태 관리 // 상태 관리
const [groups, setGroups] = useState<PopScreenGroup[]>([]); const [groups, setGroups] = useState<PopScreenGroup[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<number>>(() => {
if (typeof window === "undefined") return new Set();
try {
const saved = sessionStorage.getItem("pop-tree-expanded-groups");
return saved ? new Set(JSON.parse(saved) as number[]) : new Set();
} catch {
return new Set();
}
});
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null); const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
// 그룹 모달 상태 // 그룹 모달 상태
@ -500,7 +508,15 @@ export function PopCategoryTree({
const [moveSearchTerm, setMoveSearchTerm] = useState(""); const [moveSearchTerm, setMoveSearchTerm] = useState("");
// 미분류 회사코드별 접기/펼치기 // 미분류 회사코드별 접기/펼치기
const [expandedCompanyCodes, setExpandedCompanyCodes] = useState<Set<string>>(new Set()); const [expandedCompanyCodes, setExpandedCompanyCodes] = useState<Set<string>>(() => {
if (typeof window === "undefined") return new Set();
try {
const saved = sessionStorage.getItem("pop-tree-expanded-companies");
return saved ? new Set(JSON.parse(saved) as string[]) : new Set();
} catch {
return new Set();
}
});
// 화면 맵 생성 (screen_id로 빠르게 조회) // 화면 맵 생성 (screen_id로 빠르게 조회)
const screensMap = useMemo(() => { const screensMap = useMemo(() => {
@ -544,6 +560,9 @@ export function PopCategoryTree({
} else { } else {
next.add(groupId); next.add(groupId);
} }
try {
sessionStorage.setItem("pop-tree-expanded-groups", JSON.stringify([...next]));
} catch { /* noop */ }
return next; return next;
}); });
}; };
@ -1013,6 +1032,9 @@ export function PopCategoryTree({
} else { } else {
next.add(code); next.add(code);
} }
try {
sessionStorage.setItem("pop-tree-expanded-companies", JSON.stringify([...next]));
} catch { /* noop */ }
return next; return next;
}); });
}; };

View File

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

View File

@ -12,6 +12,8 @@ const badgeVariants = cva(
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground", outline: "text-foreground",
success: "border-transparent bg-green-500 text-white hover:bg-green-600",
warning: "border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -28,7 +28,7 @@ function SelectTrigger({
size?: "xs" | "sm" | "default"; size?: "xs" | "sm" | "default";
}) { }) {
// className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시 // className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시
const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || !!style?.height; const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || /\bh-\d/.test(className ?? "") || !!style?.height;
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger

View File

@ -365,6 +365,14 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
fetchEntityJoinColumns(); fetchEntityJoinColumns();
}, [entityJoinTargetTable]); }, [entityJoinTargetTable]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
(updates: Partial<V2RepeaterConfig>) => {
onChange({ ...config, ...updates });
},
[config, onChange],
);
// Entity 조인 컬럼 토글 (추가/제거) // Entity 조인 컬럼 토글 (추가/제거)
const toggleEntityJoinColumn = useCallback( const toggleEntityJoinColumn = useCallback(
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => { (joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
@ -423,14 +431,6 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
[config.entityJoins], [config.entityJoins],
); );
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
(updates: Partial<V2RepeaterConfig>) => {
onChange({ ...config, ...updates });
},
[config, onChange],
);
const updateDataSource = useCallback( const updateDataSource = useCallback(
(field: string, value: any) => { (field: string, value: any) => {
updateConfig({ updateConfig({

View File

@ -41,4 +41,5 @@ export const MENU_ICONS = {
STATISTICS: ["통계", "분석", "리포트", "차트"], STATISTICS: ["통계", "분석", "리포트", "차트"],
SETTINGS: ["설정", "관리", "시스템"], SETTINGS: ["설정", "관리", "시스템"],
DATAFLOW: ["데이터", "흐름", "관계", "연결"], DATAFLOW: ["데이터", "흐름", "관계", "연결"],
AI: ["AI", "어시스턴트", "챗봇", "LLM"],
} as const; } as const;

View File

@ -0,0 +1,50 @@
# AI 어시스턴트 메뉴 등록 가이드 (VEXPLOR)
AI 어시스턴트는 **VEXPLOR와 같은 서비스/같은 포트**로 동작합니다.
프론트는 `/api/ai/v1` 로 호출하고, backend-node가 AI 서비스(기본 3100 포트)로 프록시합니다.
## 서비스 기동
- **AI API**: `ERP-node/ai-assistant` 에서 `npm install``npm start` (포트 3100)
- **backend-node**: `npm run dev` (8080)
- **frontend**: `npm run dev` (9771)
별도 포트/도메인 설정 없이 브라우저에서는 **localhost:9771** 만 사용하면 됩니다.
---
## VEXPLOR 메뉴 URL 목록 (전체 탑재)
대메뉴 예: **AI 서비스** / **AI**
소메뉴는 아래 표의 **메뉴명**과 **URL**로 등록하면 됩니다. (메뉴명에 "AI", "어시스턴트", "챗봇", "LLM" 포함 시 사이드바에 Bot 아이콘 표시)
### 일반 메뉴
| 메뉴명 | URL (메뉴 관리에 입력할 값) |
|-------------|-------------------------------|
| AI 채팅 | /admin/aiAssistant/chat |
| 대시보드 | /admin/aiAssistant/dashboard |
| API 키 관리 | /admin/aiAssistant/api-keys |
| API 테스트 | /admin/aiAssistant/api-test |
| 내 사용량 | /admin/aiAssistant/usage |
| 대화 이력 | /admin/aiAssistant/history |
| 설정 | /admin/aiAssistant/settings |
### 관리자 메뉴
| 메뉴명 | URL (메뉴 관리에 입력할 값) |
|------------------|------------------------------------|
| 사용자 관리 | /admin/aiAssistant/admin/users |
| LLM 관리 | /admin/aiAssistant/admin/providers |
| LLM 사용량 통계 | /admin/aiAssistant/admin/usage-stats |
---
## 등록 순서 예시
1. **대메뉴**: 메뉴명 `AI 서비스`, URL은 비우거나 `#` (자식만 사용할 경우)
2. **소메뉴**: 위 표에서 필요한 것만 추가
- 예: 메뉴명 `대시보드`, URL `/admin/aiAssistant/dashboard`
- 예: 메뉴명 `API 키 관리`, URL `/admin/aiAssistant/api-keys`
이렇게 등록하면 VEXPLOR 사이드바에서 각 메뉴 클릭 시 해당 AI 어시스턴트 화면이 열립니다.

View File

@ -10,7 +10,7 @@
* - pop-table * - pop-table
*/ */
import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button"; import type { ButtonMainAction, ButtonTask } from "@/lib/registry/pop-components/pop-button";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { dataApi } from "@/lib/api/data"; import { dataApi } from "@/lib/api/data";
@ -197,3 +197,156 @@ export async function executePopAction(
return { success: false, error: message }; return { success: false, error: message };
} }
} }
// ========================================
// v2: 작업 목록 실행
// ========================================
/** 수집된 데이터 구조 */
export interface CollectedPayload {
items?: Record<string, unknown>[];
fieldValues?: Record<string, unknown>;
mappings?: {
cardList?: Record<string, unknown> | null;
field?: Record<string, unknown> | null;
};
cartChanges?: {
toCreate?: Record<string, unknown>[];
toUpdate?: Record<string, unknown>[];
toDelete?: (string | number)[];
};
}
/** 작업 목록 실행 옵션 */
interface ExecuteTaskListOptions {
publish: PublishFn;
componentId: string;
collectedData?: CollectedPayload;
}
/**
* .
* (data-save, data-update, data-delete, cart-save)
* API .
* (modal-open, navigate ) .
*/
export async function executeTaskList(
tasks: ButtonTask[],
options: ExecuteTaskListOptions,
): Promise<ActionResult> {
const { publish, componentId, collectedData } = options;
// 데이터 작업과 프론트 전용 작업 분리
const DATA_TASK_TYPES = new Set(["data-save", "data-update", "data-delete", "cart-save"]);
const dataTasks = tasks.filter((t) => DATA_TASK_TYPES.has(t.type));
const frontTasks = tasks.filter((t) => !DATA_TASK_TYPES.has(t.type));
let backendData: Record<string, unknown> | null = null;
try {
// 1. 데이터 작업이 있으면 백엔드에 일괄 전송
if (dataTasks.length > 0) {
const result = await apiClient.post("/pop/execute-action", {
tasks: dataTasks,
data: {
items: collectedData?.items ?? [],
fieldValues: collectedData?.fieldValues ?? {},
},
mappings: collectedData?.mappings ?? {},
cartChanges: collectedData?.cartChanges,
});
if (!result.data?.success) {
return {
success: false,
error: result.data?.message || "데이터 작업 실행에 실패했습니다.",
data: result.data,
};
}
backendData = result.data;
}
const innerData = (backendData as Record<string, unknown>)?.data as Record<string, unknown> | undefined;
const generatedCodes = innerData?.generatedCodes as
Array<{ targetColumn: string; code: string; showResultModal?: boolean }> | undefined;
const hasResultModal = generatedCodes?.some((g) => g.showResultModal);
// 2. 프론트엔드 전용 작업 순차 실행 (채번 모달이 있으면 navigate 보류)
const deferredNavigateTasks: ButtonTask[] = [];
for (const task of frontTasks) {
switch (task.type) {
case "modal-open":
publish("__pop_modal_open__", {
modalId: task.modalScreenId,
title: task.modalTitle,
mode: task.modalMode,
items: task.modalItems,
});
break;
case "navigate":
if (hasResultModal) {
deferredNavigateTasks.push(task);
} else if (task.targetScreenId) {
publish("__pop_navigate__", { screenId: task.targetScreenId, params: task.params });
}
break;
case "close-modal":
publish("__pop_close_modal__");
break;
case "refresh":
if (!hasResultModal) {
publish("__pop_refresh__");
}
break;
case "api-call": {
if (!task.apiEndpoint) break;
const method = (task.apiMethod || "POST").toUpperCase();
switch (method) {
case "GET":
await apiClient.get(task.apiEndpoint);
break;
case "PUT":
await apiClient.put(task.apiEndpoint);
break;
case "DELETE":
await apiClient.delete(task.apiEndpoint);
break;
default:
await apiClient.post(task.apiEndpoint);
}
break;
}
case "custom-event":
if (task.eventName) {
publish(task.eventName, task.eventPayload ?? {});
}
break;
}
}
// 3. 완료 이벤트
if (!hasResultModal) {
publish(`__comp_output__${componentId}__action_completed`, {
action: "task-list",
success: true,
});
}
return {
success: true,
data: {
generatedCodes,
deferredTasks: deferredNavigateTasks,
...(backendData ?? {}),
},
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "작업 실행 중 오류가 발생했습니다.";
return { success: false, error: message };
}
}

View File

@ -26,5 +26,8 @@ export { useConnectionResolver } from "./useConnectionResolver";
export { useCartSync } from "./useCartSync"; export { useCartSync } from "./useCartSync";
export type { UseCartSyncReturn } from "./useCartSync"; export type { UseCartSyncReturn } from "./useCartSync";
// 설정 패널 접기/펼치기 상태 관리
export { useCollapsibleSections } from "./useCollapsibleSections";
// SQL 빌더 유틸 (고급 사용 시) // SQL 빌더 유틸 (고급 사용 시)
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder"; export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";

View File

@ -34,6 +34,12 @@ import type {
// ===== 반환 타입 ===== // ===== 반환 타입 =====
export interface CartChanges {
toCreate: Record<string, unknown>[];
toUpdate: Record<string, unknown>[];
toDelete: (string | number)[];
}
export interface UseCartSyncReturn { export interface UseCartSyncReturn {
cartItems: CartItemWithId[]; cartItems: CartItemWithId[];
savedItems: CartItemWithId[]; savedItems: CartItemWithId[];
@ -48,6 +54,7 @@ export interface UseCartSyncReturn {
isItemInCart: (rowKey: string) => boolean; isItemInCart: (rowKey: string) => boolean;
getCartItem: (rowKey: string) => CartItemWithId | undefined; getCartItem: (rowKey: string) => CartItemWithId | undefined;
getChanges: (selectedColumns?: string[]) => CartChanges;
saveToDb: (selectedColumns?: string[]) => Promise<boolean>; saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
loadFromDb: () => Promise<void>; loadFromDb: () => Promise<void>;
resetToSaved: () => void; resetToSaved: () => void;
@ -252,6 +259,29 @@ export function useCartSync(
[cartItems], [cartItems],
); );
// ----- diff 계산 (백엔드 전송용) -----
const getChanges = useCallback((selectedColumns?: string[]): CartChanges => {
const currentScreenId = screenIdRef.current;
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDeleteItems = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
const toCreateItems = cartItems.filter((c) => !c.cartId);
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
const toUpdateItems = cartItems.filter((c) => {
if (!c.cartId) return false;
const saved = savedMap.get(c.rowKey);
if (!saved) return false;
return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status;
});
return {
toCreate: toCreateItems.map((item) => cartItemToDbRecord(item, currentScreenId, selectedColumns)),
toUpdate: toUpdateItems.map((item) => ({ id: item.cartId, ...cartItemToDbRecord(item, currentScreenId, selectedColumns) })),
toDelete: toDeleteItems.map((item) => item.cartId!),
};
}, [cartItems, savedItems]);
// ----- DB 저장 (일괄) ----- // ----- DB 저장 (일괄) -----
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => { const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
setSyncStatus("saving"); setSyncStatus("saving");
@ -324,6 +354,7 @@ export function useCartSync(
updateItemQuantity, updateItemQuantity,
isItemInCart, isItemInCart,
getCartItem, getCartItem,
getChanges,
saveToDb, saveToDb,
loadFromDb, loadFromDb,
resetToSaved, resetToSaved,

View File

@ -0,0 +1,58 @@
import { useState, useCallback, useRef } from "react";
/**
* / sessionStorage로
*
* - 상태: 모든
* -
* -
*
* @param storageKey sessionStorage (: "pop-card-list")
*/
export function useCollapsibleSections(storageKey: string) {
const fullKey = `pop-config-sections-${storageKey}`;
const [openSections, setOpenSections] = useState<Set<string>>(() => {
if (typeof window === "undefined") return new Set<string>();
try {
const saved = sessionStorage.getItem(fullKey);
if (saved) return new Set<string>(JSON.parse(saved));
} catch {}
return new Set<string>();
});
const openSectionsRef = useRef(openSections);
openSectionsRef.current = openSections;
const persist = useCallback(
(next: Set<string>) => {
try {
sessionStorage.setItem(fullKey, JSON.stringify([...next]));
} catch {}
},
[fullKey],
);
const isOpen = useCallback(
(key: string) => openSectionsRef.current.has(key),
[],
);
const toggle = useCallback(
(key: string) => {
setOpenSections((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
persist(next);
return next;
});
},
[persist],
);
return { isOpen, toggle };
}

View File

@ -0,0 +1,127 @@
/**
* AI API
* - VEXPLOR와 / : /api/ai/v1 (Next backend-node AI )
* - sessionStorage 'ai-assistant-auth' (VEXPLOR )
*/
import axios, { AxiosError } from "axios";
import type { AiAssistantAuthState } from "./types";
const STORAGE_KEY = "ai-assistant-auth";
/** 같은 오리진 기준 AI API prefix (backend-node가 /api/ai/v1 을 AI 서비스로 프록시) */
function getBaseUrl(): string {
if (typeof window === "undefined") return "";
return "/api/ai/v1";
}
export function getAiAssistantAuth(): AiAssistantAuthState | null {
if (typeof window === "undefined") return null;
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as AiAssistantAuthState) : null;
} catch {
return null;
}
}
export function setAiAssistantAuth(state: AiAssistantAuthState | null): void {
if (typeof window === "undefined") return;
if (state) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
else sessionStorage.removeItem(STORAGE_KEY);
}
export function getAiAssistantAccessToken(): string | null {
return getAiAssistantAuth()?.accessToken ?? null;
}
let refreshing = false;
const queue: Array<{ resolve: (t: string) => void; reject: (e: unknown) => void }> = [];
function processQueue(error: unknown, token: string | null) {
queue.forEach((p) => (error ? p.reject(error) : p.resolve(token!)));
queue.length = 0;
}
const client = axios.create({
baseURL: "",
headers: { "Content-Type": "application/json" },
});
client.interceptors.request.use((config) => {
const base = getBaseUrl();
if (base) config.baseURL = base;
const token = getAiAssistantAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
client.interceptors.response.use(
(res) => res,
async (err: AxiosError) => {
const original = err.config as typeof err.config & { _retry?: boolean };
const isAuth = original?.url?.includes("/auth/");
if (isAuth || err.response?.status !== 401 || original?._retry) {
return Promise.reject(err);
}
if (refreshing) {
return new Promise<string>((resolve, reject) => {
queue.push({ resolve, reject });
})
.then((token) => {
if (original.headers) original.headers.Authorization = `Bearer ${token}`;
return client(original);
})
.catch((e) => Promise.reject(e));
}
original._retry = true;
refreshing = true;
const auth = getAiAssistantAuth();
try {
if (!auth?.refreshToken) throw new Error("No refresh token");
const base = getBaseUrl();
const { data } = await axios.post<{ data: { accessToken: string } }>(
`${base}/auth/refresh`,
{ refreshToken: auth.refreshToken },
{ headers: { "Content-Type": "application/json" } },
);
const newToken = data?.data?.accessToken;
if (!newToken) throw new Error("No access token");
const newState: AiAssistantAuthState = {
...auth,
accessToken: newToken,
};
setAiAssistantAuth(newState);
processQueue(null, newToken);
if (original.headers) original.headers.Authorization = `Bearer ${newToken}`;
return client(original);
} catch (e) {
processQueue(e, null);
setAiAssistantAuth(null);
return Promise.reject(err);
} finally {
refreshing = false;
}
},
);
/** AI 어시스턴트 로그인 (이메일/비밀번호) */
export async function loginAiAssistant(
email: string,
password: string,
): Promise<AiAssistantAuthState> {
const base = getBaseUrl();
const { data } = await axios.post<{
data: { user: AiAssistantAuthState["user"]; accessToken: string; refreshToken: string };
}>(`${base}/auth/login`, { email, password }, { headers: { "Content-Type": "application/json" }, withCredentials: true });
const state: AiAssistantAuthState = {
user: data.data.user,
accessToken: data.data.accessToken,
refreshToken: data.data.refreshToken,
};
setAiAssistantAuth(state);
return state;
}
export default client;

View File

@ -0,0 +1,8 @@
export { default as aiAssistantApi } from "./client";
export {
getAiAssistantAuth,
setAiAssistantAuth,
getAiAssistantAccessToken,
loginAiAssistant,
} from "./client";
export * from "./types";

View File

@ -0,0 +1,70 @@
/**
* AI API / (workspace_assistant )
*/
export interface AiAssistantUser {
id: number;
email: string;
name: string;
role: "user" | "admin";
status?: string;
plan?: string;
monthlyTokenLimit?: number;
createdAt?: string;
}
export interface AiAssistantAuthState {
user: AiAssistantUser;
accessToken: string;
refreshToken: string;
}
export interface UsageSummary {
usage?: {
today?: { tokens?: number; requests?: number };
monthly?: { totalTokens?: number; totalCost?: number };
};
limit?: { monthly?: number };
plan?: string;
}
export interface ApiKeyItem {
id: number;
name: string;
keyPrefix: string;
status: string;
usageCount?: number;
lastUsedAt?: string | null;
createdAt: string;
}
export interface UsageLogItem {
id: number;
success: boolean;
providerName?: string;
modelName?: string;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
costUsd?: number;
responseTimeMs?: number;
createdAt: string;
}
export interface AdminStats {
users?: { total?: number; active?: number };
apiKeys?: { total?: number };
providers?: { active?: number };
}
export interface LlmProvider {
id: number;
displayName: string;
modelName: string;
apiKey?: string;
priority: number;
maxTokens?: number;
temperature?: number;
isActive: boolean;
costPer1kInputTokens?: number;
}

View File

@ -0,0 +1,98 @@
/**
* API
*/
import { apiClient } from "./client";
/** 테이블 계층 레벨 설정 */
export interface TableLevel {
tableName: string;
label: string;
parentFkColumn?: string;
parentRefColumn?: string;
upsertMode: "upsert" | "insert";
upsertKeyColumns?: string[];
columns: ColumnDef[];
}
/** 컬럼 정의 */
export interface ColumnDef {
dbColumn: string;
excelHeader: string;
required: boolean;
defaultValue?: any;
}
/** 업로드 모드 정의 */
export interface UploadMode {
id: string;
label: string;
description: string;
activeLevels: number[];
}
/** 테이블 체인 설정 */
export interface TableChainConfig {
id: string;
name: string;
description: string;
levels: TableLevel[];
uploadModes: UploadMode[];
}
/** 레벨별 결과 */
export interface LevelResult {
tableName: string;
inserted: number;
updated: number;
}
/** 업로드 결과 */
export interface MultiTableUploadResult {
success: boolean;
results: LevelResult[];
totalRows: number;
errors: string[];
}
/**
* TableChainConfig를
* DB FK +
*/
export async function autoDetectMultiTableConfig(
rootTable: string,
screenId?: number
): Promise<{ success: boolean; data?: TableChainConfig; message?: string }> {
try {
const params: Record<string, any> = { rootTable };
if (screenId) params.screenId = screenId;
const response = await apiClient.get("/data/multi-table/auto-detect", {
params,
});
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message,
};
}
}
/**
*
*/
export async function uploadMultiTableExcel(params: {
config: TableChainConfig;
modeId: string;
rows: Record<string, any>[];
}): Promise<{ success: boolean; data?: MultiTableUploadResult; message?: string }> {
try {
const response = await apiClient.post("/data/multi-table/upload", params);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message,
};
}
}

View File

@ -1235,6 +1235,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (!leftItem) return; if (!leftItem) return;
setIsLoadingRight(true); setIsLoadingRight(true);
setRightData([]);
try { try {
// detail / join 모두 동일한 필터링 로직 사용 // detail / join 모두 동일한 필터링 로직 사용
// (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함) // (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함)
@ -1343,34 +1344,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
enableEntityJoin: true, enableEntityJoin: true,
size: 1000, size: 1000,
companyCodeOverride: companyCode, companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns, // 🆕 Entity 조인 컬럼 전달 additionalJoinColumns: rightJoinColumns,
}); });
console.log("🔗 [분할패널] 복합키 조회 결과:", result); console.log("🔗 [분할패널] 복합키 조회 결과:", result);
// 추가 dataFilter 적용 setRightData(result.data || []);
let filteredData = result.data || [];
const dataFilter = componentConfig.rightPanel?.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
filteredData = filteredData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => {
const value = item[cond.column];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
return value === condValue;
case "notEquals":
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
default:
return true;
}
});
});
}
setRightData(filteredData);
} else { } else {
// 단일키 (하위 호환성) → entityJoinApi 사용으로 전환 (entity 조인 컬럼 지원) // 단일키 (하위 호환성) → entityJoinApi 사용으로 전환 (entity 조인 컬럼 지원)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
@ -1380,9 +1359,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const leftValue = leftItem[leftColumn]; const leftValue = leftItem[leftColumn];
const { entityJoinApi } = await import("@/lib/api/entityJoin"); const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 단일키를 복합키 형식으로 변환 console.log("🔗 [분할패널] 단일키 조건:", { leftColumn, rightColumn, leftValue, rightTableName });
// 단일키를 복합키 형식으로 변환 (entity 컬럼이므로 equals 연산자 필수)
const searchConditions: Record<string, any> = {}; const searchConditions: Record<string, any> = {};
searchConditions[rightColumn] = leftValue; searchConditions[rightColumn] = { value: leftValue, operator: "equals" };
// Entity 조인 컬럼 추출 // Entity 조인 컬럼 추출
const rightJoinColumnsLegacy = extractAdditionalJoinColumns( const rightJoinColumnsLegacy = extractAdditionalJoinColumns(
@ -1401,35 +1382,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
additionalJoinColumns: rightJoinColumnsLegacy, additionalJoinColumns: rightJoinColumnsLegacy,
}); });
let filteredDataLegacy = result.data || []; setRightData(result.data || []);
// 데이터 필터 적용
const dataFilterLegacy = componentConfig.rightPanel?.dataFilter;
if (dataFilterLegacy?.enabled && dataFilterLegacy.conditions?.length > 0) {
filteredDataLegacy = filteredDataLegacy.filter((item: any) => {
return dataFilterLegacy.conditions.every((cond: any) => {
const value = item[cond.column];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
return value === condValue;
case "notEquals":
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
default:
return true;
}
});
});
}
setRightData(filteredDataLegacy || []);
} }
} }
} }
} catch (error) { } catch (error) {
console.error("우측 데이터 로드 실패:", error); console.error("우측 데이터 로드 실패:", error);
setRightData([]);
toast({ toast({
title: "데이터 로드 실패", title: "데이터 로드 실패",
description: "우측 패널 데이터를 불러올 수 없습니다.", description: "우측 패널 데이터를 불러올 수 없습니다.",

File diff suppressed because it is too large Load Diff

View File

@ -702,11 +702,13 @@ export function PopCardListComponent({
} }
: null; : null;
const cartChanges = cart.isDirty ? cart.getChanges() : undefined;
const response: CollectedDataResponse = { const response: CollectedDataResponse = {
requestId: request?.requestId ?? "", requestId: request?.requestId ?? "",
componentId: componentId, componentId: componentId,
componentType: "pop-card-list", componentType: "pop-card-list",
data: { items: selectedItems }, data: { items: selectedItems, cartChanges },
mapping, mapping,
}; };
@ -714,7 +716,7 @@ export function PopCardListComponent({
} }
); );
return unsub; return unsub;
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]); }, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys, cart]);
// 장바구니 목록 모드: 선택 항목 이벤트 발행 // 장바구니 목록 모드: 선택 항목 이벤트 발행
useEffect(() => { useEffect(() => {
@ -728,14 +730,13 @@ export function PopCardListComponent({
gap: `${scaled.gap}px`, gap: `${scaled.gap}px`,
...(isHorizontalMode ...(isHorizontalMode
? { ? {
gridTemplateRows: `repeat(${gridRows}, ${scaled.cardHeight}px)`, gridTemplateRows: `repeat(${gridRows}, minmax(${scaled.cardHeight}px, auto))`,
gridAutoFlow: "column", gridAutoFlow: "column",
gridAutoColumns: `${scaled.cardWidth}px`, gridAutoColumns: `${scaled.cardWidth}px`,
} }
: { : {
// 세로 모드: 1fr 비율 기반으로 컨테이너 너비 초과 방지
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`, gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gridAutoRows: `${scaled.cardHeight}px`, gridAutoRows: `minmax(${scaled.cardHeight}px, auto)`,
}), }),
}; };
@ -998,16 +999,18 @@ function Card({
return 999999; return 999999;
}, [limitCol, row]); }, [limitCol, row]);
// 제한 컬럼이 있으면 최대값으로 자동 초기화 // 제한 컬럼이 있으면 최대값으로 자동 초기화 (장바구니 목록 모드에서는 cart 수량 유지)
useEffect(() => { useEffect(() => {
if (isCartListMode) return;
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) { if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
setInputValue(effectiveMax); setInputValue(effectiveMax);
} }
}, [effectiveMax, inputField?.enabled, limitCol]); }, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]);
const hasPackageEntries = packageEntries.length > 0;
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
height: `${scaled.cardHeight}px`, minHeight: `${scaled.cardHeight}px`,
overflow: "hidden",
}; };
const headerStyle: React.CSSProperties = { const headerStyle: React.CSSProperties = {
@ -1113,7 +1116,7 @@ function Card({
return ( return (
<div <div
className={`relative cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`} className={`relative flex cursor-pointer flex-col rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
style={cardStyle} style={cardStyle}
onClick={handleCardClick} onClick={handleCardClick}
role="button" role="button"
@ -1154,7 +1157,7 @@ function Card({
)} )}
{/* 본문 영역 */} {/* 본문 영역 */}
<div className="flex" style={bodyStyle}> <div className="flex flex-1 overflow-hidden" style={bodyStyle}>
{/* 이미지 (왼쪽) */} {/* 이미지 (왼쪽) */}
{image?.enabled && ( {image?.enabled && (
<div className="shrink-0"> <div className="shrink-0">
@ -1196,7 +1199,7 @@ function Card({
{/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */} {/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */}
{(inputField?.enabled || cartAction || isCartListMode) && ( {(inputField?.enabled || cartAction || isCartListMode) && (
<div <div
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2" className="ml-2 flex shrink-0 flex-col items-stretch justify-start gap-2"
style={{ minWidth: "100px" }} style={{ minWidth: "100px" }}
> >
{/* 수량 버튼 (입력 필드 ON일 때만) */} {/* 수량 버튼 (입력 필드 ON일 때만) */}
@ -1265,6 +1268,37 @@ function Card({
)} )}
</div> </div>
{/* 포장 요약 바: 본문 아래에 표시 */}
{hasPackageEntries && (
<div className="border-t bg-emerald-50">
{packageEntries.map((entry, idx) => (
<div
key={idx}
className="flex items-center justify-between px-3 py-1.5"
>
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white">
</span>
<Package className="h-4 w-4 text-emerald-600" />
<span
className="font-medium text-emerald-700"
style={{ fontSize: `${scaled.bodyTextSize}px` }}
>
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}
</span>
</div>
<span
className="font-bold text-emerald-700"
style={{ fontSize: `${scaled.bodyTextSize}px` }}
>
= {entry.totalQuantity.toLocaleString()}{inputField?.unit || "EA"}
</span>
</div>
))}
</div>
)}
{inputField?.enabled && ( {inputField?.enabled && (
<NumberInputModal <NumberInputModal
open={isModalOpen} open={isModalOpen}
@ -1304,7 +1338,7 @@ function FieldRow({
// 구조화된 수식 우선 // 구조화된 수식 우선
if (field.formulaLeft && field.formulaOperator) { if (field.formulaLeft && field.formulaOperator) {
const rightVal = field.formulaRightType === "input" const rightVal = (field.formulaRightType || "input") === "input"
? (inputValue ?? 0) ? (inputValue ?? 0)
: Number(row[field.formulaRight || ""] ?? 0); : Number(row[field.formulaRight || ""] ?? 0);
const leftVal = Number(row[field.formulaLeft] ?? 0); const leftVal = Number(row[field.formulaLeft] ?? 0);

View File

@ -10,6 +10,7 @@
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react"; import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react";
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
import type { GridMode } from "@/components/pop/designer/types/pop-layout"; import type { GridMode } from "@/components/pop/designer/types/pop-layout";
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout"; import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -135,6 +136,7 @@ const COLOR_OPTIONS = [
export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) { export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) {
const [activeTab, setActiveTab] = useState<"basic" | "template">("basic"); const [activeTab, setActiveTab] = useState<"basic" | "template">("basic");
const sections = useCollapsibleSections("pop-card-list");
const cfg: PopCardListConfig = config || DEFAULT_CONFIG; const cfg: PopCardListConfig = config || DEFAULT_CONFIG;
@ -184,6 +186,7 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
onUpdate={updateConfig} onUpdate={updateConfig}
currentMode={currentMode} currentMode={currentMode}
currentColSpan={currentColSpan} currentColSpan={currentColSpan}
sections={sections}
/> />
)} )}
{activeTab === "template" && ( {activeTab === "template" && (
@ -195,7 +198,7 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
</div> </div>
</div> </div>
) : ( ) : (
<CardTemplateTab config={cfg} onUpdate={updateConfig} /> <CardTemplateTab config={cfg} onUpdate={updateConfig} sections={sections} />
) )
)} )}
</div> </div>
@ -205,16 +208,20 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) ===== // ===== 기본 설정 탭 (테이블 + 레이아웃 통합) =====
type SectionsApi = { isOpen: (key: string) => boolean; toggle: (key: string) => void };
function BasicSettingsTab({ function BasicSettingsTab({
config, config,
onUpdate, onUpdate,
currentMode, currentMode,
currentColSpan, currentColSpan,
sections,
}: { }: {
config: PopCardListConfig; config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void; onUpdate: (partial: Partial<PopCardListConfig>) => void;
currentMode?: GridMode; currentMode?: GridMode;
currentColSpan?: number; currentColSpan?: number;
sections: SectionsApi;
}) { }) {
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
const [tables, setTables] = useState<TableInfo[]>([]); const [tables, setTables] = useState<TableInfo[]>([]);
@ -321,7 +328,7 @@ function BasicSettingsTab({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 장바구니 목록 모드 */} {/* 장바구니 목록 모드 */}
<CollapsibleSection title="장바구니 목록 모드" defaultOpen={isCartListMode}> <CollapsibleSection sectionKey="basic-cart-mode" title="장바구니 목록 모드" sections={sections}>
<CartListModeSection <CartListModeSection
cartListMode={config.cartListMode} cartListMode={config.cartListMode}
onUpdate={(cartListMode) => onUpdate({ cartListMode })} onUpdate={(cartListMode) => onUpdate({ cartListMode })}
@ -330,7 +337,7 @@ function BasicSettingsTab({
{/* 테이블 선택 (장바구니 모드 시 숨김) */} {/* 테이블 선택 (장바구니 모드 시 숨김) */}
{!isCartListMode && ( {!isCartListMode && (
<CollapsibleSection title="테이블 선택" defaultOpen> <CollapsibleSection sectionKey="basic-table" title="테이블 선택" sections={sections}>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label className="text-[10px] text-muted-foreground"> </Label> <Label className="text-[10px] text-muted-foreground"> </Label>
@ -365,7 +372,9 @@ function BasicSettingsTab({
{/* 조인 설정 (장바구니 모드 시 숨김) */} {/* 조인 설정 (장바구니 모드 시 숨김) */}
{!isCartListMode && dataSource.tableName && ( {!isCartListMode && dataSource.tableName && (
<CollapsibleSection <CollapsibleSection
sectionKey="basic-join"
title="조인 설정" title="조인 설정"
sections={sections}
badge={ badge={
dataSource.joins && dataSource.joins.length > 0 dataSource.joins && dataSource.joins.length > 0
? `${dataSource.joins.length}` ? `${dataSource.joins.length}`
@ -383,7 +392,9 @@ function BasicSettingsTab({
{/* 정렬 기준 (장바구니 모드 시 숨김) */} {/* 정렬 기준 (장바구니 모드 시 숨김) */}
{!isCartListMode && dataSource.tableName && ( {!isCartListMode && dataSource.tableName && (
<CollapsibleSection <CollapsibleSection
sectionKey="basic-sort"
title="정렬 기준" title="정렬 기준"
sections={sections}
badge={ badge={
dataSource.sort dataSource.sort
? Array.isArray(dataSource.sort) ? Array.isArray(dataSource.sort)
@ -403,7 +414,9 @@ function BasicSettingsTab({
{/* 필터 기준 (장바구니 모드 시 숨김) */} {/* 필터 기준 (장바구니 모드 시 숨김) */}
{!isCartListMode && dataSource.tableName && ( {!isCartListMode && dataSource.tableName && (
<CollapsibleSection <CollapsibleSection
sectionKey="basic-filter"
title="필터 기준" title="필터 기준"
sections={sections}
badge={ badge={
dataSource.filters && dataSource.filters.length > 0 dataSource.filters && dataSource.filters.length > 0
? `${dataSource.filters.length}` ? `${dataSource.filters.length}`
@ -421,7 +434,9 @@ function BasicSettingsTab({
{/* 저장 매핑 (장바구니 모드일 때만) */} {/* 저장 매핑 (장바구니 모드일 때만) */}
{isCartListMode && ( {isCartListMode && (
<CollapsibleSection <CollapsibleSection
sectionKey="basic-save-mapping"
title="저장 매핑" title="저장 매핑"
sections={sections}
badge={ badge={
config.saveMapping?.mappings && config.saveMapping.mappings.length > 0 config.saveMapping?.mappings && config.saveMapping.mappings.length > 0
? `${config.saveMapping.mappings.length}` ? `${config.saveMapping.mappings.length}`
@ -437,7 +452,7 @@ function BasicSettingsTab({
)} )}
{/* 레이아웃 설정 */} {/* 레이아웃 설정 */}
<CollapsibleSection title="레이아웃 설정" defaultOpen> <CollapsibleSection sectionKey="basic-layout" title="레이아웃 설정" sections={sections}>
<div className="space-y-3"> <div className="space-y-3">
{modeLabel && ( {modeLabel && (
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5"> <div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5">
@ -526,9 +541,11 @@ function BasicSettingsTab({
function CardTemplateTab({ function CardTemplateTab({
config, config,
onUpdate, onUpdate,
sections,
}: { }: {
config: PopCardListConfig; config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void; onUpdate: (partial: Partial<PopCardListConfig>) => void;
sections: SectionsApi;
}) { }) {
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
const template = config.cardTemplate || DEFAULT_TEMPLATE; const template = config.cardTemplate || DEFAULT_TEMPLATE;
@ -634,7 +651,7 @@ function CardTemplateTab({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 헤더 설정 */} {/* 헤더 설정 */}
<CollapsibleSection title="헤더 설정" defaultOpen> <CollapsibleSection sectionKey="tpl-header" title="헤더 설정" sections={sections}>
<HeaderSettingsSection <HeaderSettingsSection
header={template.header || DEFAULT_HEADER} header={template.header || DEFAULT_HEADER}
columnGroups={columnGroups} columnGroups={columnGroups}
@ -643,7 +660,7 @@ function CardTemplateTab({
</CollapsibleSection> </CollapsibleSection>
{/* 이미지 설정 */} {/* 이미지 설정 */}
<CollapsibleSection title="이미지 설정" defaultOpen> <CollapsibleSection sectionKey="tpl-image" title="이미지 설정" sections={sections}>
<ImageSettingsSection <ImageSettingsSection
image={template.image || DEFAULT_IMAGE} image={template.image || DEFAULT_IMAGE}
columnGroups={columnGroups} columnGroups={columnGroups}
@ -653,9 +670,10 @@ function CardTemplateTab({
{/* 본문 필드 */} {/* 본문 필드 */}
<CollapsibleSection <CollapsibleSection
sectionKey="tpl-body"
title="본문 필드" title="본문 필드"
sections={sections}
badge={`${template.body?.fields?.length || 0}`} badge={`${template.body?.fields?.length || 0}`}
defaultOpen
> >
<BodyFieldsSection <BodyFieldsSection
body={template.body || DEFAULT_BODY} body={template.body || DEFAULT_BODY}
@ -665,7 +683,7 @@ function CardTemplateTab({
</CollapsibleSection> </CollapsibleSection>
{/* 입력 필드 설정 */} {/* 입력 필드 설정 */}
<CollapsibleSection title="입력 필드" defaultOpen={false}> <CollapsibleSection sectionKey="tpl-input" title="입력 필드" sections={sections}>
<InputFieldSettingsSection <InputFieldSettingsSection
inputField={config.inputField} inputField={config.inputField}
columns={columns} columns={columns}
@ -675,7 +693,7 @@ function CardTemplateTab({
</CollapsibleSection> </CollapsibleSection>
{/* 포장등록 설정 */} {/* 포장등록 설정 */}
<CollapsibleSection title="포장등록 (계산기)" defaultOpen={false}> <CollapsibleSection sectionKey="tpl-package" title="포장등록 (계산기)" sections={sections}>
<PackageSettingsSection <PackageSettingsSection
packageConfig={config.packageConfig} packageConfig={config.packageConfig}
onUpdate={(packageConfig) => onUpdate({ packageConfig })} onUpdate={(packageConfig) => onUpdate({ packageConfig })}
@ -683,7 +701,7 @@ function CardTemplateTab({
</CollapsibleSection> </CollapsibleSection>
{/* 담기 버튼 설정 */} {/* 담기 버튼 설정 */}
<CollapsibleSection title="담기 버튼" defaultOpen={false}> <CollapsibleSection sectionKey="tpl-cart" title="담기 버튼" sections={sections}>
<CartActionSettingsSection <CartActionSettingsSection
cartAction={config.cartAction} cartAction={config.cartAction}
onUpdate={(cartAction) => onUpdate({ cartAction })} onUpdate={(cartAction) => onUpdate({ cartAction })}
@ -693,7 +711,7 @@ function CardTemplateTab({
</CollapsibleSection> </CollapsibleSection>
{/* 반응형 표시 설정 */} {/* 반응형 표시 설정 */}
<CollapsibleSection title="반응형 표시" defaultOpen={false}> <CollapsibleSection sectionKey="tpl-responsive" title="반응형 표시" sections={sections}>
<ResponsiveDisplaySection <ResponsiveDisplaySection
config={config} config={config}
onUpdate={onUpdate} onUpdate={onUpdate}
@ -769,24 +787,26 @@ function GroupedColumnSelect({
// ===== 접기/펴기 섹션 컴포넌트 ===== // ===== 접기/펴기 섹션 컴포넌트 =====
function CollapsibleSection({ function CollapsibleSection({
sectionKey,
title, title,
badge, badge,
defaultOpen = false, sections,
children, children,
}: { }: {
sectionKey: string;
title: string; title: string;
badge?: string; badge?: string;
defaultOpen?: boolean; sections: SectionsApi;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [open, setOpen] = useState(defaultOpen); const open = sections.isOpen(sectionKey);
return ( return (
<div className="rounded-md border"> <div className="rounded-md border">
<button <button
type="button" type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50" className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen(!open)} onClick={() => sections.toggle(sectionKey)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{open ? ( {open ? (
@ -2784,6 +2804,13 @@ function SaveMappingSection({
label: f.label || f.columnName, label: f.label || f.columnName,
badge: "본문", badge: "본문",
}); });
} else if (f.valueType === "formula" && f.label) {
const formulaKey = `__formula_${f.id || f.label}`;
displayed.push({
sourceField: formulaKey,
label: f.label,
badge: "수식",
});
} }
} }
if (inputFieldConfig?.enabled) { if (inputFieldConfig?.enabled) {
@ -2855,6 +2882,21 @@ function SaveMappingSection({
[mapping.mappings] [mapping.mappings]
); );
// 카드에 표시된 필드가 로드되면 매핑에 누락된 필드를 자동 추가 (매핑 안함으로)
useEffect(() => {
if (!mapping.targetTable || cardDisplayedFields.length === 0) return;
const existing = new Set(mapping.mappings.map((m) => m.sourceField));
const missing = cardDisplayedFields.filter((f) => !existing.has(f.sourceField));
if (missing.length === 0) return;
onUpdate({
...mapping,
mappings: [
...mapping.mappings,
...missing.map((f) => ({ sourceField: f.sourceField, targetColumn: "" })),
],
});
}, [cardDisplayedFields]); // eslint-disable-line react-hooks/exhaustive-deps
// 카드에 표시된 필드 중 아직 매핑되지 않은 것 // 카드에 표시된 필드 중 아직 매핑되지 않은 것
const unmappedCardFields = useMemo( const unmappedCardFields = useMemo(
() => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)), () => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)),
@ -2937,7 +2979,7 @@ function SaveMappingSection({
</div> </div>
{isCartMeta(entry.sourceField) ? ( {isCartMeta(entry.sourceField) ? (
!badge && <span className="text-[9px] text-muted-foreground"></span> !badge && <span className="text-[9px] text-muted-foreground"></span>
) : ( ) : entry.sourceField.startsWith("__formula_") ? null : (
<span className="truncate text-[9px] text-muted-foreground"> <span className="truncate text-[9px] text-muted-foreground">
{entry.sourceField} {entry.sourceField}
</span> </span>

View File

@ -1937,7 +1937,7 @@ function PageEditor({
isPreviewing?: boolean; isPreviewing?: boolean;
onUpdateItem?: (updatedItem: DashboardItem) => void; onUpdateItem?: (updatedItem: DashboardItem) => void;
}) { }) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(false);
return ( return (
<div className="rounded-md border p-2"> <div className="rounded-md border p-2">

View File

@ -20,6 +20,7 @@ import type {
FieldSectionStyle, FieldSectionStyle,
PopFieldReadSource, PopFieldReadSource,
PopFieldAutoGenMapping, PopFieldAutoGenMapping,
SelectLinkedFilter,
} from "./types"; } from "./types";
import type { CollectDataRequest, CollectedDataResponse } from "../types"; import type { CollectDataRequest, CollectedDataResponse } from "../types";
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types"; import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
@ -60,6 +61,16 @@ export function PopFieldComponent({
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm); const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm);
const fieldIdToName = useMemo(() => {
const map: Record<string, string> = {};
for (const section of cfg.sections) {
for (const f of section.fields ?? []) {
map[f.id] = f.fieldName || f.id;
}
}
return map;
}, [cfg.sections]);
// ResizeObserver로 컨테이너 너비 감시 // ResizeObserver로 컨테이너 너비 감시
useEffect(() => { useEffect(() => {
if (typeof window === "undefined" || !containerRef.current) return; if (typeof window === "undefined" || !containerRef.current) return;
@ -211,6 +222,23 @@ export function PopFieldComponent({
columnMapping: Object.fromEntries( columnMapping: Object.fromEntries(
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn]) (cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
), ),
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
.filter((m) => m.numberingRuleId)
.map((m) => ({
numberingRuleId: m.numberingRuleId!,
targetColumn: m.targetColumn,
showResultModal: m.showResultModal,
})),
hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
.filter((m) => m.targetColumn)
.map((m) => ({
valueSource: m.valueSource,
targetColumn: m.targetColumn,
staticValue: m.staticValue,
sourceJsonColumn: m.sourceJsonColumn,
sourceJsonKey: m.sourceJsonKey,
sourceDbColumn: m.sourceDbColumn,
})),
} }
: null, : null,
}; };
@ -360,6 +388,8 @@ export function PopFieldComponent({
error={errors[fKey]} error={errors[fKey]}
onChange={handleFieldChange} onChange={handleFieldChange}
sectionStyle={section.style} sectionStyle={section.style}
allValues={allValues}
fieldIdToName={fieldIdToName}
/> />
); );
})} })}
@ -394,6 +424,8 @@ interface FieldRendererProps {
error?: string; error?: string;
onChange: (fieldName: string, value: unknown) => void; onChange: (fieldName: string, value: unknown) => void;
sectionStyle: FieldSectionStyle; sectionStyle: FieldSectionStyle;
allValues?: Record<string, unknown>;
fieldIdToName?: Record<string, string>;
} }
function FieldRenderer({ function FieldRenderer({
@ -403,6 +435,8 @@ function FieldRenderer({
error, error,
onChange, onChange,
sectionStyle, sectionStyle,
allValues,
fieldIdToName,
}: FieldRendererProps) { }: FieldRendererProps) {
const handleChange = useCallback( const handleChange = useCallback(
(v: unknown) => onChange(field.fieldName, v), (v: unknown) => onChange(field.fieldName, v),
@ -429,7 +463,7 @@ function FieldRenderer({
)} )}
</label> </label>
)} )}
{renderByType(field, value, handleChange, inputClassName)} {renderByType(field, value, handleChange, inputClassName, allValues, fieldIdToName)}
{error && <p className="text-[10px] text-destructive">{error}</p>} {error && <p className="text-[10px] text-destructive">{error}</p>}
</div> </div>
); );
@ -443,7 +477,9 @@ function renderByType(
field: PopFieldItem, field: PopFieldItem,
value: unknown, value: unknown,
onChange: (v: unknown) => void, onChange: (v: unknown) => void,
className: string className: string,
allValues?: Record<string, unknown>,
fieldIdToName?: Record<string, string>,
) { ) {
switch (field.inputType) { switch (field.inputType) {
case "text": case "text":
@ -482,6 +518,8 @@ function renderByType(
value={value} value={value}
onChange={onChange} onChange={onChange}
className={className} className={className}
allValues={allValues}
fieldIdToName={fieldIdToName}
/> />
); );
case "auto": case "auto":
@ -554,11 +592,15 @@ function SelectFieldInput({
value, value,
onChange, onChange,
className, className,
allValues,
fieldIdToName,
}: { }: {
field: PopFieldItem; field: PopFieldItem;
value: unknown; value: unknown;
onChange: (v: unknown) => void; onChange: (v: unknown) => void;
className: string; className: string;
allValues?: Record<string, unknown>;
fieldIdToName?: Record<string, string>;
}) { }) {
const [options, setOptions] = useState<{ value: string; label: string }[]>( const [options, setOptions] = useState<{ value: string; label: string }[]>(
[] []
@ -566,6 +608,30 @@ function SelectFieldInput({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const source = field.selectSource; const source = field.selectSource;
const linkedFilters = source?.linkedFilters;
const hasLinkedFilters = !!linkedFilters?.length;
// 연동 필터에서 참조하는 필드의 현재 값들을 안정적인 문자열로 직렬화
const linkedFilterKey = useMemo(() => {
if (!hasLinkedFilters || !allValues || !fieldIdToName) return "";
return linkedFilters!
.map((lf) => {
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
const val = allValues[fieldName] ?? "";
return `${lf.filterColumn}=${String(val)}`;
})
.join("&");
}, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]);
// 연동 필터의 소스 값이 모두 채워졌는지 확인
const linkedFiltersFilled = useMemo(() => {
if (!hasLinkedFilters || !allValues || !fieldIdToName) return true;
return linkedFilters!.every((lf) => {
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
const val = allValues[fieldName];
return val != null && val !== "";
});
}, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]);
useEffect(() => { useEffect(() => {
if (!source) return; if (!source) return;
@ -581,22 +647,44 @@ function SelectFieldInput({
source.valueColumn && source.valueColumn &&
source.labelColumn source.labelColumn
) { ) {
// 연동 필터가 있는데 소스 값이 비어있으면 빈 옵션 표시
if (hasLinkedFilters && !linkedFiltersFilled) {
setOptions([]);
return;
}
// 동적 필터 구성
const dynamicFilters: Record<string, string> = {};
if (hasLinkedFilters && allValues && fieldIdToName) {
for (const lf of linkedFilters!) {
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
const val = allValues[fieldName];
if (val != null && val !== "" && lf.filterColumn) {
dynamicFilters[lf.filterColumn] = String(val);
}
}
}
setLoading(true); setLoading(true);
dataApi dataApi
.getTableData(source.tableName, { .getTableData(source.tableName, {
page: 1, page: 1,
pageSize: 500, size: 500,
sortColumn: source.labelColumn, sortBy: source.labelColumn,
sortDirection: "asc", sortOrder: "asc",
...(Object.keys(dynamicFilters).length > 0 ? { filters: dynamicFilters } : {}),
}) })
.then((res) => { .then((res) => {
if (res.data?.success && Array.isArray(res.data.data?.data)) { if (Array.isArray(res.data)) {
setOptions( const seen = new Set<string>();
res.data.data.data.map((row: Record<string, unknown>) => ({ const deduped: { value: string; label: string }[] = [];
value: String(row[source.valueColumn!] ?? ""), for (const row of res.data) {
label: String(row[source.labelColumn!] ?? ""), const v = String(row[source.valueColumn!] ?? "");
})) if (!v || seen.has(v)) continue;
); seen.add(v);
deduped.push({ value: v, label: String(row[source.labelColumn!] ?? "") });
}
setOptions(deduped);
} }
}) })
.catch(() => { .catch(() => {
@ -604,7 +692,16 @@ function SelectFieldInput({
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} }
}, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]); }, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions, linkedFilterKey, linkedFiltersFilled]);
// W3: 옵션이 바뀌었을 때 현재 선택값이 유효하지 않으면 자동 초기화
useEffect(() => {
if (!hasLinkedFilters || !value || loading) return;
const currentStr = String(value);
if (options.length > 0 && !options.some((o) => o.value === currentStr)) {
onChange("");
}
}, [options, hasLinkedFilters]);
if (loading) { if (loading) {
return ( return (
@ -631,6 +728,11 @@ function SelectFieldInput({
); );
} }
// W2: 연동 필터의 소스 값이 비어있으면 안내 메시지
const emptyMessage = hasLinkedFilters && !linkedFiltersFilled
? "상위 필드를 먼저 선택하세요"
: "옵션이 없습니다";
return ( return (
<Select <Select
value={String(value ?? "")} value={String(value ?? "")}
@ -642,7 +744,7 @@ function SelectFieldInput({
<SelectContent> <SelectContent>
{options.length === 0 ? ( {options.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground"> <div className="px-2 py-1.5 text-xs text-muted-foreground">
{emptyMessage}
</div> </div>
) : ( ) : (
options.map((opt) => ( options.map((opt) => (

View File

@ -9,6 +9,7 @@
*/ */
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
import { import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@ -52,6 +53,7 @@ import type {
FieldSectionStyle, FieldSectionStyle,
FieldSectionAppearance, FieldSectionAppearance,
FieldSelectSource, FieldSelectSource,
SelectLinkedFilter,
AutoNumberConfig, AutoNumberConfig,
FieldValueSource, FieldValueSource,
PopFieldSaveMapping, PopFieldSaveMapping,
@ -74,7 +76,7 @@ import {
type ColumnInfo, type ColumnInfo,
} from "../pop-dashboard/utils/dataFetcher"; } from "../pop-dashboard/utils/dataFetcher";
import { dataApi } from "@/lib/api/data"; import { dataApi } from "@/lib/api/data";
import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule"; import { getAvailableNumberingRulesForScreen, getNumberingRules } from "@/lib/api/numberingRule";
// ======================================== // ========================================
// Props // Props
@ -213,6 +215,7 @@ export function PopFieldConfigPanel({
onUpdate={(partial) => updateSection(section.id, partial)} onUpdate={(partial) => updateSection(section.id, partial)}
onRemove={() => removeSection(section.id)} onRemove={() => removeSection(section.id)}
onMoveUp={() => moveSectionUp(idx)} onMoveUp={() => moveSectionUp(idx)}
allSections={cfg.sections}
/> />
))} ))}
<Button <Button
@ -462,6 +465,8 @@ function SaveTabContent({
// --- 자동생성 필드 로직 --- // --- 자동생성 필드 로직 ---
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]); const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]);
const [allNumberingRules, setAllNumberingRules] = useState<{ ruleId: string; ruleName: string; tableName: string }[]>([]);
const [showAllRules, setShowAllRules] = useState(false);
// 레이아웃 auto 필드 → autoGenMappings 자동 동기화 // 레이아웃 auto 필드 → autoGenMappings 자동 동기화
const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(","); const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(",");
@ -478,7 +483,7 @@ function SaveTabContent({
label: field.labelText || "", label: field.labelText || "",
targetColumn: "", targetColumn: "",
numberingRuleId: field.autoNumber?.numberingRuleId ?? "", numberingRuleId: field.autoNumber?.numberingRuleId ?? "",
showInForm: true, showInForm: false,
showResultModal: true, showResultModal: true,
}); });
} }
@ -513,6 +518,24 @@ function SaveTabContent({
} }
}, [saveTableName]); }, [saveTableName]);
useEffect(() => {
if (!showAllRules) return;
if (allNumberingRules.length > 0) return;
getNumberingRules()
.then((res) => {
if (res.success && Array.isArray(res.data)) {
setAllNumberingRules(
res.data.map((r: any) => ({
ruleId: String(r.ruleId ?? r.rule_id ?? ""),
ruleName: String(r.ruleName ?? r.rule_name ?? ""),
tableName: String(r.tableName ?? r.table_name ?? ""),
}))
);
}
})
.catch(() => setAllNumberingRules([]));
}, [showAllRules, allNumberingRules.length]);
const addAutoGenMapping = useCallback(() => { const addAutoGenMapping = useCallback(() => {
const newMapping: PopFieldAutoGenMapping = { const newMapping: PopFieldAutoGenMapping = {
id: `autogen_${Date.now()}`, id: `autogen_${Date.now()}`,
@ -626,10 +649,7 @@ function SaveTabContent({
const noFields = allFields.length === 0; const noFields = allFields.length === 0;
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); const sections = useCollapsibleSections("pop-field");
const toggleSection = useCallback((key: string) => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -644,16 +664,16 @@ function SaveTabContent({
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<div <div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2" className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("table")} onClick={() => sections.toggle("table")}
> >
{collapsed["table"] ? ( {sections.isOpen("table") ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" /> <ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)} )}
<span className="text-xs font-medium"> </span> <span className="text-xs font-medium"> </span>
</div> </div>
{!collapsed["table"] && <div className="space-y-3 border-t p-3"> {sections.isOpen("table") && <div className="space-y-3 border-t p-3">
{/* 읽기 테이블 (display 섹션이 있을 때만) */} {/* 읽기 테이블 (display 섹션이 있을 때만) */}
{hasDisplayFields && ( {hasDisplayFields && (
<> <>
@ -817,19 +837,19 @@ function SaveTabContent({
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<div <div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2" className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("read")} onClick={() => sections.toggle("read")}
> >
{collapsed["read"] ? ( {sections.isOpen("read") ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" /> <ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)} )}
<span className="text-xs font-medium"> </span> <span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
( ) ( )
</span> </span>
</div> </div>
{!collapsed["read"] && <div className="space-y-2 border-t p-3"> {sections.isOpen("read") && <div className="space-y-2 border-t p-3">
{readColumns.length === 0 ? ( {readColumns.length === 0 ? (
<p className="py-2 text-xs text-muted-foreground"> <p className="py-2 text-xs text-muted-foreground">
... ...
@ -966,19 +986,19 @@ function SaveTabContent({
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<div <div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2" className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("input")} onClick={() => sections.toggle("input")}
> >
{collapsed["input"] ? ( {sections.isOpen("input") ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" /> <ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)} )}
<span className="text-xs font-medium"> </span> <span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
( ) ( )
</span> </span>
</div> </div>
{!collapsed["input"] && <div className="space-y-2 border-t p-3"> {sections.isOpen("input") && <div className="space-y-2 border-t p-3">
{saveColumns.length === 0 ? ( {saveColumns.length === 0 ? (
<p className="py-2 text-xs text-muted-foreground"> <p className="py-2 text-xs text-muted-foreground">
... ...
@ -1028,21 +1048,23 @@ function SaveTabContent({
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<div <div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2" className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("hidden")} onClick={() => sections.toggle("hidden")}
> >
{collapsed["hidden"] ? ( {sections.isOpen("hidden") ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" /> <ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)} )}
<span className="text-xs font-medium"> </span> <span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
(UI , ) (UI , )
</span> </span>
</div> </div>
{!collapsed["hidden"] && <div className="space-y-3 border-t p-3"> {sections.isOpen("hidden") && <div className="space-y-3 border-t p-3">
{hiddenMappings.map((m) => { {hiddenMappings.map((m) => {
const isJson = m.valueSource === "json_extract"; const isJson = m.valueSource === "json_extract";
const isStatic = m.valueSource === "static";
const isDbColumn = m.valueSource === "db_column";
return ( return (
<div key={m.id} className="space-y-1.5 rounded border bg-background p-2"> <div key={m.id} className="space-y-1.5 rounded border bg-background p-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1070,6 +1092,7 @@ function SaveTabContent({
sourceDbColumn: undefined, sourceDbColumn: undefined,
sourceJsonColumn: undefined, sourceJsonColumn: undefined,
sourceJsonKey: undefined, sourceJsonKey: undefined,
staticValue: undefined,
}) })
} }
> >
@ -1079,10 +1102,10 @@ function SaveTabContent({
<SelectContent> <SelectContent>
<SelectItem value="db_column" className="text-xs">DB </SelectItem> <SelectItem value="db_column" className="text-xs">DB </SelectItem>
<SelectItem value="json_extract" className="text-xs">JSON</SelectItem> <SelectItem value="json_extract" className="text-xs">JSON</SelectItem>
<SelectItem value="static" className="text-xs"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{!isJson && ( {isDbColumn && (
<>
<Select <Select
value={m.sourceDbColumn || "__none__"} value={m.sourceDbColumn || "__none__"}
onValueChange={(v) => onValueChange={(v) =>
@ -1101,7 +1124,14 @@ function SaveTabContent({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</> )}
{isStatic && (
<Input
value={m.staticValue || ""}
onChange={(e) => updateHiddenMapping(m.id, { staticValue: e.target.value })}
placeholder="고정값 입력"
className="h-7 flex-1 text-xs"
/>
)} )}
</div> </div>
{isJson && ( {isJson && (
@ -1183,19 +1213,19 @@ function SaveTabContent({
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<div <div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2" className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("autogen")} onClick={() => sections.toggle("autogen")}
> >
{collapsed["autogen"] ? ( {sections.isOpen("autogen") ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" /> <ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)} )}
<span className="text-xs font-medium"> </span> <span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
( ) ( )
</span> </span>
</div> </div>
{!collapsed["autogen"] && <div className="space-y-3 border-t p-3"> {sections.isOpen("autogen") && <div className="space-y-3 border-t p-3">
{autoGenMappings.map((m) => { {autoGenMappings.map((m) => {
const isLinked = !!m.linkedFieldId; const isLinked = !!m.linkedFieldId;
return ( return (
@ -1248,7 +1278,19 @@ function SaveTabContent({
</Select> </Select>
</div> </div>
<div> <div>
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label> <Label className="text-[10px]"> </Label>
<div className="flex items-center gap-1">
<Switch
checked={showAllRules}
onCheckedChange={setShowAllRules}
className="h-3.5 w-7 data-[state=checked]:bg-primary [&>span]:h-2.5 [&>span]:w-2.5"
/>
<Label className="cursor-pointer text-[10px] text-muted-foreground" onClick={() => setShowAllRules(!showAllRules)}>
</Label>
</div>
</div>
<Select <Select
value={m.numberingRuleId || "__none__"} value={m.numberingRuleId || "__none__"}
onValueChange={(v) => onValueChange={(v) =>
@ -1262,11 +1304,19 @@ function SaveTabContent({
<SelectItem value="__none__" className="text-xs"> <SelectItem value="__none__" className="text-xs">
</SelectItem> </SelectItem>
{numberingRules.map((r) => ( {showAllRules
? allNumberingRules.map((r) => (
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
{r.ruleName || r.ruleId}
<span className="ml-1 text-muted-foreground">({r.tableName || "-"})</span>
</SelectItem>
))
: numberingRules.map((r) => (
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs"> <SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
{r.ruleName || r.ruleId} {r.ruleName || r.ruleId}
</SelectItem> </SelectItem>
))} ))
}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -1325,6 +1375,7 @@ interface SectionEditorProps {
onUpdate: (partial: Partial<PopFieldSection>) => void; onUpdate: (partial: Partial<PopFieldSection>) => void;
onRemove: () => void; onRemove: () => void;
onMoveUp: () => void; onMoveUp: () => void;
allSections: PopFieldSection[];
} }
function migrateStyle(style: string): FieldSectionStyle { function migrateStyle(style: string): FieldSectionStyle {
@ -1341,8 +1392,9 @@ function SectionEditor({
onUpdate, onUpdate,
onRemove, onRemove,
onMoveUp, onMoveUp,
allSections,
}: SectionEditorProps) { }: SectionEditorProps) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(true);
const resolvedStyle = migrateStyle(section.style); const resolvedStyle = migrateStyle(section.style);
const sectionFields = section.fields || []; const sectionFields = section.fields || [];
@ -1522,6 +1574,7 @@ function SectionEditor({
sectionStyle={resolvedStyle} sectionStyle={resolvedStyle}
onUpdate={(partial) => updateField(field.id, partial)} onUpdate={(partial) => updateField(field.id, partial)}
onRemove={() => removeField(field.id)} onRemove={() => removeField(field.id)}
allSections={allSections}
/> />
))} ))}
<Button <Button
@ -1549,6 +1602,7 @@ interface FieldItemEditorProps {
sectionStyle?: FieldSectionStyle; sectionStyle?: FieldSectionStyle;
onUpdate: (partial: Partial<PopFieldItem>) => void; onUpdate: (partial: Partial<PopFieldItem>) => void;
onRemove: () => void; onRemove: () => void;
allSections?: PopFieldSection[];
} }
function FieldItemEditor({ function FieldItemEditor({
@ -1556,6 +1610,7 @@ function FieldItemEditor({
sectionStyle, sectionStyle,
onUpdate, onUpdate,
onRemove, onRemove,
allSections,
}: FieldItemEditorProps) { }: FieldItemEditorProps) {
const isDisplay = sectionStyle === "display"; const isDisplay = sectionStyle === "display";
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@ -1645,9 +1700,9 @@ function FieldItemEditor({
/> />
</div> </div>
{/* 읽기전용 + 필수 (입력 폼에서만 표시) */} {/* 읽기전용 + 필수 + 데이터 연동 (입력 폼에서만 표시) */}
{!isDisplay && ( {!isDisplay && (
<div className="flex items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Switch <Switch
checked={field.readOnly || false} checked={field.readOnly || false}
@ -1666,6 +1721,29 @@ function FieldItemEditor({
/> />
<Label className="text-[10px]"></Label> <Label className="text-[10px]"></Label>
</div> </div>
{field.inputType === "select" && field.selectSource?.type === "table" && (
<div className="flex items-center gap-1.5">
<Switch
checked={!!field.selectSource?.linkedFilters?.length}
onCheckedChange={(v) => {
const src = field.selectSource ?? { type: "table" as const };
if (v) {
onUpdate({
selectSource: {
...src,
linkedFilters: [{ sourceFieldId: "", filterColumn: "" }],
},
});
} else {
onUpdate({
selectSource: { ...src, linkedFilters: undefined },
});
}
}}
/>
<Label className="text-[10px]"> </Label>
</div>
)}
</div> </div>
)} )}
@ -1690,13 +1768,32 @@ function FieldItemEditor({
/> />
)} )}
{/* auto 전용: 채번 설정 */} {/* select + table + 연동 필터 활성화 시 */}
{field.inputType === "auto" && ( {field.inputType === "select" &&
<AutoNumberEditor field.selectSource?.type === "table" &&
config={field.autoNumber} field.selectSource?.linkedFilters &&
onUpdate={(autoNumber) => onUpdate({ autoNumber })} field.selectSource.linkedFilters.length > 0 && (
<LinkedFiltersEditor
linkedFilters={field.selectSource.linkedFilters}
tableName={field.selectSource.tableName || ""}
currentFieldId={field.id}
allSections={allSections || []}
onUpdate={(filters) =>
onUpdate({
selectSource: { ...field.selectSource!, linkedFilters: filters },
})
}
/> />
)} )}
{/* auto 전용: 저장 탭에서 채번 규칙을 연결하라는 안내 */}
{field.inputType === "auto" && (
<div className="rounded border bg-muted/30 p-2">
<p className="text-[10px] text-muted-foreground">
[] &gt; .
</p>
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -1948,108 +2045,7 @@ function TableSourceEditor({
); );
} }
// ======================================== // AutoNumberEditor 삭제됨: 채번 규칙은 저장 탭 > 자동생성 필드에서 관리
// AutoNumberEditor: 자동 채번 설정
// ========================================
function AutoNumberEditor({
config,
onUpdate,
}: {
config?: AutoNumberConfig;
onUpdate: (config: AutoNumberConfig) => void;
}) {
const current: AutoNumberConfig = config || {
prefix: "",
dateFormat: "YYYYMMDD",
separator: "-",
sequenceDigits: 3,
};
return (
<div className="space-y-2 rounded border bg-muted/30 p-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={current.prefix || ""}
onChange={(e) => onUpdate({ ...current, prefix: e.target.value })}
placeholder="IN-"
className="mt-0.5 h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={current.dateFormat || "YYYYMMDD"}
onValueChange={(v) => onUpdate({ ...current, dateFormat: v })}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="YYYYMMDD" className="text-xs">
YYYYMMDD
</SelectItem>
<SelectItem value="YYMMDD" className="text-xs">
YYMMDD
</SelectItem>
<SelectItem value="YYMM" className="text-xs">
YYMM
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={current.separator || ""}
onChange={(e) => onUpdate({ ...current, separator: e.target.value })}
placeholder="-"
className="mt-0.5 h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]">퀀 릿</Label>
<Input
type="number"
value={current.sequenceDigits || 3}
onChange={(e) =>
onUpdate({
...current,
sequenceDigits: Number(e.target.value) || 3,
})
}
min={1}
max={10}
className="mt-0.5 h-7 text-xs"
/>
</div>
</div>
{/* 미리보기 */}
<div className="text-[10px] text-muted-foreground">
:{" "}
<span className="font-mono">
{current.prefix || ""}
{current.separator || ""}
{current.dateFormat === "YYMM"
? "2602"
: current.dateFormat === "YYMMDD"
? "260226"
: "20260226"}
{current.separator || ""}
{"0".repeat(current.sequenceDigits || 3).slice(0, -1)}1
</span>
</div>
</div>
);
}
// ======================================== // ========================================
// JsonKeySelect: JSON 키 드롭다운 (자동 추출) // JsonKeySelect: JSON 키 드롭다운 (자동 추출)
@ -2132,6 +2128,118 @@ function JsonKeySelect({
); );
} }
// ========================================
// LinkedFiltersEditor: 데이터 연동 필터 설정
// ========================================
function LinkedFiltersEditor({
linkedFilters,
tableName,
currentFieldId,
allSections,
onUpdate,
}: {
linkedFilters: SelectLinkedFilter[];
tableName: string;
currentFieldId: string;
allSections: PopFieldSection[];
onUpdate: (filters: SelectLinkedFilter[]) => void;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
useEffect(() => {
if (tableName) {
fetchTableColumns(tableName).then(setColumns);
} else {
setColumns([]);
}
}, [tableName]);
const candidateFields = useMemo(() => {
return allSections.flatMap((sec) =>
(sec.fields ?? [])
.filter((f) => f.id !== currentFieldId)
.map((f) => ({ id: f.id, label: f.labelText || f.fieldName || f.id, sectionLabel: sec.label }))
);
}, [allSections, currentFieldId]);
const updateFilter = (idx: number, partial: Partial<SelectLinkedFilter>) => {
const next = linkedFilters.map((f, i) => (i === idx ? { ...f, ...partial } : f));
onUpdate(next);
};
const removeFilter = (idx: number) => {
const next = linkedFilters.filter((_, i) => i !== idx);
onUpdate(next.length > 0 ? next : [{ sourceFieldId: "", filterColumn: "" }]);
};
const addFilter = () => {
onUpdate([...linkedFilters, { sourceFieldId: "", filterColumn: "" }]);
};
return (
<div className="space-y-2 rounded border bg-muted/30 p-2">
<Label className="text-[10px] text-muted-foreground"> </Label>
{linkedFilters.map((lf, idx) => (
<div key={idx} className="flex items-center gap-1">
<Select
value={lf.sourceFieldId || "__none__"}
onValueChange={(v) => updateFilter(idx, { sourceFieldId: v === "__none__" ? "" : v })}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="연동 필드" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs"> </SelectItem>
{candidateFields.map((cf) => (
<SelectItem key={cf.id} value={cf.id} className="text-xs">
{cf.sectionLabel ? `[${cf.sectionLabel}] ` : ""}{cf.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground">=</span>
<Select
value={lf.filterColumn || "__none__"}
onValueChange={(v) => updateFilter(idx, { filterColumn: v === "__none__" ? "" : v })}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="필터 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs"> </SelectItem>
{columns.map((c) => (
<SelectItem key={c.name} value={c.name} className="text-xs">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{linkedFilters.length > 1 && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0 text-destructive"
onClick={() => removeFilter(idx)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
))}
<Button
variant="outline"
size="sm"
className="h-6 w-full text-[10px]"
onClick={addFilter}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
// ======================================== // ========================================
// AppearanceEditor: 섹션 외관 설정 // AppearanceEditor: 섹션 외관 설정
// ======================================== // ========================================

View File

@ -44,6 +44,11 @@ export const DEFAULT_SECTION_APPEARANCES: Record<FieldSectionStyle, FieldSection
export type FieldSelectSourceType = "static" | "table"; export type FieldSelectSourceType = "static" | "table";
export interface SelectLinkedFilter {
sourceFieldId: string;
filterColumn: string;
}
export interface FieldSelectSource { export interface FieldSelectSource {
type: FieldSelectSourceType; type: FieldSelectSourceType;
staticOptions?: { value: string; label: string }[]; staticOptions?: { value: string; label: string }[];
@ -51,6 +56,7 @@ export interface FieldSelectSource {
valueColumn?: string; valueColumn?: string;
labelColumn?: string; labelColumn?: string;
filters?: DataSourceFilter[]; filters?: DataSourceFilter[];
linkedFilters?: SelectLinkedFilter[];
} }
// ===== 자동 채번 설정 ===== // ===== 자동 채번 설정 =====
@ -124,7 +130,7 @@ export interface PopFieldSaveMapping {
// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) ===== // ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) =====
export type HiddenValueSource = "json_extract" | "db_column"; export type HiddenValueSource = "json_extract" | "db_column" | "static";
export interface PopFieldHiddenMapping { export interface PopFieldHiddenMapping {
id: string; id: string;
@ -133,6 +139,7 @@ export interface PopFieldHiddenMapping {
sourceJsonColumn?: string; sourceJsonColumn?: string;
sourceJsonKey?: string; sourceJsonKey?: string;
sourceDbColumn?: string; sourceDbColumn?: string;
staticValue?: string;
targetColumn: string; targetColumn: string;
} }

View File

@ -638,6 +638,19 @@ export interface CollectedDataResponse {
export interface SaveMapping { export interface SaveMapping {
targetTable: string; targetTable: string;
columnMapping: Record<string, string>; columnMapping: Record<string, string>;
autoGenMappings?: Array<{
numberingRuleId: string;
targetColumn: string;
showResultModal?: boolean;
}>;
hiddenMappings?: Array<{
valueSource: "json_extract" | "db_column" | "static";
targetColumn: string;
staticValue?: string;
sourceJsonColumn?: string;
sourceJsonKey?: string;
sourceDbColumn?: string;
}>;
} }
export interface StatusChangeRule { export interface StatusChangeRule {

View File

@ -49,6 +49,7 @@ export type ButtonActionType =
| "view_table_history" // 테이블 이력 보기 | "view_table_history" // 테이블 이력 보기
| "excel_download" // 엑셀 다운로드 | "excel_download" // 엑셀 다운로드
| "excel_upload" // 엑셀 업로드 | "excel_upload" // 엑셀 업로드
| "multi_table_excel_upload" // 다중 테이블 엑셀 업로드
| "barcode_scan" // 바코드 스캔 | "barcode_scan" // 바코드 스캔
| "code_merge" // 코드 병합 | "code_merge" // 코드 병합
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합 // | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
@ -428,6 +429,9 @@ export class ButtonActionExecutor {
case "excel_upload": case "excel_upload":
return await this.handleExcelUpload(config, context); return await this.handleExcelUpload(config, context);
case "multi_table_excel_upload":
return await this.handleMultiTableExcelUpload(config, context);
case "barcode_scan": case "barcode_scan":
return await this.handleBarcodeScan(config, context); return await this.handleBarcodeScan(config, context);
@ -5604,6 +5608,69 @@ export class ButtonActionExecutor {
} }
} }
/**
*
*/
private static async handleMultiTableExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
const rootTable = context.tableName;
if (!rootTable) {
toast.error("화면에 테이블이 설정되지 않았습니다. 화면 설정을 확인하세요.");
return false;
}
toast.loading("테이블 구조를 분석하고 있습니다...", { id: "multi-table-detect" });
const { autoDetectMultiTableConfig } = await import("@/lib/api/multiTableExcel");
const result = await autoDetectMultiTableConfig(rootTable, context.screenId);
toast.dismiss("multi-table-detect");
if (!result.success || !result.data) {
toast.error(result.message || `테이블 구조를 분석할 수 없습니다: ${rootTable}`);
return false;
}
const chainConfig = result.data;
const { MultiTableExcelUploadModal } = await import("@/components/common/MultiTableExcelUploadModal");
const { createRoot } = await import("react-dom/client");
const modalContainer = document.createElement("div");
document.body.appendChild(modalContainer);
const root = createRoot(modalContainer);
const closeModal = () => {
root.unmount();
document.body.removeChild(modalContainer);
};
root.render(
React.createElement(MultiTableExcelUploadModal, {
open: true,
onOpenChange: (open: boolean) => {
if (!open) closeModal();
},
config: chainConfig,
onSuccess: () => {
context.onRefresh?.();
},
}),
);
return true;
} catch (error) {
toast.dismiss("multi-table-detect");
console.error("다중 테이블 엑셀 업로드 모달 열기 실패:", error);
showErrorToast(config.errorMessage || "다중 테이블 엑셀 업로드 화면을 열 수 없습니다", error, {
guidance: "잠시 후 다시 시도해 주세요.",
});
return false;
}
}
/** /**
* *
*/ */
@ -7700,6 +7767,11 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
successMessage: "엑셀 파일이 업로드되었습니다.", successMessage: "엑셀 파일이 업로드되었습니다.",
errorMessage: "엑셀 업로드 중 오류가 발생했습니다.", errorMessage: "엑셀 업로드 중 오류가 발생했습니다.",
}, },
multi_table_excel_upload: {
type: "multi_table_excel_upload",
successMessage: "다중 테이블 엑셀 업로드가 완료되었습니다.",
errorMessage: "다중 테이블 엑셀 업로드 중 오류가 발생했습니다.",
},
barcode_scan: { barcode_scan: {
type: "barcode_scan", type: "barcode_scan",
barcodeFormat: "all", barcodeFormat: "all",

View File

@ -104,8 +104,8 @@ export async function importFromExcel(
return; return;
} }
// 워크북 읽기 // 워크북 읽기 (cellDates: 엑셀 시리얼 날짜를 JS Date 객체로 변환)
const workbook = XLSX.read(data, { type: "binary" }); const workbook = XLSX.read(data, { type: "binary", cellDates: true });
// 시트 선택 (지정된 시트 또는 첫 번째 시트) // 시트 선택 (지정된 시트 또는 첫 번째 시트)
const targetSheetName = sheetName || workbook.SheetNames[0]; const targetSheetName = sheetName || workbook.SheetNames[0];
@ -118,15 +118,31 @@ export async function importFromExcel(
// JSON으로 변환 (빈 셀도 포함하여 모든 컬럼 키 유지) // JSON으로 변환 (빈 셀도 포함하여 모든 컬럼 키 유지)
const jsonData = XLSX.utils.sheet_to_json(worksheet, { const jsonData = XLSX.utils.sheet_to_json(worksheet, {
defval: "", // 빈 셀에 빈 문자열 할당 defval: "",
});
// Date 객체를 yyyy-mm-dd 문자열로 변환
const processedData = (jsonData as Record<string, any>[]).map((row) => {
const newRow: Record<string, any> = {};
for (const [key, value] of Object.entries(row)) {
if (value instanceof Date && !isNaN(value.getTime())) {
const y = value.getUTCFullYear();
const m = String(value.getUTCMonth() + 1).padStart(2, "0");
const d = String(value.getUTCDate()).padStart(2, "0");
newRow[key] = `${y}-${m}-${d}`;
} else {
newRow[key] = value;
}
}
return newRow;
}); });
console.log("✅ 엑셀 가져오기 완료:", { console.log("✅ 엑셀 가져오기 완료:", {
sheetName: targetSheetName, sheetName: targetSheetName,
rowCount: jsonData.length, rowCount: processedData.length,
}); });
resolve(jsonData as Record<string, any>[]); resolve(processedData);
} catch (error) { } catch (error) {
console.error("❌ 엑셀 가져오기 실패:", error); console.error("❌ 엑셀 가져오기 실패:", error);
reject(error); reject(error);

View File

@ -20,11 +20,9 @@ const nextConfig = {
}, },
// API 프록시 설정 - 백엔드로 요청 전달 // API 프록시 설정 - 백엔드로 요청 전달
// Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용 // Docker: SERVER_API_URL 사용. 로컬: 127.0.0.1 사용 (localhost는 IPv6 ::1 로 해석되어 ECONNREFUSED 나는 경우 있음)
// 로컬 개발: http://127.0.0.1:8080 사용
async rewrites() { async rewrites() {
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 const backendUrl = process.env.SERVER_API_URL || "http://127.0.0.1:8080";
const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080";
return [ return [
{ {
source: "/api/:path*", source: "/api/:path*",