Compare commits
48 Commits
ycshin-nod
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
1ee946d712 | |
|
|
db31b02180 | |
|
|
ee1760da2f | |
|
|
7ab05aea72 | |
|
|
ea6c5ac43c | |
|
|
ea0123d6cc | |
|
|
93eaf59966 | |
|
|
c56f434ff1 | |
|
|
536982dd71 | |
|
|
0e8c68a9ff | |
|
|
bfd97c9717 | |
|
|
a81cb7ca19 | |
|
|
12a8290873 | |
|
|
7a9a705f19 | |
|
|
85bf4882a8 | |
|
|
b2b0b575df | |
|
|
f7bd2f6fa3 | |
|
|
7e2ae4335e | |
|
|
d58131d88d | |
|
|
1917b7253d | |
|
|
9f9b130738 | |
|
|
91c9dda6ae | |
|
|
d43f0821ed | |
|
|
4b8f2b7839 | |
|
|
4f639dec34 | |
|
|
772514c270 | |
|
|
6f7e2b1b0c | |
|
|
b9080d03f6 | |
|
|
8d0f2dbb27 | |
|
|
a69b135b65 | |
|
|
fcb122c58b | |
|
|
e11a7b1237 | |
|
|
366cfcde60 | |
|
|
5b6b4be73c | |
|
|
b40f6c28dc | |
|
|
d31568b1bd | |
|
|
3a3e4e8926 | |
|
|
818cc80514 | |
|
|
a6c0ab5664 | |
|
|
f6a2668bdc | |
|
|
e5abd93600 | |
|
|
c22b468599 | |
|
|
6a30038785 | |
|
|
89af350935 | |
|
|
d9d18c1922 | |
|
|
0d71e79c54 | |
|
|
d7ef26d679 | |
|
|
65d5392c26 |
|
|
@ -163,6 +163,12 @@ uploads/
|
||||||
# ===== 기타 =====
|
# ===== 기타 =====
|
||||||
claude.md
|
claude.md
|
||||||
|
|
||||||
|
# Agent Pipeline 로컬 파일
|
||||||
|
_local/
|
||||||
|
.agent-pipeline/
|
||||||
|
.codeguard-baseline.json
|
||||||
|
scripts/browser-test-*.js
|
||||||
|
|
||||||
# AI 에이전트 테스트 산출물
|
# AI 에이전트 테스트 산출물
|
||||||
*-test-screenshots/
|
*-test-screenshots/
|
||||||
*-screenshots/
|
*-screenshots/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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` 등
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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: '로그아웃되었습니다.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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: 권한 없음
|
||||||
|
*/
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,17 @@ import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// 처리되지 않은 Promise 거부 핸들러
|
// 처리되지 않은 Promise 거부 핸들러
|
||||||
process.on("unhandledRejection", (reason: Error | any, promise: Promise<any>) => {
|
process.on(
|
||||||
logger.error("⚠️ Unhandled Promise Rejection:", {
|
"unhandledRejection",
|
||||||
reason: reason?.message || reason,
|
(reason: Error | any, promise: Promise<any>) => {
|
||||||
stack: reason?.stack,
|
logger.error("⚠️ Unhandled Promise Rejection:", {
|
||||||
});
|
reason: reason?.message || reason,
|
||||||
// 프로세스를 종료하지 않고 로깅만 수행
|
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,11 +118,14 @@ 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 액션 실행
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
|
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||||
|
|
@ -127,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"; // 임시 주석
|
||||||
|
|
@ -151,7 +161,7 @@ app.use(
|
||||||
], // 프론트엔드 도메인 허용
|
], // 프론트엔드 도메인 허용
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
app.use(express.json({ limit: "10mb" }));
|
app.use(express.json({ limit: "10mb" }));
|
||||||
|
|
@ -174,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에서 이미 올바른 형태로 처리됨
|
||||||
|
|
@ -200,7 +210,7 @@ app.use(
|
||||||
],
|
],
|
||||||
preflightContinue: false,
|
preflightContinue: false,
|
||||||
optionsSuccessStatus: 200,
|
optionsSuccessStatus: 200,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rate Limiting (개발 환경에서는 완화)
|
// Rate Limiting (개발 환경에서는 완화)
|
||||||
|
|
@ -317,7 +327,9 @@ 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/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
|
|
@ -351,11 +363,13 @@ app.listen(PORT, HOST, async () => {
|
||||||
runDashboardMigration,
|
runDashboardMigration,
|
||||||
runTableHistoryActionMigration,
|
runTableHistoryActionMigration,
|
||||||
runDtgManagementLogMigration,
|
runDtgManagementLogMigration,
|
||||||
|
runApprovalSystemMigration,
|
||||||
} = await import("./database/runMigration");
|
} = await import("./database/runMigration");
|
||||||
|
|
||||||
await runDashboardMigration();
|
await runDashboardMigration();
|
||||||
await runTableHistoryActionMigration();
|
await runTableHistoryActionMigration();
|
||||||
await runDtgManagementLogMigration();
|
await runDtgManagementLogMigration();
|
||||||
|
await runApprovalSystemMigration();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ 마이그레이션 실패:`, error);
|
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -402,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;
|
||||||
|
|
|
||||||
|
|
@ -3690,6 +3690,8 @@ export async function copyMenu(
|
||||||
? {
|
? {
|
||||||
removeText: req.body.screenNameConfig.removeText,
|
removeText: req.body.screenNameConfig.removeText,
|
||||||
addPrefix: req.body.screenNameConfig.addPrefix,
|
addPrefix: req.body.screenNameConfig.addPrefix,
|
||||||
|
replaceFrom: req.body.screenNameConfig.replaceFrom,
|
||||||
|
replaceTo: req.body.screenNameConfig.replaceTo,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,892 @@
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 결재 정의 (Approval Definitions) CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export class ApprovalDefinitionController {
|
||||||
|
// 결재 유형 목록 조회
|
||||||
|
static async getDefinitions(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { is_active, search } = req.query;
|
||||||
|
|
||||||
|
const conditions: string[] = ["company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (is_active) {
|
||||||
|
conditions.push(`is_active = $${idx}`);
|
||||||
|
params.push(is_active);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
conditions.push(`(definition_name ILIKE $${idx} OR definition_name_eng ILIKE $${idx})`);
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT * FROM approval_definitions WHERE ${conditions.join(" AND ")} ORDER BY definition_id ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재 유형 목록 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재 유형 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 유형 상세 조회
|
||||||
|
static async getDefinition(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const row = await queryOne<any>(
|
||||||
|
"SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: row });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재 유형 상세 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재 유형 상세 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 유형 생성
|
||||||
|
static async createDefinition(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
definition_name,
|
||||||
|
definition_name_eng,
|
||||||
|
description,
|
||||||
|
default_template_id,
|
||||||
|
max_steps = 5,
|
||||||
|
allow_self_approval = false,
|
||||||
|
allow_cancel = true,
|
||||||
|
is_active = "Y",
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!definition_name) {
|
||||||
|
return res.status(400).json({ success: false, message: "결재 유형명은 필수입니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const [row] = await query<any>(
|
||||||
|
`INSERT INTO approval_definitions (
|
||||||
|
definition_name, definition_name_eng, description, default_template_id,
|
||||||
|
max_steps, allow_self_approval, allow_cancel, is_active,
|
||||||
|
company_code, created_by, updated_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
definition_name, definition_name_eng, description, default_template_id,
|
||||||
|
max_steps, allow_self_approval, allow_cancel, is_active,
|
||||||
|
companyCode, userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(201).json({ success: true, data: row, message: "결재 유형이 생성되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재 유형 생성 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재 유형 생성 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 유형 수정
|
||||||
|
static async updateDefinition(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const existing = await queryOne<any>(
|
||||||
|
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
definition_name, definition_name_eng, description, default_template_id,
|
||||||
|
max_steps, allow_self_approval, allow_cancel, is_active,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (definition_name !== undefined) { fields.push(`definition_name = $${idx++}`); params.push(definition_name); }
|
||||||
|
if (definition_name_eng !== undefined) { fields.push(`definition_name_eng = $${idx++}`); params.push(definition_name_eng); }
|
||||||
|
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
|
||||||
|
if (default_template_id !== undefined) { fields.push(`default_template_id = $${idx++}`); params.push(default_template_id); }
|
||||||
|
if (max_steps !== undefined) { fields.push(`max_steps = $${idx++}`); params.push(max_steps); }
|
||||||
|
if (allow_self_approval !== undefined) { fields.push(`allow_self_approval = $${idx++}`); params.push(allow_self_approval); }
|
||||||
|
if (allow_cancel !== undefined) { fields.push(`allow_cancel = $${idx++}`); params.push(allow_cancel); }
|
||||||
|
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
|
||||||
|
|
||||||
|
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
|
||||||
|
params.push(req.user?.userId || "system");
|
||||||
|
params.push(id, companyCode);
|
||||||
|
|
||||||
|
const [row] = await query<any>(
|
||||||
|
`UPDATE approval_definitions SET ${fields.join(", ")}
|
||||||
|
WHERE definition_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: row, message: "결재 유형이 수정되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재 유형 수정 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재 유형 수정 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 유형 삭제
|
||||||
|
static async deleteDefinition(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const existing = await queryOne<any>(
|
||||||
|
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await query<any>(
|
||||||
|
"DELETE FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ success: true, message: "결재 유형이 삭제되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재 유형 삭제 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재 유형 삭제 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 결재선 템플릿 (Approval Line Templates) CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export class ApprovalTemplateController {
|
||||||
|
// 템플릿 목록 조회
|
||||||
|
static async getTemplates(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { definition_id, is_active } = req.query;
|
||||||
|
|
||||||
|
const conditions: string[] = ["t.company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (definition_id) {
|
||||||
|
conditions.push(`t.definition_id = $${idx++}`);
|
||||||
|
params.push(definition_id);
|
||||||
|
}
|
||||||
|
if (is_active) {
|
||||||
|
conditions.push(`t.is_active = $${idx++}`);
|
||||||
|
params.push(is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT t.*, d.definition_name
|
||||||
|
FROM approval_line_templates t
|
||||||
|
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
ORDER BY t.template_id ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재선 템플릿 목록 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재선 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 상세 조회 (단계 포함)
|
||||||
|
static async getTemplate(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const template = await queryOne<any>(
|
||||||
|
`SELECT t.*, d.definition_name
|
||||||
|
FROM approval_line_templates t
|
||||||
|
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
|
||||||
|
WHERE t.template_id = $1 AND t.company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = await query<any>(
|
||||||
|
"SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: { ...template, steps } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재선 템플릿 상세 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재선 템플릿 상세 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 생성 (단계 포함 트랜잭션)
|
||||||
|
static async createTemplate(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { template_name, description, definition_id, is_active = "Y", steps = [] } = req.body;
|
||||||
|
|
||||||
|
if (!template_name) {
|
||||||
|
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
let result: any;
|
||||||
|
await transaction(async (client) => {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`INSERT INTO approval_line_templates (template_name, description, definition_id, is_active, company_code, created_by, updated_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING *`,
|
||||||
|
[template_name, description, definition_id, is_active, companyCode, userId]
|
||||||
|
);
|
||||||
|
result = rows[0];
|
||||||
|
|
||||||
|
// 단계 일괄 삽입
|
||||||
|
if (Array.isArray(steps) && steps.length > 0) {
|
||||||
|
for (const step of steps) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO approval_line_template_steps
|
||||||
|
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
result.template_id,
|
||||||
|
step.step_order,
|
||||||
|
step.approver_type || "user",
|
||||||
|
step.approver_user_id || null,
|
||||||
|
step.approver_position || null,
|
||||||
|
step.approver_dept_code || null,
|
||||||
|
step.approver_label || null,
|
||||||
|
companyCode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ success: true, data: result, message: "결재선 템플릿이 생성되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재선 템플릿 생성 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재선 템플릿 생성 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 수정
|
||||||
|
static async updateTemplate(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const existing = await queryOne<any>(
|
||||||
|
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { template_name, description, definition_id, is_active, steps } = req.body;
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
let result: any;
|
||||||
|
await transaction(async (client) => {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (template_name !== undefined) { fields.push(`template_name = $${idx++}`); params.push(template_name); }
|
||||||
|
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
|
||||||
|
if (definition_id !== undefined) { fields.push(`definition_id = $${idx++}`); params.push(definition_id); }
|
||||||
|
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
|
||||||
|
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
|
||||||
|
params.push(userId, id, companyCode);
|
||||||
|
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`UPDATE approval_line_templates SET ${fields.join(", ")}
|
||||||
|
WHERE template_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
result = rows[0];
|
||||||
|
|
||||||
|
// 단계 재등록 (steps 배열이 주어진 경우 전체 교체)
|
||||||
|
if (Array.isArray(steps)) {
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
for (const step of steps) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO approval_line_template_steps
|
||||||
|
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[id, step.step_order, step.approver_type || "user", step.approver_user_id || null,
|
||||||
|
step.approver_position || null, step.approver_dept_code || null, step.approver_label || null, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result, message: "결재선 템플릿이 수정되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재선 템플릿 수정 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재선 템플릿 수정 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 삭제
|
||||||
|
static async deleteTemplate(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const existing = await queryOne<any>(
|
||||||
|
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await query<any>(
|
||||||
|
"DELETE FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ success: true, message: "결재선 템플릿이 삭제되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재선 템플릿 삭제 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재선 템플릿 삭제 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 결재 요청 (Approval Requests) CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export class ApprovalRequestController {
|
||||||
|
// 결재 요청 목록 조회
|
||||||
|
static async getRequests(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, target_table, target_record_id, requester_id, my_approvals, page = "1", limit = "20" } = req.query;
|
||||||
|
|
||||||
|
const conditions: string[] = ["r.company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
conditions.push(`r.status = $${idx++}`);
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
if (target_table) {
|
||||||
|
conditions.push(`r.target_table = $${idx++}`);
|
||||||
|
params.push(target_table);
|
||||||
|
}
|
||||||
|
if (target_record_id) {
|
||||||
|
conditions.push(`r.target_record_id = $${idx++}`);
|
||||||
|
params.push(target_record_id);
|
||||||
|
}
|
||||||
|
if (requester_id) {
|
||||||
|
conditions.push(`r.requester_id = $${idx++}`);
|
||||||
|
params.push(requester_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내 결재 대기 목록: 현재 사용자가 결재자인 라인만 조회
|
||||||
|
if (my_approvals === "true") {
|
||||||
|
conditions.push(
|
||||||
|
`EXISTS (SELECT 1 FROM approval_lines l WHERE l.request_id = r.request_id AND l.approver_id = $${idx++} AND l.status = 'pending' AND l.company_code = r.company_code)`
|
||||||
|
);
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||||
|
params.push(parseInt(limit as string), offset);
|
||||||
|
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT r.*, d.definition_name
|
||||||
|
FROM approval_requests r
|
||||||
|
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx++}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전체 건수 조회
|
||||||
|
const countParams = params.slice(0, params.length - 2);
|
||||||
|
const [countRow] = await query<any>(
|
||||||
|
`SELECT COUNT(*) as total FROM approval_requests r
|
||||||
|
WHERE ${conditions.join(" AND ")}`,
|
||||||
|
countParams
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
total: parseInt(countRow?.total || "0"),
|
||||||
|
page: parseInt(page as string),
|
||||||
|
limit: parseInt(limit as string),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재 요청 목록 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재 요청 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 요청 상세 조회 (라인 포함)
|
||||||
|
static async getRequest(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const request = await queryOne<any>(
|
||||||
|
`SELECT r.*, d.definition_name
|
||||||
|
FROM approval_requests r
|
||||||
|
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
|
||||||
|
WHERE r.request_id = $1 AND r.company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = await query<any>(
|
||||||
|
"SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: { ...request, lines } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재 요청 상세 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재 요청 상세 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 요청 생성 (결재 라인 자동 생성)
|
||||||
|
static async createRequest(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title, description, definition_id, target_table, target_record_id,
|
||||||
|
target_record_data, screen_id, button_component_id,
|
||||||
|
approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }]
|
||||||
|
approval_mode, // "sequential" | "parallel"
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!title || !target_table) {
|
||||||
|
return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(approvers) || approvers.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const userName = req.user?.userName || "";
|
||||||
|
const deptName = req.user?.deptName || "";
|
||||||
|
|
||||||
|
const isParallel = approval_mode === "parallel";
|
||||||
|
const totalSteps = approvers.length;
|
||||||
|
|
||||||
|
// approval_mode를 target_record_data에 병합 저장
|
||||||
|
const mergedRecordData = {
|
||||||
|
...(target_record_data || {}),
|
||||||
|
approval_mode: approval_mode || "sequential",
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: any;
|
||||||
|
await transaction(async (client) => {
|
||||||
|
// 결재 요청 생성
|
||||||
|
const { rows: reqRows } = await client.query(
|
||||||
|
`INSERT INTO approval_requests (
|
||||||
|
title, description, definition_id, target_table, target_record_id,
|
||||||
|
target_record_data, status, current_step, total_steps,
|
||||||
|
requester_id, requester_name, requester_dept,
|
||||||
|
screen_id, button_component_id, company_code
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, 'requested', 1, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
title, description, definition_id, target_table, target_record_id || null,
|
||||||
|
JSON.stringify(mergedRecordData),
|
||||||
|
totalSteps,
|
||||||
|
userId, userName, deptName,
|
||||||
|
screen_id, button_component_id, companyCode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
result = reqRows[0];
|
||||||
|
|
||||||
|
// 결재 라인 생성
|
||||||
|
// 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending
|
||||||
|
for (let i = 0; i < approvers.length; i++) {
|
||||||
|
const approver = approvers[i];
|
||||||
|
const lineStatus = isParallel ? "pending" : (i === 0 ? "pending" : "waiting");
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO approval_lines (
|
||||||
|
request_id, step_order, approver_id, approver_name, approver_position,
|
||||||
|
approver_dept, approver_label, status, company_code
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
result.request_id,
|
||||||
|
i + 1,
|
||||||
|
approver.approver_id,
|
||||||
|
approver.approver_name || null,
|
||||||
|
approver.approver_position || null,
|
||||||
|
approver.approver_dept || null,
|
||||||
|
approver.approver_label || (isParallel ? "동시 결재" : `${i + 1}차 결재`),
|
||||||
|
lineStatus,
|
||||||
|
companyCode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태를 in_progress로 업데이트
|
||||||
|
await client.query(
|
||||||
|
"UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1",
|
||||||
|
[result.request_id]
|
||||||
|
);
|
||||||
|
result.status = "in_progress";
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ success: true, data: result, message: "결재 요청이 생성되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재 요청 생성 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재 요청 생성 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 요청 회수 (cancel)
|
||||||
|
static async cancelRequest(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const request = await queryOne<any>(
|
||||||
|
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.requester_id !== userId) {
|
||||||
|
return res.status(403).json({ success: false, message: "본인이 요청한 건만 회수할 수 있습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["requested", "in_progress"].includes(request.status)) {
|
||||||
|
return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await query<any>(
|
||||||
|
"UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ success: true, message: "결재 요청이 회수되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재 요청 회수 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재 요청 회수 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 결재 라인 처리 (Approval Lines - 승인/반려)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export class ApprovalLineController {
|
||||||
|
// 결재 처리 (승인/반려)
|
||||||
|
static async processApproval(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lineId } = req.params;
|
||||||
|
const { action, comment } = req.body; // action: 'approved' | 'rejected'
|
||||||
|
|
||||||
|
if (!["approved", "rejected"].includes(action)) {
|
||||||
|
return res.status(400).json({ success: false, message: "액션은 approved 또는 rejected여야 합니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = await queryOne<any>(
|
||||||
|
"SELECT * FROM approval_lines WHERE line_id = $1 AND company_code = $2",
|
||||||
|
[lineId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
return res.status(404).json({ success: false, message: "결재 라인을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.approver_id !== userId) {
|
||||||
|
return res.status(403).json({ success: false, message: "본인이 결재자로 지정된 건만 처리할 수 있습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.status !== "pending") {
|
||||||
|
return res.status(400).json({ success: false, message: "대기 중인 결재만 처리할 수 있습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction(async (client) => {
|
||||||
|
// 현재 라인 처리
|
||||||
|
await client.query(
|
||||||
|
`UPDATE approval_lines SET status = $1, comment = $2, processed_at = NOW()
|
||||||
|
WHERE line_id = $3`,
|
||||||
|
[action, comment || null, lineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: reqRows } = await client.query(
|
||||||
|
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
|
||||||
|
[line.request_id, companyCode]
|
||||||
|
);
|
||||||
|
const request = reqRows[0];
|
||||||
|
|
||||||
|
if (!request) return;
|
||||||
|
|
||||||
|
if (action === "rejected") {
|
||||||
|
// 반려: 전체 요청 반려 처리
|
||||||
|
await client.query(
|
||||||
|
`UPDATE approval_requests SET status = 'rejected', final_approver_id = $1, final_comment = $2,
|
||||||
|
completed_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE request_id = $3`,
|
||||||
|
[userId, comment || null, line.request_id]
|
||||||
|
);
|
||||||
|
// 남은 pending/waiting 라인도 skipped 처리
|
||||||
|
await client.query(
|
||||||
|
`UPDATE approval_lines SET status = 'skipped'
|
||||||
|
WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2`,
|
||||||
|
[line.request_id, lineId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 승인: 동시결재 vs 다단결재 분기
|
||||||
|
const recordData = request.target_record_data;
|
||||||
|
const isParallelMode = recordData?.approval_mode === "parallel";
|
||||||
|
|
||||||
|
if (isParallelMode) {
|
||||||
|
// 동시결재: 남은 pending 라인이 있는지 확인
|
||||||
|
const { rows: remainingLines } = await client.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM approval_lines
|
||||||
|
WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3`,
|
||||||
|
[line.request_id, lineId, companyCode]
|
||||||
|
);
|
||||||
|
const remaining = parseInt(remainingLines[0]?.cnt || "0");
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
// 모든 동시 결재자 승인 완료 → 최종 승인
|
||||||
|
await client.query(
|
||||||
|
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
|
||||||
|
completed_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE request_id = $3`,
|
||||||
|
[userId, comment || null, line.request_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 아직 남은 결재자 있으면 대기 (상태 변경 없음)
|
||||||
|
} else {
|
||||||
|
// 다단결재: 다음 단계 활성화 또는 최종 완료
|
||||||
|
const nextStep = line.step_order + 1;
|
||||||
|
|
||||||
|
if (nextStep <= request.total_steps) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE approval_lines SET status = 'pending'
|
||||||
|
WHERE request_id = $1 AND step_order = $2 AND company_code = $3`,
|
||||||
|
[line.request_id, nextStep, companyCode]
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`UPDATE approval_requests SET current_step = $1, updated_at = NOW() WHERE request_id = $2`,
|
||||||
|
[nextStep, line.request_id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
|
||||||
|
completed_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE request_id = $3`,
|
||||||
|
[userId, comment || null, line.request_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("결재 처리 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "결재 처리 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내 결재 대기 목록 조회
|
||||||
|
static async getMyPendingLines(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT l.*, r.title, r.target_table, r.target_record_id, r.requester_name, r.requester_dept, r.created_at as request_created_at
|
||||||
|
FROM approval_lines l
|
||||||
|
JOIN approval_requests r ON l.request_id = r.request_id AND l.company_code = r.company_code
|
||||||
|
WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code = $2
|
||||||
|
ORDER BY r.created_at ASC`,
|
||||||
|
[userId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("내 결재 대기 목록 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "내 결재 대기 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,37 @@ import { PostgreSQLService } from "./PostgreSQLService";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결재 시스템 테이블 마이그레이션
|
||||||
|
* approval_definitions, approval_line_templates, approval_line_template_steps,
|
||||||
|
* approval_requests, approval_lines 테이블 생성
|
||||||
|
*/
|
||||||
|
export async function runApprovalSystemMigration() {
|
||||||
|
try {
|
||||||
|
console.log("🔄 결재 시스템 마이그레이션 시작...");
|
||||||
|
|
||||||
|
const sqlFilePath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"../../db/migrations/100_create_approval_system.sql"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(sqlFilePath)) {
|
||||||
|
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||||
|
await PostgreSQLService.query(sqlContent);
|
||||||
|
|
||||||
|
console.log("✅ 결재 시스템 마이그레이션 완료!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 결재 시스템 마이그레이션 실패:", error);
|
||||||
|
if (error instanceof Error && error.message.includes("already exists")) {
|
||||||
|
console.log("ℹ️ 테이블이 이미 존재합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터베이스 마이그레이션 실행
|
* 데이터베이스 마이그레이션 실행
|
||||||
* dashboard_elements 테이블에 custom_title, show_header 컬럼 추가
|
* dashboard_elements 테이블에 custom_title, show_header 컬럼 추가
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
ApprovalDefinitionController,
|
||||||
|
ApprovalTemplateController,
|
||||||
|
ApprovalRequestController,
|
||||||
|
ApprovalLineController,
|
||||||
|
} from "../controllers/approvalController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// ==================== 결재 유형 (Definitions) ====================
|
||||||
|
router.get("/definitions", ApprovalDefinitionController.getDefinitions);
|
||||||
|
router.get("/definitions/:id", ApprovalDefinitionController.getDefinition);
|
||||||
|
router.post("/definitions", ApprovalDefinitionController.createDefinition);
|
||||||
|
router.put("/definitions/:id", ApprovalDefinitionController.updateDefinition);
|
||||||
|
router.delete("/definitions/:id", ApprovalDefinitionController.deleteDefinition);
|
||||||
|
|
||||||
|
// ==================== 결재선 템플릿 (Templates) ====================
|
||||||
|
router.get("/templates", ApprovalTemplateController.getTemplates);
|
||||||
|
router.get("/templates/:id", ApprovalTemplateController.getTemplate);
|
||||||
|
router.post("/templates", ApprovalTemplateController.createTemplate);
|
||||||
|
router.put("/templates/:id", ApprovalTemplateController.updateTemplate);
|
||||||
|
router.delete("/templates/:id", ApprovalTemplateController.deleteTemplate);
|
||||||
|
|
||||||
|
// ==================== 결재 요청 (Requests) ====================
|
||||||
|
router.get("/requests", ApprovalRequestController.getRequests);
|
||||||
|
router.get("/requests/:id", ApprovalRequestController.getRequest);
|
||||||
|
router.post("/requests", ApprovalRequestController.createRequest);
|
||||||
|
router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest);
|
||||||
|
|
||||||
|
// ==================== 결재 라인 처리 (Lines) ====================
|
||||||
|
router.get("/my-pending", ApprovalLineController.getMyPendingLines);
|
||||||
|
router.post("/lines/:lineId/process", ApprovalLineController.processApproval);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
// Phase 2-1B: 핵심 인증 API 구현
|
// Phase 2-1B: 핵심 인증 API 구현
|
||||||
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { checkAuthStatus } from "../middleware/authMiddleware";
|
|
||||||
import { AuthController } from "../controllers/authController";
|
import { AuthController } from "../controllers/authController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -12,7 +11,7 @@ const router = Router();
|
||||||
* 인증 상태 확인 API
|
* 인증 상태 확인 API
|
||||||
* 기존 Java ApiLoginController.checkAuthStatus() 포팅
|
* 기존 Java ApiLoginController.checkAuthStatus() 포팅
|
||||||
*/
|
*/
|
||||||
router.get("/status", checkAuthStatus);
|
router.get("/status", AuthController.checkAuthStatus);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/login
|
* POST /api/auth/login
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ================================
|
// ================================
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ interface Menu {
|
||||||
lang_key_desc: string | null;
|
lang_key_desc: string | null;
|
||||||
screen_code: string | null;
|
screen_code: string | null;
|
||||||
menu_code: string | null;
|
menu_code: string | null;
|
||||||
|
menu_icon: string | null;
|
||||||
|
screen_group_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -371,7 +373,8 @@ export class MenuCopyService {
|
||||||
private async collectScreens(
|
private async collectScreens(
|
||||||
menuObjids: number[],
|
menuObjids: number[],
|
||||||
sourceCompanyCode: string,
|
sourceCompanyCode: string,
|
||||||
client: PoolClient
|
client: PoolClient,
|
||||||
|
menus?: Menu[]
|
||||||
): Promise<Set<number>> {
|
): Promise<Set<number>> {
|
||||||
logger.info(
|
logger.info(
|
||||||
`📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}`
|
`📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}`
|
||||||
|
|
@ -392,9 +395,25 @@ export class MenuCopyService {
|
||||||
screenIds.add(assignment.screen_id);
|
screenIds.add(assignment.screen_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`📌 직접 할당 화면: ${screenIds.size}개`);
|
// 1.5) menu_url에서 참조되는 화면 수집 (/screens/{screenId} 패턴)
|
||||||
|
if (menus) {
|
||||||
|
const screenIdPattern = /\/screens\/(\d+)/;
|
||||||
|
for (const menu of menus) {
|
||||||
|
if (menu.menu_url) {
|
||||||
|
const match = menu.menu_url.match(screenIdPattern);
|
||||||
|
if (match) {
|
||||||
|
const urlScreenId = parseInt(match[1], 10);
|
||||||
|
if (!isNaN(urlScreenId) && urlScreenId > 0) {
|
||||||
|
screenIds.add(urlScreenId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2) 화면 내부에서 참조되는 화면 (재귀)
|
logger.info(`📌 직접 할당 + menu_url 화면: ${screenIds.size}개`);
|
||||||
|
|
||||||
|
// 2) 화면 내부에서 참조되는 화면 (재귀) - V1 + V2 레이아웃 모두 탐색
|
||||||
const queue = Array.from(screenIds);
|
const queue = Array.from(screenIds);
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
|
|
@ -403,17 +422,29 @@ export class MenuCopyService {
|
||||||
if (visited.has(screenId)) continue;
|
if (visited.has(screenId)) continue;
|
||||||
visited.add(screenId);
|
visited.add(screenId);
|
||||||
|
|
||||||
// 화면 레이아웃 조회
|
const referencedScreens: number[] = [];
|
||||||
|
|
||||||
|
// V1 레이아웃에서 참조 화면 추출
|
||||||
const layoutsResult = await client.query<ScreenLayout>(
|
const layoutsResult = await client.query<ScreenLayout>(
|
||||||
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
|
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
|
||||||
[screenId]
|
[screenId]
|
||||||
);
|
);
|
||||||
|
referencedScreens.push(
|
||||||
// 참조 화면 추출
|
...this.extractReferencedScreens(layoutsResult.rows)
|
||||||
const referencedScreens = this.extractReferencedScreens(
|
|
||||||
layoutsResult.rows
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// V2 레이아웃에서 참조 화면 추출
|
||||||
|
const layoutsV2Result = await client.query<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1 AND company_code = $2`,
|
||||||
|
[screenId, sourceCompanyCode]
|
||||||
|
);
|
||||||
|
for (const row of layoutsV2Result.rows) {
|
||||||
|
if (row.layout_data) {
|
||||||
|
this.extractScreenIdsFromObject(row.layout_data, referencedScreens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (referencedScreens.length > 0) {
|
if (referencedScreens.length > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}`
|
` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}`
|
||||||
|
|
@ -895,6 +926,8 @@ export class MenuCopyService {
|
||||||
screenNameConfig?: {
|
screenNameConfig?: {
|
||||||
removeText?: string;
|
removeText?: string;
|
||||||
addPrefix?: string;
|
addPrefix?: string;
|
||||||
|
replaceFrom?: string;
|
||||||
|
replaceTo?: string;
|
||||||
},
|
},
|
||||||
additionalCopyOptions?: AdditionalCopyOptions
|
additionalCopyOptions?: AdditionalCopyOptions
|
||||||
): Promise<MenuCopyResult> {
|
): Promise<MenuCopyResult> {
|
||||||
|
|
@ -937,7 +970,8 @@ export class MenuCopyService {
|
||||||
const screenIds = await this.collectScreens(
|
const screenIds = await this.collectScreens(
|
||||||
menus.map((m) => m.objid),
|
menus.map((m) => m.objid),
|
||||||
sourceCompanyCode,
|
sourceCompanyCode,
|
||||||
client
|
client,
|
||||||
|
menus
|
||||||
);
|
);
|
||||||
|
|
||||||
const flowIds = await this.collectFlows(screenIds, client);
|
const flowIds = await this.collectFlows(screenIds, client);
|
||||||
|
|
@ -1093,6 +1127,16 @@ export class MenuCopyService {
|
||||||
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
|
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
|
||||||
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
|
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
|
||||||
|
|
||||||
|
// === 6.7단계: screen_group_screens 복제 ===
|
||||||
|
logger.info("\n🏷️ [6.7단계] screen_group_screens 복제");
|
||||||
|
await this.copyScreenGroupScreens(
|
||||||
|
screenIds,
|
||||||
|
screenIdMap,
|
||||||
|
sourceCompanyCode,
|
||||||
|
targetCompanyCode,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
// === 7단계: 테이블 타입 설정 복사 ===
|
// === 7단계: 테이블 타입 설정 복사 ===
|
||||||
if (additionalCopyOptions?.copyTableTypeColumns) {
|
if (additionalCopyOptions?.copyTableTypeColumns) {
|
||||||
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
|
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
|
||||||
|
|
@ -1417,6 +1461,8 @@ export class MenuCopyService {
|
||||||
screenNameConfig?: {
|
screenNameConfig?: {
|
||||||
removeText?: string;
|
removeText?: string;
|
||||||
addPrefix?: string;
|
addPrefix?: string;
|
||||||
|
replaceFrom?: string;
|
||||||
|
replaceTo?: string;
|
||||||
},
|
},
|
||||||
numberingRuleIdMap?: Map<string, string>,
|
numberingRuleIdMap?: Map<string, string>,
|
||||||
menuIdMap?: Map<number, number>
|
menuIdMap?: Map<number, number>
|
||||||
|
|
@ -1516,6 +1562,13 @@ export class MenuCopyService {
|
||||||
// 3) 화면명 변환 적용
|
// 3) 화면명 변환 적용
|
||||||
let transformedScreenName = screenDef.screen_name;
|
let transformedScreenName = screenDef.screen_name;
|
||||||
if (screenNameConfig) {
|
if (screenNameConfig) {
|
||||||
|
if (screenNameConfig.replaceFrom?.trim()) {
|
||||||
|
transformedScreenName = transformedScreenName.replace(
|
||||||
|
new RegExp(screenNameConfig.replaceFrom.trim(), "g"),
|
||||||
|
screenNameConfig.replaceTo?.trim() || ""
|
||||||
|
);
|
||||||
|
transformedScreenName = transformedScreenName.trim();
|
||||||
|
}
|
||||||
if (screenNameConfig.removeText?.trim()) {
|
if (screenNameConfig.removeText?.trim()) {
|
||||||
transformedScreenName = transformedScreenName.replace(
|
transformedScreenName = transformedScreenName.replace(
|
||||||
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
||||||
|
|
@ -1533,20 +1586,21 @@ export class MenuCopyService {
|
||||||
// === 기존 복사본이 있는 경우: 업데이트 ===
|
// === 기존 복사본이 있는 경우: 업데이트 ===
|
||||||
const existingScreenId = existingCopy.screen_id;
|
const existingScreenId = existingCopy.screen_id;
|
||||||
|
|
||||||
// 원본 V2 레이아웃 조회
|
// 원본 V2 레이아웃 조회 (모든 레이어)
|
||||||
const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
|
const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`,
|
||||||
[originalScreenId]
|
[originalScreenId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 대상 V2 레이아웃 조회
|
// 대상 V2 레이아웃 조회 (모든 레이어)
|
||||||
const targetLayoutV2Result = await client.query<{ layout_data: any }>(
|
const targetLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`,
|
||||||
[existingScreenId]
|
[existingScreenId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 변경 여부 확인 (V2 레이아웃 비교)
|
// 변경 여부 확인: 레이어 수가 다르면 무조건 변경됨
|
||||||
const hasChanges = this.hasLayoutChangesV2(
|
const layerCountDiffers = sourceLayoutV2Result.rows.length !== targetLayoutV2Result.rows.length;
|
||||||
|
const hasChanges = layerCountDiffers || this.hasLayoutChangesV2(
|
||||||
sourceLayoutV2Result.rows[0]?.layout_data,
|
sourceLayoutV2Result.rows[0]?.layout_data,
|
||||||
targetLayoutV2Result.rows[0]?.layout_data
|
targetLayoutV2Result.rows[0]?.layout_data
|
||||||
);
|
);
|
||||||
|
|
@ -1650,7 +1704,7 @@ export class MenuCopyService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) ===
|
// === 2단계: screen_conditional_zones + screen_layouts_v2 처리 (멀티 레이어 지원) ===
|
||||||
logger.info(
|
logger.info(
|
||||||
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||||
);
|
);
|
||||||
|
|
@ -1662,23 +1716,90 @@ export class MenuCopyService {
|
||||||
isUpdate,
|
isUpdate,
|
||||||
} of screenDefsToProcess) {
|
} of screenDefsToProcess) {
|
||||||
try {
|
try {
|
||||||
// 원본 V2 레이아웃 조회
|
const sourceCompanyCode = screenDef.company_code;
|
||||||
const layoutV2Result = await client.query<{ layout_data: any }>(
|
|
||||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
// 원본 V2 레이아웃 전체 조회 (모든 레이어)
|
||||||
|
const layoutV2Result = await client.query<{
|
||||||
|
layout_data: any;
|
||||||
|
layer_id: number;
|
||||||
|
layer_name: string;
|
||||||
|
condition_config: any;
|
||||||
|
}>(
|
||||||
|
`SELECT layout_data, layer_id, layer_name, condition_config
|
||||||
|
FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1 AND company_code = $2
|
||||||
|
ORDER BY layer_id`,
|
||||||
|
[originalScreenId, sourceCompanyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (layoutV2Result.rows.length === 0) {
|
||||||
|
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
|
||||||
|
const componentIdMap = new Map<string, string>();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
let compIdx = 0;
|
||||||
|
for (const layer of layoutV2Result.rows) {
|
||||||
|
const components = layer.layout_data?.components || [];
|
||||||
|
for (const comp of components) {
|
||||||
|
if (!componentIdMap.has(comp.id)) {
|
||||||
|
const newId = `comp_${timestamp}_${compIdx++}_${Math.random().toString(36).substr(2, 5)}`;
|
||||||
|
componentIdMap.set(comp.id, newId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// screen_conditional_zones 복제 + zoneIdMap 생성
|
||||||
|
const zoneIdMap = new Map<number, number>();
|
||||||
|
const zonesResult = await client.query(
|
||||||
|
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
|
||||||
[originalScreenId]
|
[originalScreenId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const layoutData = layoutV2Result.rows[0]?.layout_data;
|
if (isUpdate) {
|
||||||
const components = layoutData?.components || [];
|
await client.query(
|
||||||
|
`DELETE FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2`,
|
||||||
|
[targetScreenId, targetCompanyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (layoutData && components.length > 0) {
|
for (const zone of zonesResult.rows) {
|
||||||
// component_id 매핑 생성 (원본 → 새 ID)
|
const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id;
|
||||||
const componentIdMap = new Map<string, string>();
|
const newZone = await client.query<{ zone_id: number }>(
|
||||||
const timestamp = Date.now();
|
`INSERT INTO screen_conditional_zones
|
||||||
components.forEach((comp: any, idx: number) => {
|
(screen_id, company_code, zone_name, x, y, width, height,
|
||||||
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
|
trigger_component_id, trigger_operator)
|
||||||
componentIdMap.set(comp.id, newComponentId);
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
});
|
RETURNING zone_id`,
|
||||||
|
[targetScreenId, targetCompanyCode, zone.zone_name,
|
||||||
|
zone.x, zone.y, zone.width, zone.height,
|
||||||
|
newTriggerCompId, zone.trigger_operator]
|
||||||
|
);
|
||||||
|
zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zonesResult.rows.length > 0) {
|
||||||
|
logger.info(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}개 (zoneIdMap: ${zoneIdMap.size}개)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업데이트인 경우 기존 레이아웃 삭제 (레이어 수 변경 대응)
|
||||||
|
if (isUpdate) {
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`,
|
||||||
|
[targetScreenId, targetCompanyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 레이어별 처리
|
||||||
|
let totalComponents = 0;
|
||||||
|
for (const layer of layoutV2Result.rows) {
|
||||||
|
const layoutData = layer.layout_data;
|
||||||
|
const components = layoutData?.components || [];
|
||||||
|
|
||||||
|
if (!layoutData || components.length === 0) continue;
|
||||||
|
totalComponents += components.length;
|
||||||
|
|
||||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||||
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
|
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
|
||||||
|
|
@ -1690,20 +1811,34 @@ export class MenuCopyService {
|
||||||
menuIdMap
|
menuIdMap
|
||||||
);
|
);
|
||||||
|
|
||||||
// V2 레이아웃 저장 (UPSERT)
|
// condition_config의 zone_id 재매핑
|
||||||
await client.query(
|
let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null;
|
||||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
if (updatedConditionConfig?.zone_id) {
|
||||||
VALUES ($1, $2, $3, NOW(), NOW())
|
const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id);
|
||||||
ON CONFLICT (screen_id, company_code)
|
if (newZoneId) {
|
||||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
updatedConditionConfig.zone_id = newZoneId;
|
||||||
[targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)]
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
const action = isUpdate ? "업데이트" : "복사";
|
// V2 레이아웃 저장 (레이어별 INSERT)
|
||||||
logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`);
|
await client.query(
|
||||||
} else {
|
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
|
||||||
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||||
|
ON CONFLICT (screen_id, company_code, layer_id)
|
||||||
|
DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
targetScreenId,
|
||||||
|
targetCompanyCode,
|
||||||
|
layer.layer_id,
|
||||||
|
layer.layer_name,
|
||||||
|
JSON.stringify(updatedLayoutData),
|
||||||
|
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const action = isUpdate ? "업데이트" : "복사";
|
||||||
|
logger.info(` ↳ V2 레이아웃 ${action}: ${layoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
||||||
|
|
@ -1983,6 +2118,26 @@ export class MenuCopyService {
|
||||||
|
|
||||||
logger.info(`📂 메뉴 복사 중: ${menus.length}개`);
|
logger.info(`📂 메뉴 복사 중: ${menus.length}개`);
|
||||||
|
|
||||||
|
// screen_group_id 재매핑 맵 생성 (source company → target company)
|
||||||
|
const screenGroupIdMap = new Map<number, number>();
|
||||||
|
const sourceGroupIds = [...new Set(menus.map(m => m.screen_group_id).filter(Boolean))] as number[];
|
||||||
|
if (sourceGroupIds.length > 0) {
|
||||||
|
const sourceGroups = await client.query<{ id: number; group_name: string }>(
|
||||||
|
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
|
||||||
|
[sourceGroupIds]
|
||||||
|
);
|
||||||
|
for (const sg of sourceGroups.rows) {
|
||||||
|
const targetGroup = await client.query<{ id: number }>(
|
||||||
|
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
|
||||||
|
[sg.group_name, targetCompanyCode]
|
||||||
|
);
|
||||||
|
if (targetGroup.rows.length > 0) {
|
||||||
|
screenGroupIdMap.set(sg.id, targetGroup.rows[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`🏷️ screen_group 매핑: ${screenGroupIdMap.size}/${sourceGroupIds.length}개`);
|
||||||
|
}
|
||||||
|
|
||||||
// 위상 정렬 (부모 먼저 삽입)
|
// 위상 정렬 (부모 먼저 삽입)
|
||||||
const sortedMenus = this.topologicalSortMenus(menus);
|
const sortedMenus = this.topologicalSortMenus(menus);
|
||||||
|
|
||||||
|
|
@ -2106,26 +2261,28 @@ export class MenuCopyService {
|
||||||
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||||
seq, menu_url, menu_desc, writer, status, system_name,
|
seq, menu_url, menu_desc, writer, status, system_name,
|
||||||
company_code, lang_key, lang_key_desc, screen_code, menu_code,
|
company_code, lang_key, lang_key_desc, screen_code, menu_code,
|
||||||
source_menu_objid
|
source_menu_objid, menu_icon, screen_group_id
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
|
||||||
[
|
[
|
||||||
newObjId,
|
newObjId,
|
||||||
menu.menu_type,
|
menu.menu_type,
|
||||||
newParentObjId, // 재매핑
|
newParentObjId,
|
||||||
menu.menu_name_kor,
|
menu.menu_name_kor,
|
||||||
menu.menu_name_eng,
|
menu.menu_name_eng,
|
||||||
menu.seq,
|
menu.seq,
|
||||||
menu.menu_url,
|
menu.menu_url,
|
||||||
menu.menu_desc,
|
menu.menu_desc,
|
||||||
userId,
|
userId,
|
||||||
'active', // 복제된 메뉴는 항상 활성화 상태
|
menu.status || 'active',
|
||||||
menu.system_name,
|
menu.system_name,
|
||||||
targetCompanyCode, // 새 회사 코드
|
targetCompanyCode,
|
||||||
menu.lang_key,
|
menu.lang_key,
|
||||||
menu.lang_key_desc,
|
menu.lang_key_desc,
|
||||||
menu.screen_code, // 그대로 유지
|
menu.screen_code,
|
||||||
menu.menu_code,
|
menu.menu_code,
|
||||||
sourceMenuObjid, // 원본 메뉴 ID (최상위만)
|
sourceMenuObjid,
|
||||||
|
menu.menu_icon,
|
||||||
|
menu.screen_group_id ? (screenGroupIdMap.get(menu.screen_group_id) || menu.screen_group_id) : null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2246,8 +2403,9 @@ export class MenuCopyService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 URL 업데이트 (화면 ID 재매핑)
|
* 메뉴 URL + screen_code 업데이트 (화면 ID 재매핑)
|
||||||
* menu_url에 포함된 /screens/{screenId} 형식의 화면 ID를 복제된 화면 ID로 교체
|
* menu_url에 포함된 /screens/{screenId} 형식의 화면 ID를 복제된 화면 ID로 교체
|
||||||
|
* menu_info.screen_code도 복제된 screen_definitions.screen_code로 교체
|
||||||
*/
|
*/
|
||||||
private async updateMenuUrls(
|
private async updateMenuUrls(
|
||||||
menuIdMap: Map<number, number>,
|
menuIdMap: Map<number, number>,
|
||||||
|
|
@ -2255,56 +2413,197 @@ export class MenuCopyService {
|
||||||
client: PoolClient
|
client: PoolClient
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
|
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
|
||||||
logger.info("📭 메뉴 URL 업데이트 대상 없음");
|
logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newMenuObjids = Array.from(menuIdMap.values());
|
const newMenuObjids = Array.from(menuIdMap.values());
|
||||||
|
|
||||||
// 복제된 메뉴 중 menu_url이 있는 것 조회
|
// 복제된 메뉴 조회
|
||||||
const menusWithUrl = await client.query<{
|
const menusToUpdate = await client.query<{
|
||||||
objid: number;
|
objid: number;
|
||||||
menu_url: string;
|
menu_url: string | null;
|
||||||
|
screen_code: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT objid, menu_url FROM menu_info
|
`SELECT objid, menu_url, screen_code FROM menu_info
|
||||||
WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`,
|
WHERE objid = ANY($1)`,
|
||||||
[newMenuObjids]
|
[newMenuObjids]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (menusWithUrl.rows.length === 0) {
|
if (menusToUpdate.rows.length === 0) {
|
||||||
logger.info("📭 menu_url 업데이트 대상 없음");
|
logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let updatedCount = 0;
|
// screenIdMap의 역방향: 원본 screen_id → 새 screen_id의 screen_code 조회
|
||||||
const screenIdPattern = /\/screens\/(\d+)/;
|
const newScreenIds = Array.from(screenIdMap.values());
|
||||||
|
const screenCodeMap = new Map<string, string>();
|
||||||
for (const menu of menusWithUrl.rows) {
|
if (newScreenIds.length > 0) {
|
||||||
const match = menu.menu_url.match(screenIdPattern);
|
const screenCodesResult = await client.query<{
|
||||||
if (!match) continue;
|
screen_id: number;
|
||||||
|
screen_code: string;
|
||||||
const originalScreenId = parseInt(match[1], 10);
|
source_screen_id: number;
|
||||||
const newScreenId = screenIdMap.get(originalScreenId);
|
}>(
|
||||||
|
`SELECT sd_new.screen_id, sd_new.screen_code, sd_new.source_screen_id
|
||||||
if (newScreenId && newScreenId !== originalScreenId) {
|
FROM screen_definitions sd_new
|
||||||
const newMenuUrl = menu.menu_url.replace(
|
WHERE sd_new.screen_id = ANY($1) AND sd_new.screen_code IS NOT NULL`,
|
||||||
`/screens/${originalScreenId}`,
|
[newScreenIds]
|
||||||
`/screens/${newScreenId}`
|
);
|
||||||
);
|
for (const row of screenCodesResult.rows) {
|
||||||
|
if (row.source_screen_id) {
|
||||||
await client.query(
|
// 원본의 screen_code 조회
|
||||||
`UPDATE menu_info SET menu_url = $1 WHERE objid = $2`,
|
const origResult = await client.query<{ screen_code: string }>(
|
||||||
[newMenuUrl, menu.objid]
|
`SELECT screen_code FROM screen_definitions WHERE screen_id = $1`,
|
||||||
);
|
[row.source_screen_id]
|
||||||
|
);
|
||||||
logger.info(
|
if (origResult.rows[0]?.screen_code) {
|
||||||
` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
screenCodeMap.set(origResult.rows[0].screen_code, row.screen_code);
|
||||||
);
|
}
|
||||||
updatedCount++;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}개`);
|
let updatedUrlCount = 0;
|
||||||
|
let updatedCodeCount = 0;
|
||||||
|
const screenIdPattern = /\/screens\/(\d+)/;
|
||||||
|
|
||||||
|
for (const menu of menusToUpdate.rows) {
|
||||||
|
let newMenuUrl = menu.menu_url;
|
||||||
|
let newScreenCode = menu.screen_code;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
// menu_url 재매핑
|
||||||
|
if (menu.menu_url) {
|
||||||
|
const match = menu.menu_url.match(screenIdPattern);
|
||||||
|
if (match) {
|
||||||
|
const originalScreenId = parseInt(match[1], 10);
|
||||||
|
const newScreenId = screenIdMap.get(originalScreenId);
|
||||||
|
if (newScreenId && newScreenId !== originalScreenId) {
|
||||||
|
newMenuUrl = menu.menu_url.replace(
|
||||||
|
`/screens/${originalScreenId}`,
|
||||||
|
`/screens/${newScreenId}`
|
||||||
|
);
|
||||||
|
changed = true;
|
||||||
|
updatedUrlCount++;
|
||||||
|
logger.info(
|
||||||
|
` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// /screen/{screen_code} 형식도 처리
|
||||||
|
const screenCodeUrlMatch = menu.menu_url.match(/\/screen\/(.+)/);
|
||||||
|
if (screenCodeUrlMatch && !menu.menu_url.match(/\/screens\//)) {
|
||||||
|
const origCode = screenCodeUrlMatch[1];
|
||||||
|
const newCode = screenCodeMap.get(origCode);
|
||||||
|
if (newCode && newCode !== origCode) {
|
||||||
|
newMenuUrl = `/screen/${newCode}`;
|
||||||
|
changed = true;
|
||||||
|
updatedUrlCount++;
|
||||||
|
logger.info(
|
||||||
|
` 🔗 메뉴 URL(코드) 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// screen_code 재매핑
|
||||||
|
if (menu.screen_code) {
|
||||||
|
const mappedCode = screenCodeMap.get(menu.screen_code);
|
||||||
|
if (mappedCode && mappedCode !== menu.screen_code) {
|
||||||
|
newScreenCode = mappedCode;
|
||||||
|
changed = true;
|
||||||
|
updatedCodeCount++;
|
||||||
|
logger.info(
|
||||||
|
` 🏷️ screen_code 업데이트: ${menu.screen_code} → ${newScreenCode}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`,
|
||||||
|
[newMenuUrl, newScreenCode, menu.objid]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`✅ 메뉴 URL 업데이트: ${updatedUrlCount}개, screen_code 업데이트: ${updatedCodeCount}개`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* screen_group_screens 복제 (화면-스크린그룹 매핑)
|
||||||
|
*/
|
||||||
|
private async copyScreenGroupScreens(
|
||||||
|
screenIds: Set<number>,
|
||||||
|
screenIdMap: Map<number, number>,
|
||||||
|
sourceCompanyCode: string,
|
||||||
|
targetCompanyCode: string,
|
||||||
|
client: PoolClient
|
||||||
|
): Promise<void> {
|
||||||
|
if (screenIds.size === 0 || screenIdMap.size === 0) {
|
||||||
|
logger.info("📭 screen_group_screens 복제 대상 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 COMPANY_10의 screen_group_screens 삭제 (깨진 이전 데이터 정리)
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM screen_group_screens WHERE company_code = $1`,
|
||||||
|
[targetCompanyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 소스 회사의 screen_group_screens 조회
|
||||||
|
const sourceScreenIds = Array.from(screenIds);
|
||||||
|
const sourceResult = await client.query<{
|
||||||
|
group_id: number;
|
||||||
|
screen_id: number;
|
||||||
|
screen_role: string;
|
||||||
|
display_order: number;
|
||||||
|
is_default: string;
|
||||||
|
}>(
|
||||||
|
`SELECT group_id, screen_id, screen_role, display_order, is_default
|
||||||
|
FROM screen_group_screens
|
||||||
|
WHERE company_code = $1 AND screen_id = ANY($2)`,
|
||||||
|
[sourceCompanyCode, sourceScreenIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourceResult.rows.length === 0) {
|
||||||
|
logger.info("📭 소스에 screen_group_screens 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// screen_group ID 매핑 (source group_name → target group_id)
|
||||||
|
const sourceGroupIds = [...new Set(sourceResult.rows.map(r => r.group_id))];
|
||||||
|
const sourceGroups = await client.query<{ id: number; group_name: string }>(
|
||||||
|
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
|
||||||
|
[sourceGroupIds]
|
||||||
|
);
|
||||||
|
const groupIdMap = new Map<number, number>();
|
||||||
|
for (const sg of sourceGroups.rows) {
|
||||||
|
const targetGroup = await client.query<{ id: number }>(
|
||||||
|
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
|
||||||
|
[sg.group_name, targetCompanyCode]
|
||||||
|
);
|
||||||
|
if (targetGroup.rows.length > 0) {
|
||||||
|
groupIdMap.set(sg.id, targetGroup.rows[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertedCount = 0;
|
||||||
|
for (const row of sourceResult.rows) {
|
||||||
|
const newGroupId = groupIdMap.get(row.group_id);
|
||||||
|
const newScreenId = screenIdMap.get(row.screen_id);
|
||||||
|
if (!newGroupId || !newScreenId) continue;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'system')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[newGroupId, newScreenId, row.screen_role, row.display_order, row.is_default, targetCompanyCode]
|
||||||
|
);
|
||||||
|
insertedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`✅ screen_group_screens 복제: ${insertedCount}개`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,10 @@ export async function syncScreenGroupsToMenu(
|
||||||
// 모든 메뉴의 objid 집합 (삭제 확인용)
|
// 모든 메뉴의 objid 집합 (삭제 확인용)
|
||||||
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
|
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
|
||||||
|
|
||||||
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
|
// 3. objid 충돌 방지: 순차 카운터 사용
|
||||||
|
let nextObjid = Date.now();
|
||||||
|
|
||||||
|
// 4. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
|
||||||
// 없으면 생성
|
// 없으면 생성
|
||||||
let userMenuRootObjid: number | null = null;
|
let userMenuRootObjid: number | null = null;
|
||||||
const rootMenuQuery = `
|
const rootMenuQuery = `
|
||||||
|
|
@ -138,19 +141,18 @@ export async function syncScreenGroupsToMenu(
|
||||||
if (rootMenuResult.rows.length > 0) {
|
if (rootMenuResult.rows.length > 0) {
|
||||||
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
|
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
|
||||||
} else {
|
} else {
|
||||||
// 루트 메뉴가 없으면 생성
|
const rootObjid = nextObjid++;
|
||||||
const newObjid = Date.now();
|
|
||||||
const createRootQuery = `
|
const createRootQuery = `
|
||||||
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
|
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
|
||||||
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
|
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
|
||||||
RETURNING objid
|
RETURNING objid
|
||||||
`;
|
`;
|
||||||
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
|
const createRootResult = await client.query(createRootQuery, [rootObjid, companyCode, userId]);
|
||||||
userMenuRootObjid = Number(createRootResult.rows[0].objid);
|
userMenuRootObjid = Number(createRootResult.rows[0].objid);
|
||||||
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
|
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
|
// 5. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
|
||||||
const groupToMenuMap: Map<number, number> = new Map();
|
const groupToMenuMap: Map<number, number> = new Map();
|
||||||
|
|
||||||
// screen_groups의 부모 이름 조회를 위한 매핑
|
// screen_groups의 부모 이름 조회를 위한 매핑
|
||||||
|
|
@ -280,7 +282,7 @@ export async function syncScreenGroupsToMenu(
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// 새 메뉴 생성
|
// 새 메뉴 생성
|
||||||
const newObjid = Date.now() + groupId; // 고유 ID 보장
|
const newObjid = nextObjid++;
|
||||||
|
|
||||||
// 부모 메뉴 objid 결정
|
// 부모 메뉴 objid 결정
|
||||||
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
|
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
|
||||||
|
|
@ -334,8 +336,8 @@ export async function syncScreenGroupsToMenu(
|
||||||
INSERT INTO menu_info (
|
INSERT INTO menu_info (
|
||||||
objid, parent_obj_id, menu_name_kor, menu_name_eng,
|
objid, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||||
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc,
|
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc,
|
||||||
menu_url, screen_code
|
menu_url, screen_code, menu_icon
|
||||||
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11)
|
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11, $12)
|
||||||
RETURNING objid
|
RETURNING objid
|
||||||
`;
|
`;
|
||||||
await client.query(insertMenuQuery, [
|
await client.query(insertMenuQuery, [
|
||||||
|
|
@ -350,6 +352,7 @@ export async function syncScreenGroupsToMenu(
|
||||||
group.description || null,
|
group.description || null,
|
||||||
menuUrl,
|
menuUrl,
|
||||||
screenCode,
|
screenCode,
|
||||||
|
group.icon || null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// screen_groups에 menu_objid 업데이트
|
// screen_groups에 menu_objid 업데이트
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3482,8 +3482,74 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
|
`✅ V1 screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// V2 레이아웃(screen_layouts_v2)도 동일하게 처리
|
||||||
|
const v2LayoutsResult = await client.query(
|
||||||
|
`SELECT screen_id, layer_id, company_code, layout_data
|
||||||
|
FROM screen_layouts_v2
|
||||||
|
WHERE screen_id IN (${placeholders})
|
||||||
|
AND layout_data::text ~ '"(screenId|targetScreenId|modalScreenId|leftScreenId|rightScreenId|addModalScreenId|editModalScreenId)"'`,
|
||||||
|
targetScreenIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🔍 V2 참조 업데이트 대상 레이아웃: ${v2LayoutsResult.rows.length}개`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let v2Updated = 0;
|
||||||
|
for (const v2Layout of v2LayoutsResult.rows) {
|
||||||
|
let layoutData = v2Layout.layout_data;
|
||||||
|
if (!layoutData) continue;
|
||||||
|
|
||||||
|
let v2HasChanges = false;
|
||||||
|
|
||||||
|
const updateV2References = (obj: any): void => {
|
||||||
|
if (!obj || typeof obj !== "object") return;
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
for (const item of obj) updateV2References(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (
|
||||||
|
(key === "screenId" || key === "targetScreenId" || key === "modalScreenId" ||
|
||||||
|
key === "leftScreenId" || key === "rightScreenId" ||
|
||||||
|
key === "addModalScreenId" || key === "editModalScreenId")
|
||||||
|
) {
|
||||||
|
const numVal = typeof value === "number" ? value : parseInt(value);
|
||||||
|
if (!isNaN(numVal) && numVal > 0) {
|
||||||
|
const newId = screenMap.get(numVal);
|
||||||
|
if (newId) {
|
||||||
|
obj[key] = typeof value === "number" ? newId : String(newId);
|
||||||
|
v2HasChanges = true;
|
||||||
|
console.log(`🔗 V2 ${key} 매핑: ${numVal} → ${newId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
updateV2References(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateV2References(layoutData);
|
||||||
|
|
||||||
|
if (v2HasChanges) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
|
||||||
|
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
|
||||||
|
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
|
||||||
|
);
|
||||||
|
v2Updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ V2 참조 업데이트 완료: ${v2Updated}개 레이아웃`,
|
||||||
|
);
|
||||||
|
result.updated += v2Updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -4210,39 +4276,65 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
const newScreen = newScreenResult.rows[0];
|
const newScreen = newScreenResult.rows[0];
|
||||||
|
|
||||||
// 4. 원본 화면의 V2 레이아웃 조회
|
// 4. 원본 화면의 V2 레이아웃 전체 조회 (모든 레이어)
|
||||||
let sourceLayoutV2Result = await client.query<{ layout_data: any }>(
|
let sourceLayoutV2Result = await client.query<{
|
||||||
`SELECT layout_data FROM screen_layouts_v2
|
layout_data: any;
|
||||||
WHERE screen_id = $1 AND company_code = $2`,
|
layer_id: number;
|
||||||
|
layer_name: string;
|
||||||
|
condition_config: any;
|
||||||
|
}>(
|
||||||
|
`SELECT layout_data, layer_id, layer_name, condition_config
|
||||||
|
FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1 AND company_code = $2
|
||||||
|
ORDER BY layer_id`,
|
||||||
[sourceScreenId, sourceScreen.company_code],
|
[sourceScreenId, sourceScreen.company_code],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 없으면 공통(*) 레이아웃 조회
|
// 없으면 공통(*) 레이아웃 조회
|
||||||
let layoutData = sourceLayoutV2Result.rows[0]?.layout_data;
|
if (sourceLayoutV2Result.rows.length === 0 && sourceScreen.company_code !== "*") {
|
||||||
if (!layoutData && sourceScreen.company_code !== "*") {
|
sourceLayoutV2Result = await client.query<{
|
||||||
const fallbackResult = await client.query<{ layout_data: any }>(
|
layout_data: any;
|
||||||
`SELECT layout_data FROM screen_layouts_v2
|
layer_id: number;
|
||||||
WHERE screen_id = $1 AND company_code = '*'`,
|
layer_name: string;
|
||||||
|
condition_config: any;
|
||||||
|
}>(
|
||||||
|
`SELECT layout_data, layer_id, layer_name, condition_config
|
||||||
|
FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1 AND company_code = '*'
|
||||||
|
ORDER BY layer_id`,
|
||||||
[sourceScreenId],
|
[sourceScreenId],
|
||||||
);
|
);
|
||||||
layoutData = fallbackResult.rows[0]?.layout_data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const components = layoutData?.components || [];
|
// 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
|
||||||
|
const componentIdMap = new Map<string, string>();
|
||||||
|
for (const layer of sourceLayoutV2Result.rows) {
|
||||||
|
const components = layer.layout_data?.components || [];
|
||||||
|
for (const comp of components) {
|
||||||
|
if (!componentIdMap.has(comp.id)) {
|
||||||
|
componentIdMap.set(comp.id, generateId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasComponents = componentIdMap.size > 0;
|
||||||
|
// 첫 번째 레이어의 layoutData (flowId/ruleId 수집용 - 모든 레이어에서 수집)
|
||||||
|
const allLayoutDatas = sourceLayoutV2Result.rows.map((r: any) => r.layout_data).filter(Boolean);
|
||||||
|
|
||||||
// 5. 노드 플로우 복사 (회사가 다른 경우)
|
// 5. 노드 플로우 복사 (회사가 다른 경우)
|
||||||
let flowIdMap = new Map<number, number>();
|
let flowIdMap = new Map<number, number>();
|
||||||
if (
|
if (
|
||||||
components.length > 0 &&
|
hasComponents &&
|
||||||
sourceScreen.company_code !== targetCompanyCode
|
sourceScreen.company_code !== targetCompanyCode
|
||||||
) {
|
) {
|
||||||
// V2 레이아웃에서 flowId 수집
|
const flowIds = new Set<number>();
|
||||||
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
|
for (const ld of allLayoutDatas) {
|
||||||
|
const ids = this.collectFlowIdsFromLayoutData(ld);
|
||||||
|
ids.forEach((id: number) => flowIds.add(id));
|
||||||
|
}
|
||||||
|
|
||||||
if (flowIds.size > 0) {
|
if (flowIds.size > 0) {
|
||||||
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`);
|
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`);
|
||||||
|
|
||||||
// 노드 플로우 복사 및 매핑 생성
|
|
||||||
flowIdMap = await this.copyNodeFlowsForScreen(
|
flowIdMap = await this.copyNodeFlowsForScreen(
|
||||||
flowIds,
|
flowIds,
|
||||||
sourceScreen.company_code,
|
sourceScreen.company_code,
|
||||||
|
|
@ -4255,16 +4347,17 @@ export class ScreenManagementService {
|
||||||
// 5.1. 채번 규칙 복사 (회사가 다른 경우)
|
// 5.1. 채번 규칙 복사 (회사가 다른 경우)
|
||||||
let ruleIdMap = new Map<string, string>();
|
let ruleIdMap = new Map<string, string>();
|
||||||
if (
|
if (
|
||||||
components.length > 0 &&
|
hasComponents &&
|
||||||
sourceScreen.company_code !== targetCompanyCode
|
sourceScreen.company_code !== targetCompanyCode
|
||||||
) {
|
) {
|
||||||
// V2 레이아웃에서 채번 규칙 ID 수집
|
const ruleIds = new Set<string>();
|
||||||
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData);
|
for (const ld of allLayoutDatas) {
|
||||||
|
const ids = this.collectNumberingRuleIdsFromLayoutData(ld);
|
||||||
|
ids.forEach((id: string) => ruleIds.add(id));
|
||||||
|
}
|
||||||
|
|
||||||
if (ruleIds.size > 0) {
|
if (ruleIds.size > 0) {
|
||||||
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`);
|
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`);
|
||||||
|
|
||||||
// 채번 규칙 복사 및 매핑 생성
|
|
||||||
ruleIdMap = await this.copyNumberingRulesForScreen(
|
ruleIdMap = await this.copyNumberingRulesForScreen(
|
||||||
ruleIds,
|
ruleIds,
|
||||||
sourceScreen.company_code,
|
sourceScreen.company_code,
|
||||||
|
|
@ -4274,39 +4367,89 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. V2 레이아웃이 있다면 복사
|
// 5.2. screen_conditional_zones 복제 + zoneIdMap 생성
|
||||||
if (layoutData && components.length > 0) {
|
const zoneIdMap = new Map<number, number>();
|
||||||
|
if (hasComponents) {
|
||||||
try {
|
try {
|
||||||
// componentId 매핑 생성
|
const zonesResult = await client.query(
|
||||||
const componentIdMap = new Map<string, string>();
|
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
|
||||||
for (const comp of components) {
|
[sourceScreenId]
|
||||||
componentIdMap.set(comp.id, generateId());
|
);
|
||||||
|
|
||||||
|
for (const zone of zonesResult.rows) {
|
||||||
|
const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id;
|
||||||
|
const newZone = await client.query<{ zone_id: number }>(
|
||||||
|
`INSERT INTO screen_conditional_zones
|
||||||
|
(screen_id, company_code, zone_name, x, y, width, height,
|
||||||
|
trigger_component_id, trigger_operator)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING zone_id`,
|
||||||
|
[newScreen.screen_id, targetCompanyCode, zone.zone_name,
|
||||||
|
zone.x, zone.y, zone.width, zone.height,
|
||||||
|
newTriggerCompId, zone.trigger_operator]
|
||||||
|
);
|
||||||
|
zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
if (zonesResult.rows.length > 0) {
|
||||||
const updatedLayoutData = this.updateReferencesInLayoutData(
|
console.log(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}개`);
|
||||||
layoutData,
|
}
|
||||||
{
|
} catch (error) {
|
||||||
componentIdMap,
|
console.error("조건부 영역 복사 중 오류:", error);
|
||||||
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
|
}
|
||||||
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
|
}
|
||||||
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
|
// 6. V2 레이아웃 복사 (모든 레이어 순회)
|
||||||
await client.query(
|
if (sourceLayoutV2Result.rows.length > 0 && hasComponents) {
|
||||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
|
try {
|
||||||
VALUES ($1, $2, 1, $3, NOW(), NOW())
|
let totalComponents = 0;
|
||||||
ON CONFLICT (screen_id, company_code, layer_id)
|
|
||||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
|
||||||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
for (const layer of sourceLayoutV2Result.rows) {
|
||||||
|
const layoutData = layer.layout_data;
|
||||||
|
const components = layoutData?.components || [];
|
||||||
|
|
||||||
|
if (!layoutData || components.length === 0) continue;
|
||||||
|
totalComponents += components.length;
|
||||||
|
|
||||||
|
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||||
|
const updatedLayoutData = this.updateReferencesInLayoutData(
|
||||||
|
layoutData,
|
||||||
|
{
|
||||||
|
componentIdMap,
|
||||||
|
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
|
||||||
|
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// condition_config의 zone_id 재매핑
|
||||||
|
let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null;
|
||||||
|
if (updatedConditionConfig?.zone_id) {
|
||||||
|
const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id);
|
||||||
|
if (newZoneId) {
|
||||||
|
updatedConditionConfig.zone_id = newZoneId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// V2 레이아웃 저장 (레이어별 INSERT)
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||||
|
ON CONFLICT (screen_id, company_code, layer_id)
|
||||||
|
DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
newScreen.screen_id,
|
||||||
|
targetCompanyCode,
|
||||||
|
layer.layer_id,
|
||||||
|
layer.layer_name,
|
||||||
|
JSON.stringify(updatedLayoutData),
|
||||||
|
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ↳ V2 레이아웃 복사: ${sourceLayoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("V2 레이아웃 복사 중 오류:", error);
|
console.error("V2 레이아웃 복사 중 오류:", error);
|
||||||
// 레이아웃 복사 실패해도 화면 생성은 유지
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4533,9 +4676,60 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`,
|
`✅ V1: ${updateCount}개 레이아웃 업데이트 완료`,
|
||||||
);
|
);
|
||||||
return updateCount;
|
|
||||||
|
// V2 레이아웃(screen_layouts_v2)에서도 targetScreenId 등 재매핑
|
||||||
|
const v2Layouts = await query<any>(
|
||||||
|
`SELECT screen_id, layer_id, company_code, layout_data
|
||||||
|
FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1
|
||||||
|
AND layout_data IS NOT NULL`,
|
||||||
|
[screenId],
|
||||||
|
);
|
||||||
|
|
||||||
|
let v2UpdateCount = 0;
|
||||||
|
for (const v2Layout of v2Layouts) {
|
||||||
|
const layoutData = v2Layout.layout_data;
|
||||||
|
if (!layoutData?.components) continue;
|
||||||
|
|
||||||
|
let v2Changed = false;
|
||||||
|
const updateV2Refs = (obj: any): void => {
|
||||||
|
if (!obj || typeof obj !== "object") return;
|
||||||
|
if (Array.isArray(obj)) { for (const item of obj) updateV2Refs(item); return; }
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (
|
||||||
|
(key === "targetScreenId" || key === "screenId" || key === "modalScreenId" ||
|
||||||
|
key === "leftScreenId" || key === "rightScreenId" ||
|
||||||
|
key === "addModalScreenId" || key === "editModalScreenId")
|
||||||
|
) {
|
||||||
|
const numVal = typeof value === "number" ? value : parseInt(value);
|
||||||
|
if (!isNaN(numVal) && screenIdMapping.has(numVal)) {
|
||||||
|
obj[key] = typeof value === "number" ? screenIdMapping.get(numVal)! : screenIdMapping.get(numVal)!.toString();
|
||||||
|
v2Changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && value !== null) updateV2Refs(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateV2Refs(layoutData);
|
||||||
|
|
||||||
|
if (v2Changed) {
|
||||||
|
await query(
|
||||||
|
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
|
||||||
|
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
|
||||||
|
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
|
||||||
|
);
|
||||||
|
v2UpdateCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = updateCount + v2UpdateCount;
|
||||||
|
console.log(
|
||||||
|
`✅ 총 ${total}개 레이아웃 업데이트 완료 (V1: ${updateCount}, V2: ${v2UpdateCount})`,
|
||||||
|
);
|
||||||
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 어시스턴트 프로세스 종료");
|
||||||
|
}
|
||||||
|
}
|
||||||
6
backend/gradle/wrapper/gradle-wrapper.sync-conflict-20260205-175409-RZBZWHP.properties
vendored
Normal file
6
backend/gradle/wrapper/gradle-wrapper.sync-conflict-20260205-175409-RZBZWHP.properties
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
|
@ -12,7 +12,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
- DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
|
||||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||||
- JWT_EXPIRES_IN=24h
|
- JWT_EXPIRES_IN=24h
|
||||||
- CORS_ORIGIN=http://localhost:9771
|
- CORS_ORIGIN=http://localhost:9771
|
||||||
|
|
|
||||||
|
|
@ -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]로 표시
|
||||||
|
- 현재 진행 중인 테스트는 [진행중]으로 표시
|
||||||
|
|
@ -0,0 +1,342 @@
|
||||||
|
# 결재 시스템 구현 현황
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
어떤 화면/테이블에서든 결재 버튼을 추가하여 다단계(순차) 및 다중(병렬) 결재를 처리할 수 있는 범용 결재 시스템.
|
||||||
|
|
||||||
|
### 핵심 특징
|
||||||
|
- **범용성**: 특정 테이블에 종속되지 않고 어떤 화면에서든 사용 가능
|
||||||
|
- **멀티테넌시**: 모든 데이터가 `company_code`로 격리
|
||||||
|
- **사용자 주도**: 결재 요청 시 결재 모드/결재자를 직접 설정 (관리자 사전 세팅 불필요)
|
||||||
|
- **컴포넌트 연동**: 버튼 액션 타입 + 결재 단계 시각화 컴포넌트 제공
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
[버튼 클릭 (approval 액션)]
|
||||||
|
↓
|
||||||
|
[ButtonActionExecutor] → CustomEvent('open-approval-modal') 발송
|
||||||
|
↓
|
||||||
|
[ApprovalGlobalListener] → 이벤트 수신
|
||||||
|
↓
|
||||||
|
[ApprovalRequestModal] → 결재 모드/결재자 선택 UI
|
||||||
|
↓
|
||||||
|
[POST /api/approval/requests] → 결재 요청 생성
|
||||||
|
↓
|
||||||
|
[approval_requests + approval_lines 테이블에 저장]
|
||||||
|
↓
|
||||||
|
[결재함 / 결재 단계 컴포넌트에서 조회 및 처리]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 데이터베이스
|
||||||
|
|
||||||
|
### 마이그레이션 파일
|
||||||
|
- `db/migrations/100_create_approval_system.sql`
|
||||||
|
|
||||||
|
### 테이블 구조
|
||||||
|
|
||||||
|
| 테이블 | 용도 | 주요 컬럼 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| `approval_definitions` | 결재 유형 정의 (구매결재, 문서결재 등) | definition_id, definition_name, max_steps, company_code |
|
||||||
|
| `approval_line_templates` | 결재선 템플릿 (미리 저장된 결재선) | template_id, template_name, definition_id, company_code |
|
||||||
|
| `approval_line_template_steps` | 템플릿별 결재 단계 | step_id, template_id, step_order, approver_user_id, company_code |
|
||||||
|
| `approval_requests` | 실제 결재 요청 건 | request_id, title, target_table, target_record_id, status, requester_id, company_code |
|
||||||
|
| `approval_lines` | 결재 건별 각 단계 결재자 | line_id, request_id, step_order, approver_id, status, comment, company_code |
|
||||||
|
|
||||||
|
### 결재 상태 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[requested] → [in_progress] → [approved] (모든 단계 승인)
|
||||||
|
→ [rejected] (어느 단계에서든 반려)
|
||||||
|
→ [cancelled] (요청자가 취소)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### approval_requests.status
|
||||||
|
| 상태 | 의미 |
|
||||||
|
|------|------|
|
||||||
|
| `requested` | 결재 요청됨 (1단계 결재자 처리 대기) |
|
||||||
|
| `in_progress` | 결재 진행 중 (2단계 이상 진행) |
|
||||||
|
| `approved` | 최종 승인 완료 |
|
||||||
|
| `rejected` | 반려됨 |
|
||||||
|
| `cancelled` | 요청자에 의해 취소 |
|
||||||
|
|
||||||
|
#### approval_lines.status
|
||||||
|
| 상태 | 의미 |
|
||||||
|
|------|------|
|
||||||
|
| `waiting` | 아직 차례가 아님 |
|
||||||
|
| `pending` | 현재 결재 차례 (처리 대기) |
|
||||||
|
| `approved` | 승인 완료 |
|
||||||
|
| `rejected` | 반려 |
|
||||||
|
| `skipped` | 이전 단계 반려로 스킵됨 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 백엔드 API
|
||||||
|
|
||||||
|
### 파일 위치
|
||||||
|
- **컨트롤러**: `backend-node/src/controllers/approvalController.ts`
|
||||||
|
- **라우트**: `backend-node/src/routes/approvalRoutes.ts`
|
||||||
|
|
||||||
|
### API 엔드포인트
|
||||||
|
|
||||||
|
#### 결재 유형 (Definitions)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/approval/definitions` | 결재 유형 목록 |
|
||||||
|
| GET | `/api/approval/definitions/:id` | 결재 유형 상세 |
|
||||||
|
| POST | `/api/approval/definitions` | 결재 유형 생성 |
|
||||||
|
| PUT | `/api/approval/definitions/:id` | 결재 유형 수정 |
|
||||||
|
| DELETE | `/api/approval/definitions/:id` | 결재 유형 삭제 |
|
||||||
|
|
||||||
|
#### 결재선 템플릿 (Templates)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/approval/templates` | 템플릿 목록 |
|
||||||
|
| GET | `/api/approval/templates/:id` | 템플릿 상세 (단계 포함) |
|
||||||
|
| POST | `/api/approval/templates` | 템플릿 생성 |
|
||||||
|
| PUT | `/api/approval/templates/:id` | 템플릿 수정 |
|
||||||
|
| DELETE | `/api/approval/templates/:id` | 템플릿 삭제 |
|
||||||
|
|
||||||
|
#### 결재 요청 (Requests)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/approval/requests` | 결재 요청 목록 (필터 가능) |
|
||||||
|
| GET | `/api/approval/requests/:id` | 결재 요청 상세 (결재 라인 포함) |
|
||||||
|
| POST | `/api/approval/requests` | 결재 요청 생성 |
|
||||||
|
| POST | `/api/approval/requests/:id/cancel` | 결재 취소 |
|
||||||
|
|
||||||
|
#### 결재 라인 처리 (Lines)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/approval/my-pending` | 내 미처리 결재 목록 |
|
||||||
|
| POST | `/api/approval/lines/:lineId/process` | 승인/반려 처리 |
|
||||||
|
|
||||||
|
### 결재 요청 생성 시 입력
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CreateApprovalRequestInput {
|
||||||
|
title: string; // 결재 제목
|
||||||
|
description?: string; // 결재 설명
|
||||||
|
target_table: string; // 대상 테이블명 (예: sales_order_mng)
|
||||||
|
target_record_id?: string; // 대상 레코드 ID (선택)
|
||||||
|
approval_mode?: "sequential" | "parallel"; // 결재 모드
|
||||||
|
approvers: { // 결재자 목록
|
||||||
|
approver_id: string;
|
||||||
|
approver_name?: string;
|
||||||
|
approver_position?: string;
|
||||||
|
approver_dept?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 결재 처리 로직
|
||||||
|
|
||||||
|
#### 순차 결재 (sequential)
|
||||||
|
1. 첫 번째 결재자 `status = 'pending'`, 나머지 `'waiting'`
|
||||||
|
2. 1단계 승인 → 2단계 `'pending'`으로 변경
|
||||||
|
3. 모든 단계 승인 → `approval_requests.status = 'approved'`
|
||||||
|
4. 어느 단계에서 반려 → 이후 단계 `'skipped'`, 요청 `'rejected'`
|
||||||
|
|
||||||
|
#### 병렬 결재 (parallel)
|
||||||
|
1. 모든 결재자 `status = 'pending'` (동시 처리)
|
||||||
|
2. 모든 결재자 승인 → `'approved'`
|
||||||
|
3. 한 명이라도 반려 → `'rejected'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프론트엔드
|
||||||
|
|
||||||
|
### 5.1 결재 요청 모달
|
||||||
|
|
||||||
|
**파일**: `frontend/components/approval/ApprovalRequestModal.tsx`
|
||||||
|
|
||||||
|
- 결재 모드 선택 (다단 결재 / 다중 결재)
|
||||||
|
- 결재자 검색 (사용자 API 검색, 한글/영문/ID 검색 가능)
|
||||||
|
- 결재자 추가/삭제, 순서 변경 (순차 결재 시)
|
||||||
|
- 대상 테이블/레코드 ID 자동 세팅
|
||||||
|
|
||||||
|
### 5.2 결재 글로벌 리스너
|
||||||
|
|
||||||
|
**파일**: `frontend/components/approval/ApprovalGlobalListener.tsx`
|
||||||
|
|
||||||
|
- `open-approval-modal` CustomEvent를 전역으로 수신
|
||||||
|
- 이벤트의 `detail`에서 `targetTable`, `targetRecordId`, `formData` 추출
|
||||||
|
- `ApprovalRequestModal` 열기
|
||||||
|
|
||||||
|
### 5.3 결재함 페이지
|
||||||
|
|
||||||
|
**파일**: `frontend/app/(main)/admin/approvalBox/page.tsx`
|
||||||
|
|
||||||
|
- 탭 구성: 보낸 결재 / 받은 결재 / 완료된 결재
|
||||||
|
- 결재 상태별 필터링
|
||||||
|
- 결재 상세 조회 및 승인/반려 처리
|
||||||
|
|
||||||
|
**진입점**: 사용자 프로필 드롭다운 > "결재함"
|
||||||
|
|
||||||
|
### 5.4 결재 단계 시각화 컴포넌트 (v2-approval-step)
|
||||||
|
|
||||||
|
**파일 위치**: `frontend/lib/registry/components/v2-approval-step/`
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `types.ts` | ApprovalStepConfig 타입 정의 |
|
||||||
|
| `ApprovalStepComponent.tsx` | 결재 단계 시각화 UI (가로형 스테퍼 / 세로형 타임라인) |
|
||||||
|
| `ApprovalStepConfigPanel.tsx` | 설정 패널 (대상 테이블/컬럼 Combobox, 표시 옵션) |
|
||||||
|
| `ApprovalStepRenderer.tsx` | 컴포넌트 레지스트리 등록 |
|
||||||
|
| `index.ts` | 컴포넌트 정의 (이름, 태그, 기본값 등) |
|
||||||
|
|
||||||
|
#### 설정 항목
|
||||||
|
| 설정 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| 대상 테이블 | 결재를 걸 데이터가 있는 테이블 (Combobox 검색) |
|
||||||
|
| 레코드 ID 필드명 | 테이블의 PK 컬럼 (Combobox 검색) |
|
||||||
|
| 표시 모드 | 가로형 스테퍼 / 세로형 타임라인 |
|
||||||
|
| 부서/직급 표시 | 결재자의 부서/직급 정보 표시 여부 |
|
||||||
|
| 결재 코멘트 표시 | 승인/반려 시 입력한 코멘트 표시 여부 |
|
||||||
|
| 처리 시각 표시 | 결재 처리 시각 표시 여부 |
|
||||||
|
| 콤팩트 모드 | 작게 표시 |
|
||||||
|
|
||||||
|
### 5.5 API 클라이언트
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/api/approval.ts`
|
||||||
|
|
||||||
|
| 함수 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| `getApprovalDefinitions()` | 결재 유형 목록 조회 |
|
||||||
|
| `getApprovalTemplates()` | 결재선 템플릿 목록 조회 |
|
||||||
|
| `getApprovalRequests()` | 결재 요청 목록 조회 (필터 지원) |
|
||||||
|
| `getApprovalRequest(id)` | 결재 요청 상세 조회 |
|
||||||
|
| `createApprovalRequest(data)` | 결재 요청 생성 |
|
||||||
|
| `cancelApprovalRequest(id)` | 결재 취소 |
|
||||||
|
| `getMyPendingApprovals()` | 내 미처리 결재 목록 |
|
||||||
|
| `processApprovalLine(lineId, data)` | 승인/반려 처리 |
|
||||||
|
|
||||||
|
### 5.6 버튼 액션 연동
|
||||||
|
|
||||||
|
#### 관련 파일
|
||||||
|
| 파일 | 수정 내용 |
|
||||||
|
|------|-----------|
|
||||||
|
| `frontend/lib/utils/buttonActions.ts` | `ButtonActionType`에 `"approval"` 추가, `handleApproval` 구현 |
|
||||||
|
| `frontend/lib/utils/improvedButtonActionExecutor.ts` | `approval` 액션 핸들러 추가 |
|
||||||
|
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | `silentActions`에 `"approval"` 추가 |
|
||||||
|
| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 결재 액션 설정 UI (대상 테이블 자동 세팅) |
|
||||||
|
|
||||||
|
#### 동작 흐름
|
||||||
|
1. 버튼 설정에서 액션 타입 = `"approval"` 선택
|
||||||
|
2. 대상 테이블 자동 설정 (현재 화면 테이블)
|
||||||
|
3. 버튼 클릭 시 `CustomEvent('open-approval-modal')` 발송
|
||||||
|
4. `ApprovalGlobalListener`가 수신하여 `ApprovalRequestModal` 오픈
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 멀티테넌시 적용
|
||||||
|
|
||||||
|
| 영역 | 적용 |
|
||||||
|
|------|------|
|
||||||
|
| DB 테이블 | 모든 테이블에 `company_code NOT NULL` 포함 |
|
||||||
|
| 인덱스 | `company_code` 컬럼에 인덱스 생성 |
|
||||||
|
| SELECT | `WHERE company_code = $N` 필수 |
|
||||||
|
| INSERT | `company_code` 값 포함 필수 |
|
||||||
|
| UPDATE/DELETE | `WHERE` 절에 `company_code` 조건 포함 |
|
||||||
|
| 최고관리자 | `company_code = '*'` → 모든 데이터 조회 가능 |
|
||||||
|
| JOIN | `ON` 절에 `company_code` 매칭 포함 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 전체 파일 목록
|
||||||
|
|
||||||
|
### 데이터베이스
|
||||||
|
```
|
||||||
|
db/migrations/100_create_approval_system.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백엔드
|
||||||
|
```
|
||||||
|
backend-node/src/controllers/approvalController.ts
|
||||||
|
backend-node/src/routes/approvalRoutes.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - 결재 모달/리스너
|
||||||
|
```
|
||||||
|
frontend/components/approval/ApprovalRequestModal.tsx
|
||||||
|
frontend/components/approval/ApprovalGlobalListener.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - 결재함 페이지
|
||||||
|
```
|
||||||
|
frontend/app/(main)/admin/approvalBox/page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - 결재 단계 컴포넌트
|
||||||
|
```
|
||||||
|
frontend/lib/registry/components/v2-approval-step/types.ts
|
||||||
|
frontend/lib/registry/components/v2-approval-step/ApprovalStepComponent.tsx
|
||||||
|
frontend/lib/registry/components/v2-approval-step/ApprovalStepConfigPanel.tsx
|
||||||
|
frontend/lib/registry/components/v2-approval-step/ApprovalStepRenderer.tsx
|
||||||
|
frontend/lib/registry/components/v2-approval-step/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - API 클라이언트
|
||||||
|
```
|
||||||
|
frontend/lib/api/approval.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - 버튼 액션 연동 (수정된 파일)
|
||||||
|
```
|
||||||
|
frontend/lib/utils/buttonActions.ts
|
||||||
|
frontend/lib/utils/improvedButtonActionExecutor.ts
|
||||||
|
frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx
|
||||||
|
frontend/components/screen/config-panels/ButtonConfigPanel.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - 레이아웃 (수정된 파일)
|
||||||
|
```
|
||||||
|
frontend/components/layout/UserDropdown.tsx (결재함 메뉴 추가)
|
||||||
|
frontend/components/layout/AppLayout.tsx (결재함 메뉴 추가)
|
||||||
|
frontend/lib/registry/components/index.ts (v2-approval-step 렌더러 import)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 사용 방법
|
||||||
|
|
||||||
|
### 결재 버튼 추가
|
||||||
|
1. 화면 디자이너에서 버튼 컴포넌트 추가
|
||||||
|
2. 버튼 설정 > 액션 타입 = `결재` 선택
|
||||||
|
3. 대상 테이블이 자동 설정됨 (수동 변경 가능)
|
||||||
|
4. 저장
|
||||||
|
|
||||||
|
### 결재 요청하기
|
||||||
|
1. 데이터 행 선택 (선택적)
|
||||||
|
2. 결재 버튼 클릭
|
||||||
|
3. 결재 모달에서:
|
||||||
|
- 결재 제목 입력
|
||||||
|
- 결재 모드 선택 (다단 결재 / 다중 결재)
|
||||||
|
- 결재자 검색하여 추가
|
||||||
|
4. 결재 요청 클릭
|
||||||
|
|
||||||
|
### 결재 처리하기
|
||||||
|
1. 프로필 드롭다운 > 결재함 클릭
|
||||||
|
2. 받은 결재 탭에서 대기 중인 결재 확인
|
||||||
|
3. 상세 보기 > 승인 또는 반려
|
||||||
|
|
||||||
|
### 결재 단계 표시하기
|
||||||
|
1. 화면 디자이너에서 `결재 단계` 컴포넌트 추가
|
||||||
|
2. 설정에서 대상 테이블 / 레코드 ID 필드 선택
|
||||||
|
3. 표시 모드 (가로/세로) 및 옵션 설정
|
||||||
|
4. 저장 → 행 선택 시 해당 레코드의 결재 단계가 표시됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 향후 개선 사항
|
||||||
|
|
||||||
|
- [ ] 결재 알림 (실시간 알림, 이메일 연동)
|
||||||
|
- [ ] 제어관리 시스템 연동 (결재 완료 후 자동 액션)
|
||||||
|
- [ ] 결재 위임 기능
|
||||||
|
- [ ] 결재 이력 조회 / 통계 대시보드
|
||||||
|
- [ ] 결재선 즐겨찾기 (자주 쓰는 결재선 저장)
|
||||||
|
- [ ] 모바일 결재 처리 최적화
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
/** AI 어시스턴트 진입 시 대시보드로 이동 */
|
||||||
|
export default function AIAssistantPage() {
|
||||||
|
redirect("/admin/aiAssistant/dashboard");
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,419 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Loader2, Send, Inbox, CheckCircle, XCircle, Clock, Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
import {
|
||||||
|
getApprovalRequests,
|
||||||
|
getApprovalRequest,
|
||||||
|
getMyPendingApprovals,
|
||||||
|
processApprovalLine,
|
||||||
|
cancelApprovalRequest,
|
||||||
|
type ApprovalRequest,
|
||||||
|
type ApprovalLine,
|
||||||
|
} from "@/lib/api/approval";
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
requested: { label: "요청", variant: "outline" },
|
||||||
|
in_progress: { label: "진행중", variant: "default" },
|
||||||
|
approved: { label: "승인", variant: "default" },
|
||||||
|
rejected: { label: "반려", variant: "destructive" },
|
||||||
|
cancelled: { label: "회수", variant: "secondary" },
|
||||||
|
waiting: { label: "대기", variant: "outline" },
|
||||||
|
pending: { label: "결재대기", variant: "default" },
|
||||||
|
skipped: { label: "건너뜀", variant: "secondary" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const info = STATUS_MAP[status] || { label: status, variant: "outline" as const };
|
||||||
|
return <Badge variant={info.variant}>{info.label}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr?: string) {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric", month: "2-digit", day: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 상신함 (내가 올린 결재)
|
||||||
|
// ============================================================
|
||||||
|
function SentTab() {
|
||||||
|
const [requests, setRequests] = useState<ApprovalRequest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchRequests = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getApprovalRequests({ my_approvals: false });
|
||||||
|
if (res.success && res.data) setRequests(res.data);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchRequests(); }, [fetchRequests]);
|
||||||
|
|
||||||
|
const openDetail = async (req: ApprovalRequest) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDetailOpen(true);
|
||||||
|
const res = await getApprovalRequest(req.request_id);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setSelectedRequest(res.data);
|
||||||
|
} else {
|
||||||
|
setSelectedRequest(req);
|
||||||
|
}
|
||||||
|
setDetailLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!selectedRequest) return;
|
||||||
|
const res = await cancelApprovalRequest(selectedRequest.request_id);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("결재가 회수되었습니다.");
|
||||||
|
setDetailOpen(false);
|
||||||
|
fetchRequests();
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || "회수 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : requests.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
|
<Send className="text-muted-foreground mb-2 h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground text-sm">상신한 결재가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">대상 테이블</TableHead>
|
||||||
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">진행</TableHead>
|
||||||
|
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">상태</TableHead>
|
||||||
|
<TableHead className="h-12 w-[140px] text-sm font-semibold">요청일</TableHead>
|
||||||
|
<TableHead className="h-12 w-[60px] text-center text-sm font-semibold">보기</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{requests.map((req) => (
|
||||||
|
<TableRow key={req.request_id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<TableCell className="h-14 text-sm font-medium">{req.title}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-14 text-sm">{req.target_table}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center text-sm">
|
||||||
|
{req.current_step}/{req.total_steps}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-14 text-center"><StatusBadge status={req.status} /></TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(req.created_at)}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openDetail(req)}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 상세 모달 */}
|
||||||
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">결재 상세</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
{selectedRequest?.title}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : selectedRequest && (
|
||||||
|
<div className="max-h-[50vh] space-y-4 overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">상태</span>
|
||||||
|
<div className="mt-1"><StatusBadge status={selectedRequest.status} /></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">진행</span>
|
||||||
|
<p className="mt-1 font-medium">{selectedRequest.current_step}/{selectedRequest.total_steps}단계</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">대상 테이블</span>
|
||||||
|
<p className="mt-1 font-medium">{selectedRequest.target_table}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">요청일</span>
|
||||||
|
<p className="mt-1">{formatDate(selectedRequest.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedRequest.description && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">사유</span>
|
||||||
|
<p className="mt-1 text-sm">{selectedRequest.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 결재선 */}
|
||||||
|
{selectedRequest.lines && selectedRequest.lines.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">결재선</span>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{selectedRequest.lines
|
||||||
|
.sort((a, b) => a.step_order - b.step_order)
|
||||||
|
.map((line) => (
|
||||||
|
<div key={line.line_id} className="bg-muted/30 flex items-center justify-between rounded-md border p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-[10px]">{line.step_order}차</Badge>
|
||||||
|
<span className="text-sm font-medium">{line.approver_name || line.approver_id}</span>
|
||||||
|
{line.approver_position && (
|
||||||
|
<span className="text-muted-foreground text-xs">({line.approver_position})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={line.status} />
|
||||||
|
{line.processed_at && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formatDate(line.processed_at)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
{selectedRequest?.status === "requested" && (
|
||||||
|
<Button variant="destructive" onClick={handleCancel} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
결재 회수
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={() => setDetailOpen(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 수신함 (내가 결재해야 할 것)
|
||||||
|
// ============================================================
|
||||||
|
function ReceivedTab() {
|
||||||
|
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [processOpen, setProcessOpen] = useState(false);
|
||||||
|
const [selectedLine, setSelectedLine] = useState<ApprovalLine | null>(null);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
const fetchPending = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getMyPendingApprovals();
|
||||||
|
if (res.success && res.data) setPendingLines(res.data);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPending(); }, [fetchPending]);
|
||||||
|
|
||||||
|
const openProcess = (line: ApprovalLine) => {
|
||||||
|
setSelectedLine(line);
|
||||||
|
setComment("");
|
||||||
|
setProcessOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProcess = async (action: "approved" | "rejected") => {
|
||||||
|
if (!selectedLine) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
const res = await processApprovalLine(selectedLine.line_id, {
|
||||||
|
action,
|
||||||
|
comment: comment.trim() || undefined,
|
||||||
|
});
|
||||||
|
setIsProcessing(false);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(action === "approved" ? "승인되었습니다." : "반려되었습니다.");
|
||||||
|
setProcessOpen(false);
|
||||||
|
fetchPending();
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || "처리 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : pendingLines.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
|
<Inbox className="text-muted-foreground mb-2 h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground text-sm">결재 대기 건이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">요청자</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">대상 테이블</TableHead>
|
||||||
|
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">단계</TableHead>
|
||||||
|
<TableHead className="h-12 w-[140px] text-sm font-semibold">요청일</TableHead>
|
||||||
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">처리</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pendingLines.map((line) => (
|
||||||
|
<TableRow key={line.line_id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<TableCell className="h-14 text-sm font-medium">{line.title || "-"}</TableCell>
|
||||||
|
<TableCell className="h-14 text-sm">
|
||||||
|
{line.requester_name || "-"}
|
||||||
|
{line.requester_dept && (
|
||||||
|
<span className="text-muted-foreground ml-1 text-xs">({line.requester_dept})</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-14 text-sm">{line.target_table || "-"}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center text-sm">
|
||||||
|
<Badge variant="outline">{line.step_order}차</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(line.request_created_at || line.created_at)}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center">
|
||||||
|
<Button size="sm" className="h-8 text-xs" onClick={() => openProcess(line)}>
|
||||||
|
결재하기
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결재 처리 모달 */}
|
||||||
|
<Dialog open={processOpen} onOpenChange={setProcessOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">결재 처리</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
{selectedLine?.title}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">요청자</span>
|
||||||
|
<p className="mt-1 font-medium">{selectedLine?.requester_name || "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">결재 단계</span>
|
||||||
|
<p className="mt-1 font-medium">{selectedLine?.step_order}차 결재</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">의견</Label>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="결재 의견을 입력하세요 (선택사항)"
|
||||||
|
className="min-h-[80px] text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleProcess("rejected")}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
반려
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleProcess("approved")}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
승인
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메인 페이지
|
||||||
|
// ============================================================
|
||||||
|
export default function ApprovalBoxPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">결재함</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
내가 상신한 결재와 나에게 온 결재를 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="received" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="received" className="gap-2">
|
||||||
|
<Inbox className="h-4 w-4" />
|
||||||
|
수신함 (결재 대기)
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sent" className="gap-2">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
상신함 (내가 올린)
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="received">
|
||||||
|
<ReceivedTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sent">
|
||||||
|
<SentTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,788 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Plus, Edit, Trash2, Search, Users, FileText, Loader2 } from "lucide-react";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
import {
|
||||||
|
type ApprovalDefinition,
|
||||||
|
type ApprovalLineTemplate,
|
||||||
|
type ApprovalLineTemplateStep,
|
||||||
|
getApprovalDefinitions,
|
||||||
|
createApprovalDefinition,
|
||||||
|
updateApprovalDefinition,
|
||||||
|
deleteApprovalDefinition,
|
||||||
|
getApprovalTemplates,
|
||||||
|
getApprovalTemplate,
|
||||||
|
createApprovalTemplate,
|
||||||
|
updateApprovalTemplate,
|
||||||
|
deleteApprovalTemplate,
|
||||||
|
} from "@/lib/api/approval";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 결재 유형 관리 탭
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function DefinitionsTab() {
|
||||||
|
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editingDef, setEditingDef] = useState<ApprovalDefinition | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
definition_name: "",
|
||||||
|
definition_name_eng: "",
|
||||||
|
description: "",
|
||||||
|
max_steps: 3,
|
||||||
|
allow_self_approval: false,
|
||||||
|
allow_cancel: true,
|
||||||
|
is_active: "Y",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ApprovalDefinition | null>(null);
|
||||||
|
|
||||||
|
const fetchDefinitions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getApprovalDefinitions({ search: searchTerm || undefined });
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setDefinitions(res.data);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDefinitions();
|
||||||
|
}, [fetchDefinitions]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingDef(null);
|
||||||
|
setFormData({
|
||||||
|
definition_name: "",
|
||||||
|
definition_name_eng: "",
|
||||||
|
description: "",
|
||||||
|
max_steps: 3,
|
||||||
|
allow_self_approval: false,
|
||||||
|
allow_cancel: true,
|
||||||
|
is_active: "Y",
|
||||||
|
});
|
||||||
|
setEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (def: ApprovalDefinition) => {
|
||||||
|
setEditingDef(def);
|
||||||
|
setFormData({
|
||||||
|
definition_name: def.definition_name,
|
||||||
|
definition_name_eng: def.definition_name_eng || "",
|
||||||
|
description: def.description || "",
|
||||||
|
max_steps: def.max_steps,
|
||||||
|
allow_self_approval: def.allow_self_approval,
|
||||||
|
allow_cancel: def.allow_cancel,
|
||||||
|
is_active: def.is_active,
|
||||||
|
});
|
||||||
|
setEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.definition_name.trim()) {
|
||||||
|
toast.warning("결재 유형명을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (editingDef) {
|
||||||
|
res = await updateApprovalDefinition(editingDef.definition_id, formData);
|
||||||
|
} else {
|
||||||
|
res = await createApprovalDefinition(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(editingDef ? "수정되었습니다." : "등록되었습니다.");
|
||||||
|
setEditOpen(false);
|
||||||
|
fetchDefinitions();
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || "저장 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
const res = await deleteApprovalDefinition(deleteTarget.definition_id);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("삭제되었습니다.");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
fetchDefinitions();
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || "삭제 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = definitions.filter(
|
||||||
|
(d) =>
|
||||||
|
d.definition_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(d.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 검색 + 등록 */}
|
||||||
|
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative w-full sm:w-[300px]">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="유형명 또는 설명 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
총 <span className="text-foreground font-semibold">{filtered.length}</span> 건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate} className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
결재 유형 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
|
<p className="text-muted-foreground text-sm">등록된 결재 유형이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">유형명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">최대 단계</TableHead>
|
||||||
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">자가결재</TableHead>
|
||||||
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">회수가능</TableHead>
|
||||||
|
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">상태</TableHead>
|
||||||
|
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold">관리</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((def) => (
|
||||||
|
<TableRow key={def.definition_id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<TableCell className="h-14 text-sm font-medium">{def.definition_name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-14 text-sm">{def.description || "-"}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center text-sm">{def.max_steps}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center text-sm">
|
||||||
|
<Badge variant={def.allow_self_approval ? "default" : "secondary"}>
|
||||||
|
{def.allow_self_approval ? "허용" : "불가"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-14 text-center text-sm">
|
||||||
|
<Badge variant={def.allow_cancel ? "default" : "secondary"}>
|
||||||
|
{def.allow_cancel ? "허용" : "불가"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-14 text-center text-sm">
|
||||||
|
<Badge variant={def.is_active === "Y" ? "default" : "outline"}>
|
||||||
|
{def.is_active === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-14 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(def)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => setDeleteTarget(def)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
{editingDef ? "결재 유형 수정" : "결재 유형 등록"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
결재 유형의 기본 정보를 설정합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">유형명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.definition_name}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, definition_name: e.target.value }))}
|
||||||
|
placeholder="예: 일반 결재, 긴급 결재"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">영문명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.definition_name_eng}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, definition_name_eng: e.target.value }))}
|
||||||
|
placeholder="예: General Approval"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">설명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||||
|
placeholder="유형에 대한 설명"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">최대 결재 단계</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={formData.max_steps}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, max_steps: Number(e.target.value) }))}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">자가 결재 허용</Label>
|
||||||
|
<Switch
|
||||||
|
checked={formData.allow_self_approval}
|
||||||
|
onCheckedChange={(v) => setFormData((p) => ({ ...p, allow_self_approval: v }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">회수 가능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={formData.allow_cancel}
|
||||||
|
onCheckedChange={(v) => setFormData((p) => ({ ...p, allow_cancel: v }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">활성 상태</Label>
|
||||||
|
<Switch
|
||||||
|
checked={formData.is_active === "Y"}
|
||||||
|
onCheckedChange={(v) => setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={() => setEditOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
{editingDef ? "수정" : "등록"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 */}
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>결재 유형 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
"{deleteTarget?.definition_name}"을(를) 삭제하시겠습니까?
|
||||||
|
<br />이 유형에 연결된 결재 요청이 있으면 삭제할 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 결재선 템플릿 관리 탭
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function TemplatesTab() {
|
||||||
|
const [templates, setTemplates] = useState<ApprovalLineTemplate[]>([]);
|
||||||
|
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editingTpl, setEditingTpl] = useState<ApprovalLineTemplate | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
template_name: "",
|
||||||
|
description: "",
|
||||||
|
definition_id: null as number | null,
|
||||||
|
is_active: "Y",
|
||||||
|
steps: [] as Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ApprovalLineTemplate | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const [tplRes, defRes] = await Promise.all([
|
||||||
|
getApprovalTemplates(),
|
||||||
|
getApprovalDefinitions({ is_active: "Y" }),
|
||||||
|
]);
|
||||||
|
if (tplRes.success && tplRes.data) setTemplates(tplRes.data);
|
||||||
|
if (defRes.success && defRes.data) setDefinitions(defRes.data);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingTpl(null);
|
||||||
|
setFormData({
|
||||||
|
template_name: "",
|
||||||
|
description: "",
|
||||||
|
definition_id: null,
|
||||||
|
is_active: "Y",
|
||||||
|
steps: [{ step_order: 1, approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
|
||||||
|
});
|
||||||
|
setEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = async (tpl: ApprovalLineTemplate) => {
|
||||||
|
const res = await getApprovalTemplate(tpl.template_id);
|
||||||
|
if (!res.success || !res.data) {
|
||||||
|
toast.error("템플릿 정보를 불러올 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detail = res.data;
|
||||||
|
setEditingTpl(detail);
|
||||||
|
setFormData({
|
||||||
|
template_name: detail.template_name,
|
||||||
|
description: detail.description || "",
|
||||||
|
definition_id: detail.definition_id || null,
|
||||||
|
is_active: detail.is_active,
|
||||||
|
steps: (detail.steps || []).map((s) => ({
|
||||||
|
step_order: s.step_order,
|
||||||
|
approver_type: s.approver_type,
|
||||||
|
approver_user_id: s.approver_user_id,
|
||||||
|
approver_position: s.approver_position,
|
||||||
|
approver_dept_code: s.approver_dept_code,
|
||||||
|
approver_label: s.approver_label,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
setEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStep = () => {
|
||||||
|
setFormData((p) => ({
|
||||||
|
...p,
|
||||||
|
steps: [
|
||||||
|
...p.steps,
|
||||||
|
{
|
||||||
|
step_order: p.steps.length + 1,
|
||||||
|
approver_type: "user" as const,
|
||||||
|
approver_user_id: "",
|
||||||
|
approver_label: `${p.steps.length + 1}차 결재자`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStep = (idx: number) => {
|
||||||
|
setFormData((p) => ({
|
||||||
|
...p,
|
||||||
|
steps: p.steps.filter((_, i) => i !== idx).map((s, i) => ({ ...s, step_order: i + 1 })),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStep = (idx: number, field: string, value: string) => {
|
||||||
|
setFormData((p) => ({
|
||||||
|
...p,
|
||||||
|
steps: p.steps.map((s, i) => (i === idx ? { ...s, [field]: value } : s)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.template_name.trim()) {
|
||||||
|
toast.warning("템플릿명을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.steps.length === 0) {
|
||||||
|
toast.warning("결재 단계를 최소 1개 추가해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
template_name: formData.template_name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
definition_id: formData.definition_id || undefined,
|
||||||
|
is_active: formData.is_active,
|
||||||
|
steps: formData.steps,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (editingTpl) {
|
||||||
|
res = await updateApprovalTemplate(editingTpl.template_id, payload);
|
||||||
|
} else {
|
||||||
|
res = await createApprovalTemplate(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(editingTpl ? "수정되었습니다." : "등록되었습니다.");
|
||||||
|
setEditOpen(false);
|
||||||
|
fetchData();
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || "저장 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
const res = await deleteApprovalTemplate(deleteTarget.template_id);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("삭제되었습니다.");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
fetchData();
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || "삭제 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = templates.filter(
|
||||||
|
(t) =>
|
||||||
|
t.template_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(t.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 검색 + 등록 */}
|
||||||
|
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative w-full sm:w-[300px]">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="템플릿명 또는 설명 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
총 <span className="text-foreground font-semibold">{filtered.length}</span> 건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate} className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
결재선 템플릿 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
|
<p className="text-muted-foreground text-sm">등록된 결재선 템플릿이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">템플릿명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">연결된 유형</TableHead>
|
||||||
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">단계 수</TableHead>
|
||||||
|
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">상태</TableHead>
|
||||||
|
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold">관리</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((tpl) => (
|
||||||
|
<TableRow key={tpl.template_id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<TableCell className="h-14 text-sm font-medium">{tpl.template_name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-14 text-sm">{tpl.description || "-"}</TableCell>
|
||||||
|
<TableCell className="h-14 text-sm">{tpl.definition_name || "-"}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center text-sm">
|
||||||
|
<Badge variant="secondary">{tpl.steps?.length || 0}단계</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-14 text-center text-sm">
|
||||||
|
<Badge variant={tpl.is_active === "Y" ? "default" : "outline"}>
|
||||||
|
{tpl.is_active === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-14 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(tpl)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => setDeleteTarget(tpl)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
{editingTpl ? "결재선 템플릿 수정" : "결재선 템플릿 등록"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
결재선의 기본 정보와 결재 단계를 설정합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-[60vh] space-y-3 overflow-y-auto sm:space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">템플릿명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.template_name}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, template_name: e.target.value }))}
|
||||||
|
placeholder="예: 일반 3단계 결재선"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">설명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||||
|
placeholder="템플릿에 대한 설명"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">연결 결재 유형</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.definition_id ? String(formData.definition_id) : "none"}
|
||||||
|
onValueChange={(v) => setFormData((p) => ({ ...p, definition_id: v === "none" ? null : Number(v) }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="결재 유형 선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">연결 없음</SelectItem>
|
||||||
|
{definitions.map((d) => (
|
||||||
|
<SelectItem key={d.definition_id} value={String(d.definition_id)}>
|
||||||
|
{d.definition_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">활성 상태</Label>
|
||||||
|
<Switch
|
||||||
|
checked={formData.is_active === "Y"}
|
||||||
|
onCheckedChange={(v) => setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결재 단계 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold sm:text-sm">결재 단계</Label>
|
||||||
|
<Button variant="outline" size="sm" onClick={addStep} className="h-7 gap-1 text-xs">
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
단계 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.steps.length === 0 && (
|
||||||
|
<p className="text-muted-foreground py-4 text-center text-xs">
|
||||||
|
결재 단계를 추가해주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.steps.map((step, idx) => (
|
||||||
|
<div key={idx} className="bg-muted/30 space-y-2 rounded-md border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">{step.step_order}단계</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive h-6 w-6"
|
||||||
|
onClick={() => removeStep(idx)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">결재자 유형</Label>
|
||||||
|
<Select value={step.approver_type} onValueChange={(v) => updateStep(idx, "approver_type", v)}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">사용자 지정</SelectItem>
|
||||||
|
<SelectItem value="position">직급 지정</SelectItem>
|
||||||
|
<SelectItem value="dept">부서 지정</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">표시 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={step.approver_label || ""}
|
||||||
|
onChange={(e) => updateStep(idx, "approver_label", e.target.value)}
|
||||||
|
placeholder="예: 팀장"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{step.approver_type === "user" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">사용자 ID</Label>
|
||||||
|
<Input
|
||||||
|
value={step.approver_user_id || ""}
|
||||||
|
onChange={(e) => updateStep(idx, "approver_user_id", e.target.value)}
|
||||||
|
placeholder="고정 결재자 ID (비워두면 요청 시 지정)"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step.approver_type === "position" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">직급</Label>
|
||||||
|
<Input
|
||||||
|
value={step.approver_position || ""}
|
||||||
|
onChange={(e) => updateStep(idx, "approver_position", e.target.value)}
|
||||||
|
placeholder="예: 부장, 이사"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step.approver_type === "dept" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">부서 코드</Label>
|
||||||
|
<Input
|
||||||
|
value={step.approver_dept_code || ""}
|
||||||
|
onChange={(e) => updateStep(idx, "approver_dept_code", e.target.value)}
|
||||||
|
placeholder="예: DEPT001"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={() => setEditOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
{editingTpl ? "수정" : "등록"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 */}
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>결재선 템플릿 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
"{deleteTarget?.template_name}"을(를) 삭제하시겠습니까?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메인 페이지
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export default function ApprovalManagementPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">결재 관리</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
결재 유형과 결재선 템플릿을 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<Tabs defaultValue="definitions" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="definitions" className="gap-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
결재 유형
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="templates" className="gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
결재선 템플릿
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="definitions">
|
||||||
|
<DefinitionsTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="templates">
|
||||||
|
<TemplatesTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,426 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Loader2, CheckCircle2, XCircle, Clock, FileCheck2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
getApprovalRequests,
|
||||||
|
getApprovalRequest,
|
||||||
|
getMyPendingApprovals,
|
||||||
|
processApprovalLine,
|
||||||
|
cancelApprovalRequest,
|
||||||
|
type ApprovalRequest,
|
||||||
|
type ApprovalLine,
|
||||||
|
} from "@/lib/api/approval";
|
||||||
|
|
||||||
|
// 상태 배지 색상
|
||||||
|
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
requested: { label: "요청됨", variant: "secondary" },
|
||||||
|
in_progress: { label: "진행 중", variant: "default" },
|
||||||
|
approved: { label: "승인됨", variant: "outline" },
|
||||||
|
rejected: { label: "반려됨", variant: "destructive" },
|
||||||
|
cancelled: { label: "취소됨", variant: "secondary" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineStatusConfig: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||||
|
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||||
|
pending: { label: "진행 중", icon: <Clock className="h-3 w-3 text-primary" /> },
|
||||||
|
approved: { label: "승인", icon: <CheckCircle2 className="h-3 w-3 text-green-600" /> },
|
||||||
|
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
|
||||||
|
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 결재 상세 모달
|
||||||
|
interface ApprovalDetailModalProps {
|
||||||
|
request: ApprovalRequest | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
pendingLineId?: number; // 내가 처리해야 할 결재 라인 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalDetailModal({ request, open, onClose, onRefresh, pendingLineId }: ApprovalDetailModalProps) {
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [isCancelling, setIsCancelling] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setComment("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleProcess = async (action: "approved" | "rejected") => {
|
||||||
|
if (!pendingLineId) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
const res = await processApprovalLine(pendingLineId, { action, comment: comment.trim() || undefined });
|
||||||
|
setIsProcessing(false);
|
||||||
|
if (res.success) {
|
||||||
|
onRefresh();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!request) return;
|
||||||
|
setIsCancelling(true);
|
||||||
|
const res = await cancelApprovalRequest(request.request_id);
|
||||||
|
setIsCancelling(false);
|
||||||
|
if (res.success) {
|
||||||
|
onRefresh();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!request) return null;
|
||||||
|
|
||||||
|
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
|
<FileCheck2 className="h-5 w-5" />
|
||||||
|
{request.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
<Badge variant={statusInfo.variant} className="mr-2">
|
||||||
|
{statusInfo.label}
|
||||||
|
</Badge>
|
||||||
|
요청자: {request.requester_name || request.requester_id}
|
||||||
|
{request.requester_dept ? ` (${request.requester_dept})` : ""}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 결재 사유 */}
|
||||||
|
{request.description && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-1 text-xs font-medium">결재 사유</p>
|
||||||
|
<p className="rounded-md bg-muted p-3 text-xs sm:text-sm">{request.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결재선 */}
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-2 text-xs font-medium">결재선</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(request.lines || []).map((line) => {
|
||||||
|
const lineStatus = lineStatusConfig[line.status] || { label: line.status, icon: null };
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={line.line_id}
|
||||||
|
className="flex items-start justify-between rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{lineStatus.icon}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium sm:text-sm">
|
||||||
|
{line.approver_label || `${line.step_order}차 결재`} — {line.approver_name || line.approver_id}
|
||||||
|
</p>
|
||||||
|
{line.approver_position && (
|
||||||
|
<p className="text-muted-foreground text-[10px] sm:text-xs">{line.approver_position}</p>
|
||||||
|
)}
|
||||||
|
{line.comment && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
의견: {line.comment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-[10px] sm:text-xs">{lineStatus.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 승인/반려 입력 (대기 상태일 때만) */}
|
||||||
|
{pendingLineId && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-1 text-xs font-medium">결재 의견 (선택사항)</p>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="결재 의견을 입력하세요"
|
||||||
|
className="min-h-[60px] text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-wrap gap-2 sm:gap-1">
|
||||||
|
{/* 요청자만 취소 가능 (요청됨/진행 중 상태) */}
|
||||||
|
{(request.status === "requested" || request.status === "in_progress") && !pendingLineId && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isCancelling}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
{isCancelling ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : null}
|
||||||
|
회수
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{pendingLineId && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleProcess("rejected")}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <XCircle className="mr-1 h-3 w-3" />}
|
||||||
|
반려
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleProcess("approved")}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <CheckCircle2 className="mr-1 h-3 w-3" />}
|
||||||
|
승인
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 대기 행 (ApprovalLine 기반)
|
||||||
|
function ApprovalLineRow({ line, onClick }: { line: ApprovalLine; onClick: () => void }) {
|
||||||
|
const statusInfo = lineStatusConfig[line.status] || { label: line.status, icon: null };
|
||||||
|
const createdAt = line.request_created_at || line.created_at;
|
||||||
|
const formattedDate = createdAt
|
||||||
|
? new Date(createdAt).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium sm:text-sm">{line.title || "제목 없음"}</p>
|
||||||
|
{line.requester_name && (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||||
|
요청자: {line.requester_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<span className="flex items-center gap-1 text-[10px] sm:text-xs">
|
||||||
|
{statusInfo.icon}
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 요청 행 (ApprovalRequest 기반)
|
||||||
|
function ApprovalRequestRow({ request, onClick }: { request: ApprovalRequest; onClick: () => void }) {
|
||||||
|
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
|
||||||
|
const formattedDate = request.created_at
|
||||||
|
? new Date(request.created_at).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium sm:text-sm">{request.title}</p>
|
||||||
|
{request.requester_name && (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||||
|
요청자: {request.requester_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<Badge variant={statusInfo.variant} className="text-[10px]">
|
||||||
|
{statusInfo.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 빈 상태 컴포넌트
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||||
|
<FileCheck2 className="h-7 w-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 결재함 페이지
|
||||||
|
export default function ApprovalPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState("pending");
|
||||||
|
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
|
||||||
|
const [myRequests, setMyRequests] = useState<ApprovalRequest[]>([]);
|
||||||
|
const [completedRequests, setCompletedRequests] = useState<ApprovalRequest[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 상세 모달
|
||||||
|
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
|
||||||
|
const [selectedPendingLineId, setSelectedPendingLineId] = useState<number | undefined>();
|
||||||
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const [pendingRes, myRes, completedRes] = await Promise.all([
|
||||||
|
getMyPendingApprovals(),
|
||||||
|
// my_approvals 없이 호출 → 백엔드에서 현재 사용자의 요청 건 반환
|
||||||
|
getApprovalRequests(),
|
||||||
|
getApprovalRequests({ status: "approved" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (pendingRes.success && pendingRes.data) setPendingLines(pendingRes.data);
|
||||||
|
if (myRes.success && myRes.data) setMyRequests(myRes.data);
|
||||||
|
if (completedRes.success && completedRes.data) setCompletedRequests(completedRes.data);
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleOpenDetail = async (requestId: number, pendingLineId?: number) => {
|
||||||
|
const res = await getApprovalRequest(requestId);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setSelectedRequest(res.data);
|
||||||
|
setSelectedPendingLineId(pendingLineId);
|
||||||
|
setDetailModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenFromLine = async (line: ApprovalLine) => {
|
||||||
|
if (!line.request_id) return;
|
||||||
|
await handleOpenDetail(line.request_id, line.line_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-3xl p-4 sm:p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold sm:text-2xl">결재함</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">결재 요청을 확인하고 처리합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="mb-4 grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="pending" className="text-xs sm:text-sm">
|
||||||
|
대기함
|
||||||
|
{pendingLines.length > 0 && (
|
||||||
|
<Badge variant="destructive" className="ml-1 h-4 min-w-[16px] px-1 text-[10px]">
|
||||||
|
{pendingLines.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="my-requests" className="text-xs sm:text-sm">
|
||||||
|
요청함
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="completed" className="text-xs sm:text-sm">
|
||||||
|
완료함
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 대기함: 내가 결재해야 할 건 */}
|
||||||
|
<TabsContent value="pending">
|
||||||
|
{pendingLines.length === 0 ? (
|
||||||
|
<EmptyState message="결재 대기 중인 건이 없습니다." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingLines.map((line) => (
|
||||||
|
<ApprovalLineRow
|
||||||
|
key={line.line_id}
|
||||||
|
line={line}
|
||||||
|
onClick={() => handleOpenFromLine(line)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 요청함: 내가 요청한 건 */}
|
||||||
|
<TabsContent value="my-requests">
|
||||||
|
{myRequests.length === 0 ? (
|
||||||
|
<EmptyState message="요청한 결재 건이 없습니다." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{myRequests.map((req) => (
|
||||||
|
<ApprovalRequestRow
|
||||||
|
key={req.request_id}
|
||||||
|
request={req}
|
||||||
|
onClick={() => handleOpenDetail(req.request_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 완료함 */}
|
||||||
|
<TabsContent value="completed">
|
||||||
|
{completedRequests.length === 0 ? (
|
||||||
|
<EmptyState message="완료된 결재 건이 없습니다." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{completedRequests.map((req) => (
|
||||||
|
<ApprovalRequestRow
|
||||||
|
key={req.request_id}
|
||||||
|
request={req}
|
||||||
|
onClick={() => handleOpenDetail(req.request_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* 결재 상세 모달 */}
|
||||||
|
<ApprovalDetailModal
|
||||||
|
request={selectedRequest}
|
||||||
|
open={detailModalOpen}
|
||||||
|
onClose={() => setDetailModalOpen(false)}
|
||||||
|
onRefresh={loadData}
|
||||||
|
pendingLineId={selectedPendingLineId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { AuthProvider } from "@/contexts/AuthContext";
|
import { AuthProvider } from "@/contexts/AuthContext";
|
||||||
import { MenuProvider } from "@/contexts/MenuContext";
|
import { MenuProvider } from "@/contexts/MenuContext";
|
||||||
import { AppLayout } from "@/components/layout/AppLayout";
|
import { AppLayout } from "@/components/layout/AppLayout";
|
||||||
|
import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener";
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<MenuProvider>
|
<MenuProvider>
|
||||||
<AppLayout>{children}</AppLayout>
|
<AppLayout>{children}</AppLayout>
|
||||||
|
<ApprovalGlobalListener />
|
||||||
</MenuProvider>
|
</MenuProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -0,0 +1,52 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { ApprovalRequestModal, type ApprovalModalEventDetail } from "./ApprovalRequestModal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 결재 요청 모달 리스너
|
||||||
|
*
|
||||||
|
* CustomEvent('open-approval-modal')를 수신하여 ApprovalRequestModal을 엽니다.
|
||||||
|
*
|
||||||
|
* 이벤트 발송 예시:
|
||||||
|
* window.dispatchEvent(new CustomEvent('open-approval-modal', {
|
||||||
|
* detail: {
|
||||||
|
* targetTable: 'purchase_orders',
|
||||||
|
* targetRecordId: '123',
|
||||||
|
* targetRecordData: { ... },
|
||||||
|
* definitionId: 1,
|
||||||
|
* screenId: 10,
|
||||||
|
* buttonComponentId: 'btn-approval-001',
|
||||||
|
* }
|
||||||
|
* }));
|
||||||
|
*/
|
||||||
|
export const ApprovalGlobalListener: React.FC = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [eventDetail, setEventDetail] = useState<ApprovalModalEventDetail | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpenModal = (e: Event) => {
|
||||||
|
const customEvent = e as CustomEvent<ApprovalModalEventDetail>;
|
||||||
|
setEventDetail(customEvent.detail || null);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("open-approval-modal", handleOpenModal);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("open-approval-modal", handleOpenModal);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApprovalRequestModal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
setOpen(v);
|
||||||
|
if (!v) setEventDetail(null);
|
||||||
|
}}
|
||||||
|
eventDetail={eventDetail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApprovalGlobalListener;
|
||||||
|
|
@ -0,0 +1,483 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
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 { Badge } from "@/components/ui/badge";
|
||||||
|
import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { createApprovalRequest } from "@/lib/api/approval";
|
||||||
|
import { getUserList } from "@/lib/api/user";
|
||||||
|
|
||||||
|
// 결재 방식
|
||||||
|
type ApprovalMode = "sequential" | "parallel";
|
||||||
|
|
||||||
|
interface ApproverRow {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
position_name: string;
|
||||||
|
dept_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalModalEventDetail {
|
||||||
|
targetTable: string;
|
||||||
|
targetRecordId: string;
|
||||||
|
targetRecordData?: Record<string, any>;
|
||||||
|
definitionId?: number;
|
||||||
|
screenId?: number;
|
||||||
|
buttonComponentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalRequestModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
eventDetail?: ApprovalModalEventDetail | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSearchResult {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
positionName?: string;
|
||||||
|
deptName?: string;
|
||||||
|
deptCode?: string;
|
||||||
|
email?: string;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
position_name?: string;
|
||||||
|
dept_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genId(): string {
|
||||||
|
return `a_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
eventDetail,
|
||||||
|
}) => {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [approvalMode, setApprovalMode] = useState<ApprovalMode>("sequential");
|
||||||
|
const [approvers, setApprovers] = useState<ApproverRow[]>([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 사용자 검색 상태
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<UserSearchResult[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 모달 닫힐 때 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setTitle("");
|
||||||
|
setDescription("");
|
||||||
|
setApprovalMode("sequential");
|
||||||
|
setApprovers([]);
|
||||||
|
setError(null);
|
||||||
|
setSearchOpen(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 사용자 검색 (디바운스)
|
||||||
|
const searchUsers = useCallback(async (query: string) => {
|
||||||
|
if (!query.trim() || query.trim().length < 1) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await getUserList({ search: query.trim(), limit: 20 });
|
||||||
|
const data = res?.data || res || [];
|
||||||
|
const rawUsers: any[] = Array.isArray(data) ? data : [];
|
||||||
|
const users: UserSearchResult[] = rawUsers.map((u: any) => ({
|
||||||
|
userId: u.userId || u.user_id || "",
|
||||||
|
userName: u.userName || u.user_name || "",
|
||||||
|
positionName: u.positionName || u.position_name || "",
|
||||||
|
deptName: u.deptName || u.dept_name || "",
|
||||||
|
deptCode: u.deptCode || u.dept_code || "",
|
||||||
|
email: u.email || "",
|
||||||
|
}));
|
||||||
|
const existingIds = new Set(approvers.map((a) => a.user_id));
|
||||||
|
setSearchResults(users.filter((u) => u.userId && !existingIds.has(u.userId)));
|
||||||
|
} catch {
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, [approvers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimerRef.current = setTimeout(() => {
|
||||||
|
searchUsers(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery, searchUsers]);
|
||||||
|
|
||||||
|
const addApprover = (user: UserSearchResult) => {
|
||||||
|
setApprovers((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: genId(),
|
||||||
|
user_id: user.userId,
|
||||||
|
user_name: user.userName,
|
||||||
|
position_name: user.positionName || "",
|
||||||
|
dept_name: user.deptName || "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSearchQuery("");
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearchOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeApprover = (id: string) => {
|
||||||
|
setApprovers((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveApprover = (idx: number, direction: "up" | "down") => {
|
||||||
|
setApprovers((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
|
||||||
|
if (targetIdx < 0 || targetIdx >= next.length) return prev;
|
||||||
|
[next[idx], next[targetIdx]] = [next[targetIdx], next[idx]];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError("결재 제목을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (approvers.length === 0) {
|
||||||
|
setError("결재자를 1명 이상 추가해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!eventDetail?.targetTable) {
|
||||||
|
setError("결재 대상 테이블 정보가 없습니다. 버튼 설정을 확인해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await createApprovalRequest({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
target_table: eventDetail.targetTable,
|
||||||
|
target_record_id: eventDetail.targetRecordId || undefined,
|
||||||
|
target_record_data: eventDetail.targetRecordData,
|
||||||
|
approval_mode: approvalMode,
|
||||||
|
screen_id: eventDetail.screenId,
|
||||||
|
button_component_id: eventDetail.buttonComponentId,
|
||||||
|
approvers: approvers.map((a, idx) => ({
|
||||||
|
approver_id: a.user_id,
|
||||||
|
approver_name: a.user_name,
|
||||||
|
approver_position: a.position_name || undefined,
|
||||||
|
approver_dept: a.dept_name || undefined,
|
||||||
|
approver_label:
|
||||||
|
approvalMode === "sequential"
|
||||||
|
? `${idx + 1}차 결재`
|
||||||
|
: "동시 결재",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("결재 요청이 완료되었습니다.");
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
setError(res.error || res.message || "결재 요청에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">결재 상신</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
결재 방식을 선택하고 결재자를 검색하여 추가합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[65vh] space-y-4 overflow-y-auto pr-1">
|
||||||
|
{/* 결재 제목 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="approval-title" className="text-xs sm:text-sm">
|
||||||
|
결재 제목 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="approval-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="결재 제목을 입력하세요"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결재 사유 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="approval-desc" className="text-xs sm:text-sm">
|
||||||
|
결재 사유
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="approval-desc"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="결재 사유를 입력하세요 (선택사항)"
|
||||||
|
className="min-h-[60px] text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결재 방식 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">결재 방식</Label>
|
||||||
|
<div className="mt-1.5 grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApprovalMode("sequential")}
|
||||||
|
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||||
|
approvalMode === "sequential"
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||||
|
: "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium sm:text-sm">다단 결재</p>
|
||||||
|
<p className="text-muted-foreground text-[10px]">순차적으로 결재</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApprovalMode("parallel")}
|
||||||
|
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||||
|
approvalMode === "parallel"
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||||
|
: "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers className="h-4 w-4 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium sm:text-sm">동시 결재</p>
|
||||||
|
<p className="text-muted-foreground text-[10px]">모든 결재자 동시 진행</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결재자 추가 (사용자 검색) */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
결재자 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{approvers.length}명 선택됨
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 입력 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setSearchOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setSearchOpen(true)}
|
||||||
|
placeholder="이름 또는 사번으로 검색..."
|
||||||
|
className="h-8 pl-9 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 검색 결과 드롭다운 */}
|
||||||
|
{searchOpen && searchQuery.trim() && (
|
||||||
|
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
||||||
|
{isSearching ? (
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground ml-2 text-xs">검색 중...</span>
|
||||||
|
</div>
|
||||||
|
) : searchResults.length === 0 ? (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<p className="text-muted-foreground text-xs">검색 결과가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{searchResults.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.userId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addApprover(user)}
|
||||||
|
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium sm:text-sm">
|
||||||
|
{user.userName}
|
||||||
|
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||||
|
({user.userId})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground truncate text-[10px]">
|
||||||
|
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 클릭 외부 영역 닫기 */}
|
||||||
|
{searchOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setSearchOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 결재자 목록 */}
|
||||||
|
{approvers.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground mt-3 rounded-md border border-dashed p-4 text-center text-xs">
|
||||||
|
위 검색창에서 결재자를 검색하여 추가하세요
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{approvers.map((approver, idx) => (
|
||||||
|
<div
|
||||||
|
key={approver.id}
|
||||||
|
className="bg-muted/30 flex items-center gap-2 rounded-md border p-2"
|
||||||
|
>
|
||||||
|
{/* 순서 표시 */}
|
||||||
|
{approvalMode === "sequential" ? (
|
||||||
|
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveApprover(idx, "up")}
|
||||||
|
disabled={idx === 0}
|
||||||
|
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3 w-3 rotate-90" />
|
||||||
|
</button>
|
||||||
|
<Badge variant="outline" className="h-5 min-w-[24px] justify-center px-1 text-[10px]">
|
||||||
|
{idx + 1}
|
||||||
|
</Badge>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveApprover(idx, "down")}
|
||||||
|
disabled={idx === approvers.length - 1}
|
||||||
|
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3 w-3 rotate-90" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
|
||||||
|
동시
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사용자 정보 */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium">
|
||||||
|
{approver.user_name}
|
||||||
|
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||||
|
({approver.user_id})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground truncate text-[10px]">
|
||||||
|
{[approver.dept_name, approver.position_name].filter(Boolean).join(" / ") || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제거 버튼 */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
onClick={() => removeApprover(approver.id)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 결재 흐름 시각화 */}
|
||||||
|
{approvalMode === "sequential" && approvers.length > 1 && (
|
||||||
|
<p className="text-muted-foreground text-center text-[10px]">
|
||||||
|
{approvers.map((a) => a.user_name).join(" → ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-destructive/10 rounded-md p-2">
|
||||||
|
<p className="text-destructive text-xs">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || approvers.length === 0}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
요청 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`결재 상신 (${approvers.length}명)`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApprovalRequestModal;
|
||||||
|
|
@ -6,9 +6,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.ReactNode }[] = [
|
const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.ReactNode; barcodeType?: string }[] = [
|
||||||
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
|
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
|
||||||
{ type: "barcode", label: "바코드", icon: <Barcode className="h-4 w-4" /> },
|
{ type: "barcode", label: "바코드", icon: <Barcode className="h-4 w-4" /> },
|
||||||
|
{ type: "barcode", label: "QR 코드", icon: <Barcode className="h-4 w-4" />, barcodeType: "QR" },
|
||||||
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
|
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
|
||||||
{ type: "line", label: "선", icon: <Minus className="h-4 w-4" /> },
|
{ type: "line", label: "선", icon: <Minus className="h-4 w-4" /> },
|
||||||
{ type: "rectangle", label: "사각형", icon: <Square className="h-4 w-4" /> },
|
{ type: "rectangle", label: "사각형", icon: <Square className="h-4 w-4" /> },
|
||||||
|
|
@ -16,22 +17,24 @@ const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.R
|
||||||
|
|
||||||
const MM_TO_PX = 4;
|
const MM_TO_PX = 4;
|
||||||
|
|
||||||
function defaultComponent(type: BarcodeLabelComponent["type"]): BarcodeLabelComponent {
|
function defaultComponent(type: BarcodeLabelComponent["type"], barcodeType?: string): BarcodeLabelComponent {
|
||||||
const id = `comp_${uuidv4()}`;
|
const id = `comp_${uuidv4()}`;
|
||||||
const base = { id, type, x: 10 * MM_TO_PX, y: 10 * MM_TO_PX, width: 80, height: 24, zIndex: 0 };
|
const base = { id, type, x: 10 * MM_TO_PX, y: 10 * MM_TO_PX, width: 80, height: 24, zIndex: 0 };
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "text":
|
case "text":
|
||||||
return { ...base, content: "텍스트", fontSize: 10, fontColor: "#000000" };
|
return { ...base, content: "텍스트", fontSize: 10, fontColor: "#000000" };
|
||||||
case "barcode":
|
case "barcode": {
|
||||||
|
const isQR = barcodeType === "QR";
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
width: 120,
|
width: isQR ? 100 : 120,
|
||||||
height: 40,
|
height: isQR ? 100 : 40,
|
||||||
barcodeType: "CODE128",
|
barcodeType: barcodeType || "CODE128",
|
||||||
barcodeValue: "123456789",
|
barcodeValue: isQR ? "" : "123456789",
|
||||||
showBarcodeText: true,
|
showBarcodeText: !isQR,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case "image":
|
case "image":
|
||||||
return { ...base, width: 60, height: 60, imageUrl: "", objectFit: "contain" };
|
return { ...base, width: 60, height: 60, imageUrl: "", objectFit: "contain" };
|
||||||
case "line":
|
case "line":
|
||||||
|
|
@ -47,14 +50,16 @@ function DraggableItem({
|
||||||
type,
|
type,
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
|
barcodeType,
|
||||||
}: {
|
}: {
|
||||||
type: BarcodeLabelComponent["type"];
|
type: BarcodeLabelComponent["type"];
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
|
barcodeType?: string;
|
||||||
}) {
|
}) {
|
||||||
const [{ isDragging }, drag] = useDrag(() => ({
|
const [{ isDragging }, drag] = useDrag(() => ({
|
||||||
type: "barcode-component",
|
type: "barcode-component",
|
||||||
item: { component: defaultComponent(type) },
|
item: { component: defaultComponent(type, barcodeType) },
|
||||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -78,8 +83,14 @@ export function BarcodeComponentPalette() {
|
||||||
<CardTitle className="text-sm">요소 추가</CardTitle>
|
<CardTitle className="text-sm">요소 추가</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{ITEMS.map((item) => (
|
{ITEMS.map((item, idx) => (
|
||||||
<DraggableItem key={item.type} type={item.type} label={item.label} icon={item.icon} />
|
<DraggableItem
|
||||||
|
key={item.barcodeType ? `${item.type}_${item.barcodeType}` : `${item.type}_${idx}`}
|
||||||
|
type={item.type}
|
||||||
|
label={item.label}
|
||||||
|
icon={item.icon}
|
||||||
|
barcodeType={item.barcodeType}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -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,36 +82,56 @@ 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
|
<div
|
||||||
key={`canvas-${widthMm}-${heightMm}`}
|
className="flex shrink-0 items-center justify-center"
|
||||||
ref={(r) => {
|
style={{ width: scaledW, height: scaledH }}
|
||||||
(canvasRef as any).current = r;
|
|
||||||
drop(r);
|
|
||||||
}}
|
|
||||||
className="relative bg-white shadow-lg"
|
|
||||||
style={{
|
|
||||||
width: widthPx,
|
|
||||||
height: heightPx,
|
|
||||||
minWidth: widthPx,
|
|
||||||
minHeight: heightPx,
|
|
||||||
backgroundImage: showGrid
|
|
||||||
? `linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
|
||||||
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)`
|
|
||||||
: undefined,
|
|
||||||
backgroundSize: showGrid ? `${MM_TO_PX * 5}px ${MM_TO_PX * 5}px` : undefined,
|
|
||||||
outline: isOver ? "2px dashed #2563eb" : "1px solid #d1d5db",
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) selectComponent(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{components.map((c) => (
|
<div
|
||||||
<BarcodeLabelCanvasComponent key={c.id} component={c} />
|
style={{
|
||||||
))}
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
key={`canvas-${widthMm}-${heightMm}`}
|
||||||
|
ref={(r) => {
|
||||||
|
(canvasRef as { current: HTMLDivElement | null }).current = r;
|
||||||
|
drop(r);
|
||||||
|
}}
|
||||||
|
className="relative bg-white shadow-lg"
|
||||||
|
style={{
|
||||||
|
width: widthPx,
|
||||||
|
height: heightPx,
|
||||||
|
minWidth: widthPx,
|
||||||
|
minHeight: heightPx,
|
||||||
|
backgroundImage: showGrid
|
||||||
|
? `linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)`
|
||||||
|
: undefined,
|
||||||
|
backgroundSize: showGrid ? `${MM_TO_PX * 5}px ${MM_TO_PX * 5}px` : undefined,
|
||||||
|
outline: isOver ? "2px dashed #2563eb" : "1px solid #d1d5db",
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) selectComponent(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{components.map((c) => (
|
||||||
|
<BarcodeLabelCanvasComponent key={c.id} component={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,125 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2, Plus } from "lucide-react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
||||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||||
|
|
||||||
|
// QR 기본 키: 품번, 품명, 규격
|
||||||
|
const DEFAULT_QR_JSON_KEYS = ["part_no", "part_name", "spec"];
|
||||||
|
|
||||||
|
function parseQRJsonValue(str: string): Record<string, string> {
|
||||||
|
const trimmed = (str || "").trim();
|
||||||
|
if (!trimmed) return {};
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(trimmed);
|
||||||
|
if (o && typeof o === "object" && !Array.isArray(o)) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(o).map(([k, v]) => [String(k), v != null ? String(v) : ""])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function QRJsonFields({
|
||||||
|
selected,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
|
selected: BarcodeLabelComponent;
|
||||||
|
update: (u: Partial<BarcodeLabelComponent>) => void;
|
||||||
|
}) {
|
||||||
|
const [pairs, setPairs] = useState<{ key: string; value: string }[]>(() => {
|
||||||
|
const parsed = parseQRJsonValue(selected.barcodeValue || "");
|
||||||
|
if (Object.keys(parsed).length > 0) {
|
||||||
|
return Object.entries(parsed).map(([key, value]) => ({ key, value }));
|
||||||
|
}
|
||||||
|
return DEFAULT_QR_JSON_KEYS.map((key) => ({ key, value: "" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 바코드 값이 바깥에서 바뀌면 파싱해서 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
const parsed = parseQRJsonValue(selected.barcodeValue || "");
|
||||||
|
if (Object.keys(parsed).length > 0) {
|
||||||
|
setPairs(Object.entries(parsed).map(([key, value]) => ({ key, value: String(value ?? "") })));
|
||||||
|
}
|
||||||
|
}, [selected.barcodeValue]);
|
||||||
|
|
||||||
|
const applyJson = () => {
|
||||||
|
const obj: Record<string, string> = {};
|
||||||
|
pairs.forEach(({ key, value }) => {
|
||||||
|
const k = key.trim();
|
||||||
|
if (k) obj[k] = value.trim();
|
||||||
|
});
|
||||||
|
update({ barcodeValue: JSON.stringify(obj) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPair = (index: number, field: "key" | "value", val: string) => {
|
||||||
|
setPairs((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
if (!next[index]) next[index] = { key: "", value: "" };
|
||||||
|
next[index] = { ...next[index], [field]: val };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRow = () => setPairs((prev) => [...prev, { key: "", value: "" }]);
|
||||||
|
const removeRow = (index: number) =>
|
||||||
|
setPairs((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-primary/30 bg-muted/20 p-3">
|
||||||
|
<Label className="text-xs font-medium">여러 값 입력 → JSON으로 QR 생성</Label>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px]">키는 자유 입력, 값 입력 후 적용 버튼을 누르면 QR에 반영됩니다.</p>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{pairs.map((p, i) => (
|
||||||
|
<div key={i} className="flex gap-1 items-center">
|
||||||
|
<Input
|
||||||
|
className="h-8 flex-1 min-w-0 text-xs"
|
||||||
|
placeholder="키 (예: part_no)"
|
||||||
|
value={p.key}
|
||||||
|
onChange={(e) => setPair(i, "key", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="h-8 flex-1 min-w-0 text-xs"
|
||||||
|
placeholder="값"
|
||||||
|
value={p.value}
|
||||||
|
onChange={(e) => setPair(i, "value", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 text-destructive"
|
||||||
|
onClick={() => removeRow(i)}
|
||||||
|
disabled={pairs.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-1">
|
||||||
|
<Button type="button" size="sm" variant="outline" className="flex-1 gap-1" onClick={addRow}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" className="flex-1" onClick={applyJson}>
|
||||||
|
JSON으로 QR 적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BarcodeDesignerRightPanel() {
|
export function BarcodeDesignerRightPanel() {
|
||||||
const {
|
const {
|
||||||
components,
|
components,
|
||||||
|
|
@ -56,8 +167,8 @@ export function BarcodeDesignerRightPanel() {
|
||||||
updateComponent(selected.id, updates);
|
updateComponent(selected.id, updates);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72 border-l bg-white">
|
<div className="flex w-72 flex-col border-l bg-white overflow-hidden">
|
||||||
<div className="border-b p-2 flex items-center justify-between">
|
<div className="shrink-0 border-b p-2 flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">속성</span>
|
<span className="text-sm font-medium">속성</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -71,6 +182,7 @@ export function BarcodeDesignerRightPanel() {
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -161,12 +273,15 @@ export function BarcodeDesignerRightPanel() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{selected.barcodeType === "QR" && (
|
||||||
|
<QRJsonFields selected={selected} update={update} />
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">값</Label>
|
<Label className="text-xs">{selected.barcodeType === "QR" ? "값 (직접 JSON 입력)" : "값"}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={selected.barcodeValue || ""}
|
value={selected.barcodeValue || ""}
|
||||||
onChange={(e) => update({ barcodeValue: e.target.value })}
|
onChange={(e) => update({ barcodeValue: e.target.value })}
|
||||||
placeholder="123456789"
|
placeholder={selected.barcodeType === "QR" ? '{"part_no":"","part_name":"","spec":""}' : "123456789"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -246,6 +361,7 @@ export function BarcodeDesignerRightPanel() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
Building2,
|
Building2,
|
||||||
|
FileCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
@ -524,6 +525,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
<span>프로필</span>
|
<span>프로필</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||||
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
|
<span>결재함</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>로그아웃</span>
|
<span>로그아웃</span>
|
||||||
|
|
@ -692,6 +698,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
<span>프로필</span>
|
<span>프로필</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||||
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
|
<span>결재함</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>로그아웃</span>
|
<span>로그아웃</span>
|
||||||
|
|
|
||||||
|
|
@ -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" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { LogOut, User } from "lucide-react";
|
import { LogOut, User, FileCheck } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface UserDropdownProps {
|
interface UserDropdownProps {
|
||||||
user: any;
|
user: any;
|
||||||
|
|
@ -20,6 +21,8 @@ interface UserDropdownProps {
|
||||||
* 사용자 드롭다운 메뉴 컴포넌트
|
* 사용자 드롭다운 메뉴 컴포넌트
|
||||||
*/
|
*/
|
||||||
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
|
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -79,6 +82,11 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
<span>프로필</span>
|
<span>프로필</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||||
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
|
<span>결재함</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={onLogout}>
|
<DropdownMenuItem onClick={onLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>로그아웃</span>
|
<span>로그아웃</span>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -577,8 +577,10 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
const getActionDisplayName = (actionType: ButtonActionType): string => {
|
const getActionDisplayName = (actionType: ButtonActionType): string => {
|
||||||
const displayNames: Record<ButtonActionType, string> = {
|
const displayNames: Record<ButtonActionType, string> = {
|
||||||
save: "저장",
|
save: "저장",
|
||||||
|
cancel: "취소",
|
||||||
delete: "삭제",
|
delete: "삭제",
|
||||||
edit: "수정",
|
edit: "수정",
|
||||||
|
copy: "복사",
|
||||||
add: "추가",
|
add: "추가",
|
||||||
search: "검색",
|
search: "검색",
|
||||||
reset: "초기화",
|
reset: "초기화",
|
||||||
|
|
@ -589,6 +591,9 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
newWindow: "새 창",
|
newWindow: "새 창",
|
||||||
navigate: "페이지 이동",
|
navigate: "페이지 이동",
|
||||||
control: "제어",
|
control: "제어",
|
||||||
|
transferData: "데이터 전달",
|
||||||
|
quickInsert: "즉시 저장",
|
||||||
|
approval: "결재",
|
||||||
};
|
};
|
||||||
return displayNames[actionType] || actionType;
|
return displayNames[actionType] || actionType;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
||||||
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||||
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
||||||
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
|
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
|
||||||
|
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
|
||||||
|
|
||||||
// 🆕 제목 블록 타입
|
// 🆕 제목 블록 타입
|
||||||
interface TitleBlock {
|
interface TitleBlock {
|
||||||
|
|
@ -107,6 +108,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// 결재 유형 목록 상태
|
||||||
|
const [approvalDefinitions, setApprovalDefinitions] = useState<ApprovalDefinition[]>([]);
|
||||||
|
const [approvalDefinitionsLoading, setApprovalDefinitionsLoading] = useState(false);
|
||||||
|
|
||||||
// 🆕 그룹화 컬럼 선택용 상태
|
// 🆕 그룹화 컬럼 선택용 상태
|
||||||
const [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]);
|
const [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||||
const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
|
const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
|
||||||
|
|
@ -689,6 +694,25 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
fetchScreens();
|
fetchScreens();
|
||||||
}, [currentScreenCompanyCode]);
|
}, [currentScreenCompanyCode]);
|
||||||
|
|
||||||
|
// 결재 유형 목록 가져오기 (approval 액션일 때)
|
||||||
|
useEffect(() => {
|
||||||
|
if (localInputs.actionType !== "approval") return;
|
||||||
|
const fetchApprovalDefinitions = async () => {
|
||||||
|
setApprovalDefinitionsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getApprovalDefinitions({ is_active: "Y" });
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setApprovalDefinitions(res.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 조용히 실패
|
||||||
|
} finally {
|
||||||
|
setApprovalDefinitionsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchApprovalDefinitions();
|
||||||
|
}, [localInputs.actionType]);
|
||||||
|
|
||||||
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
|
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTableColumns = async () => {
|
const fetchTableColumns = async () => {
|
||||||
|
|
@ -827,10 +851,12 @@ 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>
|
||||||
<SelectItem value="control">제어 흐름</SelectItem>
|
<SelectItem value="control">제어 흐름</SelectItem>
|
||||||
|
<SelectItem value="approval">결재 요청</SelectItem>
|
||||||
|
|
||||||
{/* 특수 기능 (필요 시 사용) */}
|
{/* 특수 기능 (필요 시 사용) */}
|
||||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||||
|
|
@ -2405,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">
|
||||||
|
|
@ -3730,6 +3758,79 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 결재 요청(approval) 액션 설정 */}
|
||||||
|
{localInputs.actionType === "approval" && (
|
||||||
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||||
|
<h4 className="text-foreground text-sm font-medium">결재 요청 설정</h4>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
버튼 클릭 시 결재 요청 모달이 열립니다. 결재 유형을 선택하면 기본 결재선이 자동으로 세팅됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="approval-definition" className="text-xs sm:text-sm">
|
||||||
|
결재 유형
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={String(component.componentConfig?.action?.approvalDefinitionId || "")}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onUpdateProperty("componentConfig.action.approvalDefinitionId", value === "none" ? null : Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder={approvalDefinitionsLoading ? "로딩 중..." : "결재 유형 선택 (선택사항)"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">유형 없음 (직접 설정)</SelectItem>
|
||||||
|
{approvalDefinitions.map((def) => (
|
||||||
|
<SelectItem key={def.definition_id} value={String(def.definition_id)}>
|
||||||
|
{def.definition_name}
|
||||||
|
{def.description ? ` - ${def.description}` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
결재 유형을 선택하면 기본 결재선 템플릿이 자동 적용됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="approval-target-table" className="text-xs sm:text-sm">
|
||||||
|
대상 테이블
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="approval-target-table"
|
||||||
|
placeholder={currentTableName || "예: purchase_orders"}
|
||||||
|
value={component.componentConfig?.action?.approvalTargetTable || currentTableName || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.approvalTargetTable", e.target.value)}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
readOnly={!!currentTableName && !component.componentConfig?.action?.approvalTargetTable}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
{currentTableName
|
||||||
|
? `현재 화면 테이블 "${currentTableName}" 자동 적용됨`
|
||||||
|
: "결재 대상 레코드가 저장된 테이블명"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="approval-record-id-field" className="text-xs sm:text-sm">
|
||||||
|
레코드 ID 필드명
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="approval-record-id-field"
|
||||||
|
placeholder="예: id, purchase_id"
|
||||||
|
value={component.componentConfig?.action?.approvalRecordIdField || "id"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.approvalRecordIdField", e.target.value)}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
현재 선택된 레코드의 PK 컬럼명
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 🆕 이벤트 발송 액션 설정 */}
|
{/* 🆕 이벤트 발송 액션 설정 */}
|
||||||
{localInputs.actionType === "event" && (
|
{localInputs.actionType === "event" && (
|
||||||
<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">
|
||||||
|
|
@ -3899,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>
|
||||||
|
|
@ -4589,3 +4690,4 @@ const ExcelUploadConfigSection: React.FC<{
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export interface ActionTabProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동작 탭: 클릭 이벤트, 네비게이션, 모달 열기, 확인 다이얼로그 등 동작 설정
|
||||||
|
* 실제 UI는 메인 ButtonConfigPanel에서 렌더링 후 children으로 전달
|
||||||
|
*/
|
||||||
|
export const ActionTab: React.FC<ActionTabProps> = ({ children }) => {
|
||||||
|
return <div className="space-y-4">{children}</div>;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export interface BasicTabProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
localText?: string;
|
||||||
|
onTextChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BasicTab: React.FC<BasicTabProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
localText,
|
||||||
|
onTextChange,
|
||||||
|
}) => {
|
||||||
|
const text = localText !== undefined ? localText : (config.text !== undefined ? config.text : "버튼");
|
||||||
|
|
||||||
|
const handleChange = (newValue: string) => {
|
||||||
|
onTextChange?.(newValue);
|
||||||
|
onChange("componentConfig.text", newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="button-text">버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
id="button-text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder="버튼 텍스트를 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,872 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
export interface DataTabProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
component: ComponentData;
|
||||||
|
allComponents: ComponentData[];
|
||||||
|
currentTableName?: string;
|
||||||
|
availableTables: Array<{ name: string; label: string }>;
|
||||||
|
mappingTargetColumns: Array<{ name: string; label: string }>;
|
||||||
|
mappingSourceColumnsMap: Record<string, Array<{ name: string; label: string }>>;
|
||||||
|
currentTableColumns: Array<{ name: string; label: string }>;
|
||||||
|
mappingSourcePopoverOpen: Record<string, boolean>;
|
||||||
|
setMappingSourcePopoverOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||||
|
mappingTargetPopoverOpen: Record<string, boolean>;
|
||||||
|
setMappingTargetPopoverOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||||
|
activeMappingGroupIndex: number;
|
||||||
|
setActiveMappingGroupIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
loadMappingColumns: (tableName: string) => Promise<Array<{ name: string; label: string }>>;
|
||||||
|
setMappingSourceColumnsMap: React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, Array<{ name: string; label: string }>>>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTab: React.FC<DataTabProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
component,
|
||||||
|
allComponents,
|
||||||
|
currentTableName,
|
||||||
|
availableTables,
|
||||||
|
mappingTargetColumns,
|
||||||
|
mappingSourceColumnsMap,
|
||||||
|
currentTableColumns,
|
||||||
|
mappingSourcePopoverOpen,
|
||||||
|
setMappingSourcePopoverOpen,
|
||||||
|
mappingTargetPopoverOpen,
|
||||||
|
setMappingTargetPopoverOpen,
|
||||||
|
activeMappingGroupIndex,
|
||||||
|
setActiveMappingGroupIndex,
|
||||||
|
loadMappingColumns,
|
||||||
|
setMappingSourceColumnsMap,
|
||||||
|
}) => {
|
||||||
|
const actionType = config.action?.type;
|
||||||
|
const onUpdateProperty = (path: string, value: any) => onChange(path, value);
|
||||||
|
|
||||||
|
if (actionType === "quickInsert") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<QuickInsertConfigSection
|
||||||
|
component={component}
|
||||||
|
onUpdateProperty={onUpdateProperty}
|
||||||
|
allComponents={allComponents}
|
||||||
|
currentTableName={currentTableName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType !== "transferData") {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
데이터 전달 또는 즉시 저장 액션을 선택하면 설정할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-muted/50 space-y-4 rounded-lg border p-4">
|
||||||
|
<h4 className="text-foreground text-sm font-medium">데이터 전달 설정</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
소스 컴포넌트 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.dataTransfer?.sourceComponentId || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__auto__">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">자동 탐색 (현재 활성 테이블)</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">(auto)</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
{allComponents
|
||||||
|
.filter((comp: any) => {
|
||||||
|
const type = comp.componentType || comp.type || "";
|
||||||
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||||
|
type.includes(t),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((comp: any) => {
|
||||||
|
const compType = comp.componentType || comp.type || "unknown";
|
||||||
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||||
|
const layerName = comp._layerName;
|
||||||
|
return (
|
||||||
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">{compLabel}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||||
|
{layerName && (
|
||||||
|
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{layerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{allComponents.filter((comp: any) => {
|
||||||
|
const type = comp.componentType || comp.type || "";
|
||||||
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||||
|
type.includes(t),
|
||||||
|
);
|
||||||
|
}).length === 0 && (
|
||||||
|
<SelectItem value="__none__" disabled>
|
||||||
|
데이터 제공 가능한 컴포넌트가 없습니다
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target-type">
|
||||||
|
타겟 타입 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.dataTransfer?.targetType || "component"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="component">같은 화면의 컴포넌트</SelectItem>
|
||||||
|
<SelectItem value="splitPanel">분할 패널 반대편 화면</SelectItem>
|
||||||
|
<SelectItem value="screen" disabled>
|
||||||
|
다른 화면 (구현 예정)
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.action?.dataTransfer?.targetType === "component" && (
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
타겟 컴포넌트 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value);
|
||||||
|
const selectedComp = allComponents.find((c: any) => c.id === value);
|
||||||
|
if (selectedComp && (selectedComp as any)._layerId) {
|
||||||
|
onUpdateProperty(
|
||||||
|
"componentConfig.action.dataTransfer.targetLayerId",
|
||||||
|
(selectedComp as any)._layerId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{allComponents
|
||||||
|
.filter((comp: any) => {
|
||||||
|
const type = comp.componentType || comp.type || "";
|
||||||
|
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||||
|
(t) => type.includes(t),
|
||||||
|
);
|
||||||
|
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||||
|
})
|
||||||
|
.map((comp: any) => {
|
||||||
|
const compType = comp.componentType || comp.type || "unknown";
|
||||||
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||||
|
const layerName = comp._layerName;
|
||||||
|
return (
|
||||||
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">{compLabel}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||||
|
{layerName && (
|
||||||
|
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{layerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{allComponents.filter((comp: any) => {
|
||||||
|
const type = comp.componentType || comp.type || "";
|
||||||
|
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||||
|
(t) => type.includes(t),
|
||||||
|
);
|
||||||
|
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||||
|
}).length === 0 && (
|
||||||
|
<SelectItem value="__none__" disabled>
|
||||||
|
데이터 수신 가능한 컴포넌트가 없습니다
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||||||
|
<div>
|
||||||
|
<Label>타겟 컴포넌트 ID (선택사항)</Label>
|
||||||
|
<Input
|
||||||
|
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="transfer-mode">데이터 전달 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.dataTransfer?.mode || "append"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.mode", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="append">추가 (Append)</SelectItem>
|
||||||
|
<SelectItem value="replace">교체 (Replace)</SelectItem>
|
||||||
|
<SelectItem value="merge">병합 (Merge)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">기존 데이터를 어떻게 처리할지 선택</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="clear-after-transfer">전달 후 소스 선택 초기화</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">데이터 전달 후 소스의 선택을 해제합니다</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="clear-after-transfer"
|
||||||
|
checked={config.action?.dataTransfer?.clearAfterTransfer === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="confirm-before-transfer">전달 전 확인 메시지</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">데이터 전달 전 확인 다이얼로그를 표시합니다</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="confirm-before-transfer"
|
||||||
|
checked={config.action?.dataTransfer?.confirmBeforeTransfer === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.action?.dataTransfer?.confirmBeforeTransfer && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="confirm-message">확인 메시지</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-message"
|
||||||
|
placeholder="선택한 항목을 전달하시겠습니까?"
|
||||||
|
value={config.action?.dataTransfer?.confirmMessage || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>검증 설정</Label>
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="min-selection" className="text-xs">
|
||||||
|
최소 선택 개수
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="min-selection"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
value={config.action?.dataTransfer?.validation?.minSelection || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateProperty(
|
||||||
|
"componentConfig.action.dataTransfer.validation.minSelection",
|
||||||
|
parseInt(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="max-selection" className="text-xs">
|
||||||
|
최대 선택 개수
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max-selection"
|
||||||
|
type="number"
|
||||||
|
placeholder="제한없음"
|
||||||
|
value={config.action?.dataTransfer?.validation?.maxSelection || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateProperty(
|
||||||
|
"componentConfig.action.dataTransfer.validation.maxSelection",
|
||||||
|
parseInt(e.target.value) || undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>추가 데이터 소스 (선택사항)</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">추가 컴포넌트</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.dataTransfer?.additionalSources?.[0]?.componentId || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||||
|
const newSources = [...currentSources];
|
||||||
|
if (newSources.length === 0) {
|
||||||
|
newSources.push({ componentId: value, fieldName: "" });
|
||||||
|
} else {
|
||||||
|
newSources[0] = { ...newSources[0], componentId: value };
|
||||||
|
}
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="추가 데이터 컴포넌트 선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__clear__">
|
||||||
|
<span className="text-muted-foreground">선택 안 함</span>
|
||||||
|
</SelectItem>
|
||||||
|
{allComponents
|
||||||
|
.filter((comp: any) => {
|
||||||
|
const type = comp.componentType || comp.type || "";
|
||||||
|
return ["conditional-container", "select-basic", "select", "combobox"].some((t) =>
|
||||||
|
type.includes(t),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((comp: any) => {
|
||||||
|
const compType = comp.componentType || comp.type || "unknown";
|
||||||
|
const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id;
|
||||||
|
return (
|
||||||
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">{compLabel}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="additional-field-name" className="text-xs">
|
||||||
|
타겟 필드명 (선택사항)
|
||||||
|
</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||||
|
{(() => {
|
||||||
|
const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
|
||||||
|
if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
|
||||||
|
const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
|
||||||
|
const found = cols.find((c) => c.name === fieldName);
|
||||||
|
return found ? `${found.label || found.name}` : fieldName;
|
||||||
|
})()}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[240px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="__none__"
|
||||||
|
onSelect={() => {
|
||||||
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||||
|
const newSources = [...currentSources];
|
||||||
|
if (newSources.length === 0) {
|
||||||
|
newSources.push({ componentId: "", fieldName: "" });
|
||||||
|
} else {
|
||||||
|
newSources[0] = { ...newSources[0], fieldName: "" };
|
||||||
|
}
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
!config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">선택 안 함 (전체 데이터 병합)</span>
|
||||||
|
</CommandItem>
|
||||||
|
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label || ""} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||||
|
const newSources = [...currentSources];
|
||||||
|
if (newSources.length === 0) {
|
||||||
|
newSources.push({ componentId: "", fieldName: col.name });
|
||||||
|
} else {
|
||||||
|
newSources[0] = { ...newSources[0], fieldName: col.name };
|
||||||
|
}
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{col.label || col.name}</span>
|
||||||
|
{col.label && col.label !== col.name && (
|
||||||
|
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">추가 데이터가 저장될 타겟 테이블 컬럼</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>필드 매핑 설정</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">타겟 테이블</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||||
|
{config.action?.dataTransfer?.targetTable
|
||||||
|
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
||||||
|
config.action?.dataTransfer?.targetTable
|
||||||
|
: "타겟 테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.name}
|
||||||
|
value={`${table.label} ${table.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{table.label}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">소스 테이블별 매핑</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px]"
|
||||||
|
onClick={() => {
|
||||||
|
const currentMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [
|
||||||
|
...currentMappings,
|
||||||
|
{ sourceTable: "", mappingRules: [] },
|
||||||
|
]);
|
||||||
|
setActiveMappingGroupIndex(currentMappings.length);
|
||||||
|
}}
|
||||||
|
disabled={!config.action?.dataTransfer?.targetTable}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
소스 테이블 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!config.action?.dataTransfer?.targetTable ? (
|
||||||
|
<div className="rounded-md border border-dashed p-3 text-center">
|
||||||
|
<p className="text-muted-foreground text-xs">먼저 타겟 테이블을 선택하세요.</p>
|
||||||
|
</div>
|
||||||
|
) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (
|
||||||
|
<div className="rounded-md border border-dashed p-3 text-center">
|
||||||
|
<p className="text-muted-foreground text-xs">매핑 그룹이 없습니다. 소스 테이블을 추가하세요.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
|
||||||
|
<div key={gIdx} className="flex items-center gap-0.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px]"
|
||||||
|
onClick={() => setActiveMappingGroupIndex(gIdx)}
|
||||||
|
>
|
||||||
|
{group.sourceTable
|
||||||
|
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
|
||||||
|
: `그룹 ${gIdx + 1}`}
|
||||||
|
{group.mappingRules?.length > 0 && (
|
||||||
|
<span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]">
|
||||||
|
{group.mappingRules.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:bg-destructive/10 h-5 w-5"
|
||||||
|
onClick={() => {
|
||||||
|
const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
|
||||||
|
mappings.splice(gIdx, 1);
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||||
|
if (activeMappingGroupIndex >= mappings.length) {
|
||||||
|
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const multiMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||||
|
const activeGroup = multiMappings[activeMappingGroupIndex];
|
||||||
|
if (!activeGroup) return null;
|
||||||
|
|
||||||
|
const activeSourceTable = activeGroup.sourceTable || "";
|
||||||
|
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
|
||||||
|
const activeRules: any[] = activeGroup.mappingRules || [];
|
||||||
|
|
||||||
|
const updateGroupField = (field: string, value: any) => {
|
||||||
|
const mappings = [...multiMappings];
|
||||||
|
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">소스 테이블</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||||
|
{activeSourceTable
|
||||||
|
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
|
||||||
|
: "소스 테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.name}
|
||||||
|
value={`${table.label} ${table.name}`}
|
||||||
|
onSelect={async () => {
|
||||||
|
updateGroupField("sourceTable", table.name);
|
||||||
|
if (!mappingSourceColumnsMap[table.name]) {
|
||||||
|
const cols = await loadMappingColumns(table.name);
|
||||||
|
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{table.label}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px]">매핑 규칙</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 text-[10px]"
|
||||||
|
onClick={() => {
|
||||||
|
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
|
||||||
|
}}
|
||||||
|
disabled={!activeSourceTable}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!activeSourceTable ? (
|
||||||
|
<p className="text-muted-foreground text-[10px]">소스 테이블을 먼저 선택하세요.</p>
|
||||||
|
) : activeRules.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-[10px]">매핑 없음 (동일 필드명 자동 매핑)</p>
|
||||||
|
) : (
|
||||||
|
activeRules.map((rule: any, rIdx: number) => {
|
||||||
|
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`;
|
||||||
|
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`;
|
||||||
|
return (
|
||||||
|
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Popover
|
||||||
|
open={mappingSourcePopoverOpen[popoverKeyS] || false}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{rule.sourceField
|
||||||
|
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
|
||||||
|
rule.sourceField
|
||||||
|
: "소스 필드"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{activeSourceColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const newRules = [...activeRules];
|
||||||
|
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
|
||||||
|
updateGroupField("mappingRules", newRules);
|
||||||
|
setMappingSourcePopoverOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[popoverKeyS]: false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
rule.sourceField === col.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{col.label}</span>
|
||||||
|
{col.label !== col.name && (
|
||||||
|
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground text-xs">→</span>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<Popover
|
||||||
|
open={mappingTargetPopoverOpen[popoverKeyT] || false}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{rule.targetField
|
||||||
|
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
|
||||||
|
rule.targetField
|
||||||
|
: "타겟 필드"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{mappingTargetColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const newRules = [...activeRules];
|
||||||
|
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
|
||||||
|
updateGroupField("mappingRules", newRules);
|
||||||
|
setMappingTargetPopoverOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[popoverKeyT]: false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
rule.targetField === col.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{col.label}</span>
|
||||||
|
{col.label !== col.name && (
|
||||||
|
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
const newRules = [...activeRules];
|
||||||
|
newRules.splice(rIdx, 1);
|
||||||
|
updateGroupField("mappingRules", newRules);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||||
|
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||||
|
<strong>사용 방법:</strong>
|
||||||
|
<br />
|
||||||
|
1. 소스 컴포넌트에서 데이터를 선택합니다
|
||||||
|
<br />
|
||||||
|
2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
|
||||||
|
<br />
|
||||||
|
3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -420,10 +420,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entityJoinTables.map((joinTable, idx) => {
|
{entityJoinTables.map((joinTable, idx) => {
|
||||||
// 같은 테이블이 여러 FK로 조인될 수 있으므로 sourceColumn으로 고유 키 생성
|
const uniqueKey = `entity-join-${joinTable.tableName}-${joinTable.joinConfig?.sourceColumn || ''}-${idx}`;
|
||||||
const uniqueKey = joinTable.joinConfig?.sourceColumn
|
|
||||||
? `entity-join-${joinTable.tableName}-${joinTable.joinConfig.sourceColumn}`
|
|
||||||
: `entity-join-${joinTable.tableName}-${idx}`;
|
|
||||||
const isExpanded = expandedJoinTables.has(joinTable.tableName);
|
const isExpanded = expandedJoinTables.has(joinTable.tableName);
|
||||||
// 검색어로 필터링
|
// 검색어로 필터링
|
||||||
const filteredColumns = searchTerm
|
const filteredColumns = searchTerm
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,5 @@ export const MENU_ICONS = {
|
||||||
STATISTICS: ["통계", "분석", "리포트", "차트"],
|
STATISTICS: ["통계", "분석", "리포트", "차트"],
|
||||||
SETTINGS: ["설정", "관리", "시스템"],
|
SETTINGS: ["설정", "관리", "시스템"],
|
||||||
DATAFLOW: ["데이터", "흐름", "관계", "연결"],
|
DATAFLOW: ["데이터", "흐름", "관계", "연결"],
|
||||||
|
AI: ["AI", "어시스턴트", "챗봇", "LLM"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -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 어시스턴트 화면이 열립니다.
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -161,13 +161,14 @@ export const useAuth = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const token = TokenManager.getToken();
|
const token = TokenManager.getToken();
|
||||||
if (!token || TokenManager.isTokenExpired(token)) {
|
if (!token) {
|
||||||
AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`);
|
AuthLogger.log("AUTH_CHECK_FAIL", "refreshUserData: 토큰 없음");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 만료된 토큰이라도 apiClient 요청 인터셉터가 자동 갱신하므로 여기서 차단하지 않음
|
||||||
|
|
||||||
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
|
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
|
||||||
|
|
||||||
|
|
@ -177,6 +178,10 @@ export const useAuth = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// /auth/me 성공 = 인증 확인 완료. /auth/status는 보조 정보(isAdmin)만 참조
|
||||||
|
// 두 API를 Promise.all로 호출 시, 토큰 만료 타이밍에 따라
|
||||||
|
// /auth/me는 401→갱신→성공, /auth/status는 200 isAuthenticated:false를 반환하는
|
||||||
|
// 레이스 컨디션이 발생할 수 있으므로, isLoggedIn 판단은 /auth/me 성공 여부로 결정
|
||||||
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
||||||
|
|
||||||
if (userInfo) {
|
if (userInfo) {
|
||||||
|
|
@ -184,19 +189,12 @@ export const useAuth = () => {
|
||||||
|
|
||||||
const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
|
const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
|
||||||
const finalAuthStatus = {
|
const finalAuthStatus = {
|
||||||
isLoggedIn: authStatusData.isLoggedIn,
|
isLoggedIn: true,
|
||||||
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
||||||
};
|
};
|
||||||
|
|
||||||
setAuthStatus(finalAuthStatus);
|
setAuthStatus(finalAuthStatus);
|
||||||
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
|
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
|
||||||
|
|
||||||
if (!finalAuthStatus.isLoggedIn) {
|
|
||||||
AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거");
|
|
||||||
TokenManager.removeToken();
|
|
||||||
setUser(null);
|
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
|
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
|
||||||
try {
|
try {
|
||||||
|
|
@ -412,18 +410,19 @@ export const useAuth = () => {
|
||||||
|
|
||||||
const token = TokenManager.getToken();
|
const token = TokenManager.getToken();
|
||||||
|
|
||||||
if (token && !TokenManager.isTokenExpired(token)) {
|
if (token) {
|
||||||
AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`);
|
// 유효/만료 모두 refreshUserData로 처리
|
||||||
|
// apiClient 요청 인터셉터가 만료 토큰을 자동 갱신하므로 여기서 삭제하지 않음
|
||||||
|
const isExpired = TokenManager.isTokenExpired(token);
|
||||||
|
AuthLogger.log(
|
||||||
|
"AUTH_CHECK_START",
|
||||||
|
`초기 인증 확인: 토큰 ${isExpired ? "만료됨 → 갱신 시도" : "유효"} (경로: ${window.location.pathname})`,
|
||||||
|
);
|
||||||
setAuthStatus({
|
setAuthStatus({
|
||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
});
|
});
|
||||||
refreshUserData();
|
refreshUserData();
|
||||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
|
||||||
AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`);
|
|
||||||
TokenManager.removeToken();
|
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
|
||||||
setLoading(false);
|
|
||||||
} else {
|
} else {
|
||||||
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
|
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue