commit
62b0564619
|
|
@ -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",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
|
|
@ -1045,6 +1046,7 @@
|
|||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
|
|
@ -2372,6 +2374,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
|
|
@ -3318,6 +3321,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-proxy": {
|
||||
"version": "1.17.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
|
||||
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/imap": {
|
||||
"version": "0.8.42",
|
||||
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
|
||||
|
|
@ -3475,6 +3487,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -3711,6 +3724,7 @@
|
|||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
|
|
@ -3928,6 +3942,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -4419,7 +4434,6 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
|
|
@ -4454,6 +4468,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
|
|
@ -5664,6 +5679,7 @@
|
|||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
|
|
@ -5942,6 +5958,7 @@
|
|||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
|
|
@ -6154,7 +6171,6 @@
|
|||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
|
|
@ -6887,6 +6903,20 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^4.0.0",
|
||||
"follow-redirects": "^1.0.0",
|
||||
"requires-port": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
|
|
@ -6900,6 +6930,29 @@
|
|||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
|
||||
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.15",
|
||||
"debug": "^4.3.6",
|
||||
"http-proxy": "^1.18.1",
|
||||
"is-glob": "^4.0.3",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
|
|
@ -7208,7 +7261,6 @@
|
|||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -7238,7 +7290,6 @@
|
|||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
|
|
@ -7269,7 +7320,6 @@
|
|||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
|
|
@ -7294,6 +7344,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
|
|
@ -7435,6 +7494,7 @@
|
|||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
|
|
@ -8404,7 +8464,6 @@
|
|||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
|
|
@ -8566,7 +8625,6 @@
|
|||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
|
|
@ -9293,6 +9351,7 @@
|
|||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
|
|
@ -9388,7 +9447,6 @@
|
|||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
|
|
@ -9946,6 +10004,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
|
|
@ -10143,7 +10207,6 @@
|
|||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
|
|
@ -10824,7 +10887,6 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
|
|
@ -10952,6 +11014,7 @@
|
|||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
|
|
@ -11057,6 +11120,7 @@
|
|||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
|
|
|
|||
|
|
@ -16,14 +16,17 @@ import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
|||
// ============================================
|
||||
|
||||
// 처리되지 않은 Promise 거부 핸들러
|
||||
process.on("unhandledRejection", (reason: Error | any, promise: Promise<any>) => {
|
||||
logger.error("⚠️ Unhandled Promise Rejection:", {
|
||||
reason: reason?.message || reason,
|
||||
stack: reason?.stack,
|
||||
});
|
||||
// 프로세스를 종료하지 않고 로깅만 수행
|
||||
// 심각한 에러의 경우 graceful shutdown 고려
|
||||
});
|
||||
process.on(
|
||||
"unhandledRejection",
|
||||
(reason: Error | any, promise: Promise<any>) => {
|
||||
logger.error("⚠️ Unhandled Promise Rejection:", {
|
||||
reason: reason?.message || reason,
|
||||
stack: reason?.stack,
|
||||
});
|
||||
// 프로세스를 종료하지 않고 로깅만 수행
|
||||
// 심각한 에러의 경우 graceful shutdown 고려
|
||||
},
|
||||
);
|
||||
|
||||
// 처리되지 않은 예외 핸들러
|
||||
process.on("uncaughtException", (error: Error) => {
|
||||
|
|
@ -38,13 +41,16 @@ process.on("uncaughtException", (error: Error) => {
|
|||
// SIGTERM 시그널 처리 (Docker/Kubernetes 환경)
|
||||
process.on("SIGTERM", () => {
|
||||
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
|
||||
// 여기서 연결 풀 정리 등 cleanup 로직 추가 가능
|
||||
const { stopAiAssistant } = require("./utils/startAiAssistant");
|
||||
stopAiAssistant();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// SIGINT 시그널 처리 (Ctrl+C)
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
|
||||
const { stopAiAssistant } = require("./utils/startAiAssistant");
|
||||
stopAiAssistant();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
|
|
@ -112,7 +118,9 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
|||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||
import entitySearchRoutes, {
|
||||
entityOptionsRouter,
|
||||
} from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||
|
|
@ -128,7 +136,9 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다
|
|||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
|
||||
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -152,7 +162,7 @@ app.use(
|
|||
], // 프론트엔드 도메인 허용
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
|
|
@ -175,13 +185,13 @@ app.use(
|
|||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization"
|
||||
"Content-Type, Authorization",
|
||||
);
|
||||
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
next();
|
||||
},
|
||||
express.static(path.join(process.cwd(), "uploads"))
|
||||
express.static(path.join(process.cwd(), "uploads")),
|
||||
);
|
||||
|
||||
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
|
||||
|
|
@ -201,7 +211,7 @@ app.use(
|
|||
],
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 200,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Rate Limiting (개발 환경에서는 완화)
|
||||
|
|
@ -317,7 +327,9 @@ app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카
|
|||
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
||||
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
||||
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||
app.use("/api/mold", moldRoutes); // 금형 관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
app.use("/api/approval", approvalRoutes); // 결재 시스템
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
|
|
@ -406,6 +418,14 @@ app.listen(PORT, HOST, async () => {
|
|||
} catch (error) {
|
||||
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
|
||||
}
|
||||
|
||||
// AI 어시스턴트 서비스 함께 기동 (한 번에 킬 가능)
|
||||
try {
|
||||
const { startAiAssistant } = await import("./utils/startAiAssistant");
|
||||
startAiAssistant();
|
||||
} catch (error) {
|
||||
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
|
|
|||
|
|
@ -3563,29 +3563,36 @@ export async function getTableSchema(
|
|||
|
||||
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
||||
|
||||
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
|
||||
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보 + 회사별 제약조건 함께 가져오기
|
||||
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
|
||||
const schemaQuery = `
|
||||
SELECT
|
||||
ic.column_name,
|
||||
ic.data_type,
|
||||
ic.is_nullable,
|
||||
ic.is_nullable AS db_is_nullable,
|
||||
ic.column_default,
|
||||
ic.character_maximum_length,
|
||||
ic.numeric_precision,
|
||||
ic.numeric_scale,
|
||||
ttc.column_label,
|
||||
ttc.display_order
|
||||
COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label,
|
||||
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order,
|
||||
COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable,
|
||||
COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique
|
||||
FROM information_schema.columns ic
|
||||
LEFT JOIN table_type_columns ttc
|
||||
ON ttc.table_name = ic.table_name
|
||||
AND ttc.column_name = ic.column_name
|
||||
AND ttc.company_code = '*'
|
||||
LEFT JOIN table_type_columns ttc_common
|
||||
ON ttc_common.table_name = ic.table_name
|
||||
AND ttc_common.column_name = ic.column_name
|
||||
AND ttc_common.company_code = '*'
|
||||
LEFT JOIN table_type_columns ttc_company
|
||||
ON ttc_company.table_name = ic.table_name
|
||||
AND ttc_company.column_name = ic.column_name
|
||||
AND ttc_company.company_code = $2
|
||||
WHERE ic.table_schema = 'public'
|
||||
AND ic.table_name = $1
|
||||
ORDER BY COALESCE(ttc.display_order, ic.ordinal_position), ic.ordinal_position
|
||||
ORDER BY COALESCE(ttc_company.display_order, ttc_common.display_order, ic.ordinal_position), ic.ordinal_position
|
||||
`;
|
||||
|
||||
const columns = await query<any>(schemaQuery, [tableName]);
|
||||
const columns = await query<any>(schemaQuery, [tableName, companyCode]);
|
||||
|
||||
if (columns.length === 0) {
|
||||
res.status(404).json({
|
||||
|
|
@ -3595,17 +3602,28 @@ export async function getTableSchema(
|
|||
return;
|
||||
}
|
||||
|
||||
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
|
||||
const columnList = columns.map((col: any) => ({
|
||||
name: col.column_name,
|
||||
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
|
||||
type: col.data_type,
|
||||
nullable: col.is_nullable === "YES",
|
||||
default: col.column_default,
|
||||
maxLength: col.character_maximum_length,
|
||||
precision: col.numeric_precision,
|
||||
scale: col.numeric_scale,
|
||||
}));
|
||||
// 컬럼 정보를 간단한 형태로 변환 (회사별 제약조건 반영)
|
||||
const columnList = columns.map((col: any) => {
|
||||
// DB level nullable + 회사별 table_type_columns 제약조건 통합
|
||||
// table_type_columns에서 is_nullable = 'N'이면 필수 (DB가 nullable이어도)
|
||||
const dbNullable = col.db_is_nullable === "YES";
|
||||
const ttcNotNull = col.ttc_is_nullable === "N";
|
||||
const effectiveNullable = ttcNotNull ? false : dbNullable;
|
||||
|
||||
const ttcUnique = col.ttc_is_unique === "Y";
|
||||
|
||||
return {
|
||||
name: col.column_name,
|
||||
label: col.column_label || col.column_name,
|
||||
type: col.data_type,
|
||||
nullable: effectiveNullable,
|
||||
unique: ttcUnique,
|
||||
default: col.column_default,
|
||||
maxLength: col.character_maximum_length,
|
||||
precision: col.numeric_precision,
|
||||
scale: col.numeric_scale,
|
||||
};
|
||||
});
|
||||
|
||||
logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`);
|
||||
|
||||
|
|
|
|||
|
|
@ -818,13 +818,13 @@ export const getCategoryValueCascadingParentOptions = async (
|
|||
|
||||
const group = groupResult.rows[0];
|
||||
|
||||
// 부모 카테고리 값 조회 (table_column_category_values에서)
|
||||
// 부모 카테고리 값 조회 (category_values에서)
|
||||
let optionsQuery = `
|
||||
SELECT
|
||||
value_code as value,
|
||||
value_label as label,
|
||||
value_order as display_order
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
|
|
@ -916,13 +916,13 @@ export const getCategoryValueCascadingChildOptions = async (
|
|||
|
||||
const group = groupResult.rows[0];
|
||||
|
||||
// 자식 카테고리 값 조회 (table_column_category_values에서)
|
||||
// 자식 카테고리 값 조회 (category_values에서)
|
||||
let optionsQuery = `
|
||||
SELECT
|
||||
value_code as value,
|
||||
value_label as label,
|
||||
value_order as display_order
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Request, Response } from "express";
|
|||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { TableManagementService } from "../services/tableManagementService";
|
||||
|
||||
/**
|
||||
* 데이터 액션 실행
|
||||
|
|
@ -81,6 +82,19 @@ async function executeMainDatabaseAction(
|
|||
company_code: companyCode,
|
||||
};
|
||||
|
||||
// UNIQUE 제약조건 검증 (INSERT/UPDATE/UPSERT 전)
|
||||
if (["insert", "update", "upsert"].includes(actionType.toLowerCase())) {
|
||||
const tms = new TableManagementService();
|
||||
const uniqueViolations = await tms.validateUniqueConstraints(
|
||||
tableName,
|
||||
dataWithCompany,
|
||||
companyCode
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
throw new Error(`중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
switch (actionType.toLowerCase()) {
|
||||
case "insert":
|
||||
return await executeInsert(tableName, dataWithCompany);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Response } from "express";
|
||||
import { dynamicFormService } from "../services/dynamicFormService";
|
||||
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
|
||||
import { TableManagementService } from "../services/tableManagementService";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { formatPgError } from "../utils/pgErrorUtil";
|
||||
|
||||
// 폼 데이터 저장 (기존 버전 - 레거시 지원)
|
||||
export const saveFormData = async (
|
||||
|
|
@ -47,6 +49,21 @@ export const saveFormData = async (
|
|||
formDataWithMeta.company_code = companyCode;
|
||||
}
|
||||
|
||||
// UNIQUE 제약조건 검증 (INSERT 전)
|
||||
const tms = new TableManagementService();
|
||||
const uniqueViolations = await tms.validateUniqueConstraints(
|
||||
tableName,
|
||||
formDataWithMeta,
|
||||
companyCode || "*"
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 클라이언트 IP 주소 추출
|
||||
const ipAddress =
|
||||
req.ip ||
|
||||
|
|
@ -68,9 +85,12 @@ export const saveFormData = async (
|
|||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ 폼 데이터 저장 실패:", error);
|
||||
res.status(500).json({
|
||||
const { companyCode } = req.user as any;
|
||||
const friendlyMsg = await formatPgError(error, companyCode);
|
||||
const statusCode = error.code?.startsWith("23") ? 400 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: error.message || "데이터 저장에 실패했습니다.",
|
||||
message: friendlyMsg,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -108,6 +128,21 @@ export const saveFormDataEnhanced = async (
|
|||
formDataWithMeta.company_code = companyCode;
|
||||
}
|
||||
|
||||
// UNIQUE 제약조건 검증 (INSERT 전)
|
||||
const tmsEnhanced = new TableManagementService();
|
||||
const uniqueViolations = await tmsEnhanced.validateUniqueConstraints(
|
||||
tableName,
|
||||
formDataWithMeta,
|
||||
companyCode || "*"
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 개선된 서비스 사용
|
||||
const result = await enhancedDynamicFormService.saveFormData(
|
||||
screenId,
|
||||
|
|
@ -118,9 +153,12 @@ export const saveFormDataEnhanced = async (
|
|||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
|
||||
res.status(500).json({
|
||||
const { companyCode } = req.user as any;
|
||||
const friendlyMsg = await formatPgError(error, companyCode);
|
||||
const statusCode = error.code?.startsWith("23") ? 400 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: error.message || "데이터 저장에 실패했습니다.",
|
||||
message: friendlyMsg,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -146,12 +184,28 @@ export const updateFormData = async (
|
|||
const formDataWithMeta = {
|
||||
...data,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
writer: data.writer || userId,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
// UNIQUE 제약조건 검증 (UPDATE 시 자기 자신 제외)
|
||||
const tmsUpdate = new TableManagementService();
|
||||
const uniqueViolations = await tmsUpdate.validateUniqueConstraints(
|
||||
tableName,
|
||||
formDataWithMeta,
|
||||
companyCode || "*",
|
||||
id
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dynamicFormService.updateFormData(
|
||||
id, // parseInt 제거 - 문자열 ID 지원
|
||||
id,
|
||||
tableName,
|
||||
formDataWithMeta
|
||||
);
|
||||
|
|
@ -163,9 +217,12 @@ export const updateFormData = async (
|
|||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ 폼 데이터 업데이트 실패:", error);
|
||||
res.status(500).json({
|
||||
const { companyCode } = req.user as any;
|
||||
const friendlyMsg = await formatPgError(error, companyCode);
|
||||
const statusCode = error.code?.startsWith("23") ? 400 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: error.message || "데이터 업데이트에 실패했습니다.",
|
||||
message: friendlyMsg,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -199,11 +256,27 @@ export const updateFormDataPartial = async (
|
|||
const newDataWithMeta = {
|
||||
...newData,
|
||||
updated_by: userId,
|
||||
writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
writer: newData.writer || userId,
|
||||
};
|
||||
|
||||
// UNIQUE 제약조건 검증 (부분 UPDATE 시 자기 자신 제외)
|
||||
const tmsPartial = new TableManagementService();
|
||||
const uniqueViolations = await tmsPartial.validateUniqueConstraints(
|
||||
tableName,
|
||||
newDataWithMeta,
|
||||
companyCode || "*",
|
||||
id
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dynamicFormService.updateFormDataPartial(
|
||||
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
|
||||
id,
|
||||
tableName,
|
||||
originalData,
|
||||
newDataWithMeta
|
||||
|
|
@ -216,9 +289,12 @@ export const updateFormDataPartial = async (
|
|||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ 부분 업데이트 실패:", error);
|
||||
res.status(500).json({
|
||||
const { companyCode } = req.user as any;
|
||||
const friendlyMsg = await formatPgError(error, companyCode);
|
||||
const statusCode = error.code?.startsWith("23") ? 400 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: error.message || "부분 업데이트에 실패했습니다.",
|
||||
message: friendlyMsg,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -417,10 +417,10 @@ export class EntityJoinController {
|
|||
// 1. 현재 테이블의 Entity 조인 설정 조회
|
||||
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
|
||||
|
||||
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
|
||||
// 🆕 화면 디자이너용: category_values는 카테고리 드롭다운용이므로 제외
|
||||
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
|
||||
const joinConfigs = allJoinConfigs.filter(
|
||||
(config) => config.referenceTable !== "table_column_category_values"
|
||||
(config) => config.referenceTable !== "category_values"
|
||||
);
|
||||
|
||||
if (joinConfigs.length === 0) {
|
||||
|
|
|
|||
|
|
@ -181,20 +181,92 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// DISTINCT 쿼리 실행
|
||||
const query = `
|
||||
// 1단계: DISTINCT 값 조회
|
||||
const distinctQuery = `
|
||||
SELECT DISTINCT "${columnName}" as value, "${effectiveLabelColumn}" as label
|
||||
FROM "${tableName}"
|
||||
${whereClause}
|
||||
ORDER BY "${effectiveLabelColumn}" ASC
|
||||
LIMIT 500
|
||||
`;
|
||||
const result = await pool.query(distinctQuery, params);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
// 2단계: 카테고리/코드 라벨 변환 (값이 있을 때만)
|
||||
if (result.rows.length > 0) {
|
||||
const rawValues = result.rows.map((r: any) => r.value);
|
||||
const labelMap: Record<string, string> = {};
|
||||
|
||||
// category_values에서 라벨 조회
|
||||
try {
|
||||
const cvCompanyCondition = companyCode !== "*"
|
||||
? `AND (company_code = $4 OR company_code = '*')`
|
||||
: "";
|
||||
const cvParams = companyCode !== "*"
|
||||
? [tableName, columnName, rawValues, companyCode]
|
||||
: [tableName, columnName, rawValues];
|
||||
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_code, value_label FROM category_values
|
||||
WHERE table_name = $1 AND column_name = $2
|
||||
AND value_code = ANY($3) AND is_active = true
|
||||
${cvCompanyCondition}`,
|
||||
cvParams
|
||||
);
|
||||
cvResult.rows.forEach((r: any) => {
|
||||
labelMap[r.value_code] = r.value_label;
|
||||
});
|
||||
} catch (e) {
|
||||
// category_values 조회 실패 시 무시
|
||||
}
|
||||
|
||||
// code_info에서 라벨 조회 (code_category 기반)
|
||||
try {
|
||||
const ttcResult = await pool.query(
|
||||
`SELECT code_category FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 AND code_category IS NOT NULL
|
||||
LIMIT 1`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
const codeCategory = ttcResult.rows[0]?.code_category;
|
||||
|
||||
if (codeCategory) {
|
||||
const ciCompanyCondition = companyCode !== "*"
|
||||
? `AND (company_code = $3 OR company_code = '*')`
|
||||
: "";
|
||||
const ciParams = companyCode !== "*"
|
||||
? [codeCategory, rawValues, companyCode]
|
||||
: [codeCategory, rawValues];
|
||||
|
||||
const ciResult = await pool.query(
|
||||
`SELECT code_value, code_name FROM code_info
|
||||
WHERE code_category = $1 AND code_value = ANY($2) AND is_active = 'Y'
|
||||
${ciCompanyCondition}`,
|
||||
ciParams
|
||||
);
|
||||
ciResult.rows.forEach((r: any) => {
|
||||
if (!labelMap[r.code_value]) {
|
||||
labelMap[r.code_value] = r.code_name;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// code_info 조회 실패 시 무시
|
||||
}
|
||||
|
||||
// 라벨 매핑 적용
|
||||
if (Object.keys(labelMap).length > 0) {
|
||||
result.rows.forEach((row: any) => {
|
||||
if (labelMap[row.value]) {
|
||||
row.label = labelMap[row.value];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("컬럼 DISTINCT 값 조회 성공", {
|
||||
tableName,
|
||||
columnName,
|
||||
columnInputType: columnInputType || "none",
|
||||
labelColumn: effectiveLabelColumn,
|
||||
companyCode,
|
||||
hasFilters: !!filtersParam,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,497 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ============================================
|
||||
// 금형 마스터 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getMoldList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { mold_code, mold_name, mold_type, operation_status } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 전체 조회
|
||||
} else {
|
||||
conditions.push(`company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (mold_code) {
|
||||
conditions.push(`mold_code ILIKE $${paramIndex}`);
|
||||
params.push(`%${mold_code}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
if (mold_name) {
|
||||
conditions.push(`mold_name ILIKE $${paramIndex}`);
|
||||
params.push(`%${mold_name}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
if (mold_type) {
|
||||
conditions.push(`mold_type = $${paramIndex}`);
|
||||
params.push(mold_type);
|
||||
paramIndex++;
|
||||
}
|
||||
if (operation_status) {
|
||||
conditions.push(`operation_status = $${paramIndex}`);
|
||||
params.push(operation_status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const sql = `SELECT * FROM mold_mng ${whereClause} ORDER BY created_date DESC`;
|
||||
const result = await query(sql, params);
|
||||
|
||||
logger.info("금형 목록 조회", { companyCode, count: result.length });
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("금형 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMoldDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { moldCode } = req.params;
|
||||
|
||||
let sql: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
sql = `SELECT * FROM mold_mng WHERE mold_code = $1 LIMIT 1`;
|
||||
params = [moldCode];
|
||||
} else {
|
||||
sql = `SELECT * FROM mold_mng WHERE mold_code = $1 AND company_code = $2 LIMIT 1`;
|
||||
params = [moldCode, companyCode];
|
||||
}
|
||||
|
||||
const result = await query(sql, params);
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("금형 상세 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMold(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const {
|
||||
mold_code, mold_name, mold_type, category, manufacturer,
|
||||
manufacturing_number, manufacturing_date, cavity_count,
|
||||
shot_count, mold_quantity, base_input_qty, operation_status,
|
||||
remarks, image_path, memo,
|
||||
} = req.body;
|
||||
|
||||
if (!mold_code || !mold_name) {
|
||||
res.status(400).json({ success: false, message: "금형코드와 금형명은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO mold_mng (
|
||||
company_code, mold_code, mold_name, mold_type, category,
|
||||
manufacturer, manufacturing_number, manufacturing_date,
|
||||
cavity_count, shot_count, mold_quantity, base_input_qty,
|
||||
operation_status, remarks, image_path, memo, writer
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||
RETURNING *
|
||||
`;
|
||||
const params = [
|
||||
companyCode, mold_code, mold_name, mold_type || null, category || null,
|
||||
manufacturer || null, manufacturing_number || null, manufacturing_date || null,
|
||||
cavity_count || 0, shot_count || 0, mold_quantity || 1, base_input_qty || 0,
|
||||
operation_status || "ACTIVE", remarks || null, image_path || null, memo || null, userId,
|
||||
];
|
||||
|
||||
const result = await query(sql, params);
|
||||
logger.info("금형 생성", { companyCode, moldCode: mold_code });
|
||||
res.json({ success: true, data: result[0], message: "금형이 등록되었습니다." });
|
||||
} catch (error: any) {
|
||||
if (error.code === "23505") {
|
||||
res.status(409).json({ success: false, message: "이미 존재하는 금형코드입니다." });
|
||||
return;
|
||||
}
|
||||
logger.error("금형 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMold(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { moldCode } = req.params;
|
||||
const {
|
||||
mold_name, mold_type, category, manufacturer,
|
||||
manufacturing_number, manufacturing_date, cavity_count,
|
||||
shot_count, mold_quantity, base_input_qty, operation_status,
|
||||
remarks, image_path, memo,
|
||||
} = req.body;
|
||||
|
||||
const sql = `
|
||||
UPDATE mold_mng SET
|
||||
mold_name = COALESCE($1, mold_name),
|
||||
mold_type = $2, category = $3, manufacturer = $4,
|
||||
manufacturing_number = $5, manufacturing_date = $6,
|
||||
cavity_count = COALESCE($7, cavity_count),
|
||||
shot_count = COALESCE($8, shot_count),
|
||||
mold_quantity = COALESCE($9, mold_quantity),
|
||||
base_input_qty = COALESCE($10, base_input_qty),
|
||||
operation_status = COALESCE($11, operation_status),
|
||||
remarks = $12, image_path = $13, memo = $14,
|
||||
updated_date = NOW()
|
||||
WHERE mold_code = $15 AND company_code = $16
|
||||
RETURNING *
|
||||
`;
|
||||
const params = [
|
||||
mold_name, mold_type, category, manufacturer,
|
||||
manufacturing_number, manufacturing_date,
|
||||
cavity_count, shot_count, mold_quantity, base_input_qty,
|
||||
operation_status, remarks, image_path, memo,
|
||||
moldCode, companyCode,
|
||||
];
|
||||
|
||||
const result = await query(sql, params);
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("금형 수정", { companyCode, moldCode });
|
||||
res.json({ success: true, data: result[0], message: "금형이 수정되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("금형 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMold(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { moldCode } = req.params;
|
||||
|
||||
// 관련 데이터 먼저 삭제
|
||||
await query(`DELETE FROM mold_serial WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]);
|
||||
await query(`DELETE FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]);
|
||||
await query(`DELETE FROM mold_part WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]);
|
||||
|
||||
const result = await query(
|
||||
`DELETE FROM mold_mng WHERE mold_code = $1 AND company_code = $2 RETURNING id`,
|
||||
[moldCode, companyCode]
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("금형 삭제", { companyCode, moldCode });
|
||||
res.json({ success: true, message: "금형이 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("금형 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 일련번호 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getMoldSerials(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { moldCode } = req.params;
|
||||
|
||||
const sql = `SELECT * FROM mold_serial WHERE mold_code = $1 AND company_code = $2 ORDER BY serial_number`;
|
||||
const result = await query(sql, [moldCode, companyCode]);
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("일련번호 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { moldCode } = req.params;
|
||||
const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body;
|
||||
|
||||
let finalSerialNumber = serial_number;
|
||||
|
||||
// 일련번호가 비어있으면 채번 규칙으로 자동 생성
|
||||
if (!finalSerialNumber) {
|
||||
try {
|
||||
const { numberingRuleService } = await import("../services/numberingRuleService");
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode,
|
||||
"mold_serial",
|
||||
"serial_number"
|
||||
);
|
||||
|
||||
if (rule) {
|
||||
// formData에 mold_code를 포함 (reference 파트에서 참조)
|
||||
const formData = { mold_code: moldCode, ...req.body };
|
||||
finalSerialNumber = await numberingRuleService.allocateCode(
|
||||
rule.ruleId,
|
||||
companyCode,
|
||||
formData
|
||||
);
|
||||
logger.info("일련번호 자동 채번 완료", { serialNumber: finalSerialNumber, ruleId: rule.ruleId });
|
||||
}
|
||||
} catch (numError: any) {
|
||||
logger.error("일련번호 자동 채번 실패", { error: numError.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSerialNumber) {
|
||||
res.status(400).json({ success: false, message: "일련번호를 생성할 수 없습니다. 채번 규칙을 확인해주세요." });
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO mold_serial (company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, writer)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
RETURNING *
|
||||
`;
|
||||
const params = [
|
||||
companyCode, moldCode, finalSerialNumber, status || "STORED",
|
||||
progress || 0, work_description || null, manager || null,
|
||||
completion_date || null, remarks || null, userId,
|
||||
];
|
||||
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result[0], message: "일련번호가 등록되었습니다." });
|
||||
} catch (error: any) {
|
||||
if (error.code === "23505") {
|
||||
res.status(409).json({ success: false, message: "이미 존재하는 일련번호입니다." });
|
||||
return;
|
||||
}
|
||||
logger.error("일련번호 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await query(
|
||||
`DELETE FROM mold_serial WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({ success: false, message: "일련번호를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, message: "일련번호가 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("일련번호 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 점검항목 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getMoldInspections(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { moldCode } = req.params;
|
||||
|
||||
const sql = `SELECT * FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`;
|
||||
const result = await query(sql, [moldCode, companyCode]);
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("점검항목 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMoldInspection(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { moldCode } = req.params;
|
||||
const {
|
||||
inspection_item, inspection_cycle, inspection_method,
|
||||
inspection_content, lower_limit, upper_limit, unit,
|
||||
is_active, checklist, remarks,
|
||||
} = req.body;
|
||||
|
||||
if (!inspection_item) {
|
||||
res.status(400).json({ success: false, message: "점검항목명은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO mold_inspection_item (
|
||||
company_code, mold_code, inspection_item, inspection_cycle,
|
||||
inspection_method, inspection_content, lower_limit, upper_limit,
|
||||
unit, is_active, checklist, remarks, writer
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING *
|
||||
`;
|
||||
const params = [
|
||||
companyCode, moldCode, inspection_item, inspection_cycle || null,
|
||||
inspection_method || null, inspection_content || null,
|
||||
lower_limit || null, upper_limit || null, unit || null,
|
||||
is_active || "Y", checklist || null, remarks || null, userId,
|
||||
];
|
||||
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result[0], message: "점검항목이 등록되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("점검항목 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMoldInspection(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await query(
|
||||
`DELETE FROM mold_inspection_item WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({ success: false, message: "점검항목을 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, message: "점검항목이 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("점검항목 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 부품 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getMoldParts(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { moldCode } = req.params;
|
||||
|
||||
const sql = `SELECT * FROM mold_part WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`;
|
||||
const result = await query(sql, [moldCode, companyCode]);
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("부품 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMoldPart(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { moldCode } = req.params;
|
||||
const {
|
||||
part_name, replacement_cycle, unit, specification,
|
||||
manufacturer, manufacturer_code, image_path, remarks,
|
||||
} = req.body;
|
||||
|
||||
if (!part_name) {
|
||||
res.status(400).json({ success: false, message: "부품명은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO mold_part (
|
||||
company_code, mold_code, part_name, replacement_cycle,
|
||||
unit, specification, manufacturer, manufacturer_code,
|
||||
image_path, remarks, writer
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
RETURNING *
|
||||
`;
|
||||
const params = [
|
||||
companyCode, moldCode, part_name, replacement_cycle || null,
|
||||
unit || null, specification || null, manufacturer || null,
|
||||
manufacturer_code || null, image_path || null, remarks || null, userId,
|
||||
];
|
||||
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result[0], message: "부품이 등록되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("부품 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMoldPart(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await query(
|
||||
`DELETE FROM mold_part WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
res.status(404).json({ success: false, message: "부품을 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, message: "부품이 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("부품 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 일련번호 현황 집계
|
||||
// ============================================
|
||||
|
||||
export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { moldCode } = req.params;
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use,
|
||||
COUNT(*) FILTER (WHERE status = 'REPAIR') as repair,
|
||||
COUNT(*) FILTER (WHERE status = 'STORED') as stored,
|
||||
COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed
|
||||
FROM mold_serial
|
||||
WHERE mold_code = $1 AND company_code = $2
|
||||
`;
|
||||
const result = await query(sql, [moldCode, companyCode]);
|
||||
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("일련번호 현황 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -405,6 +405,30 @@ router.post(
|
|||
}
|
||||
);
|
||||
|
||||
// 테이블+컬럼 기반 채번 규칙 조회 (메인 API)
|
||||
router.get(
|
||||
"/by-column/:tableName/:columnName",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName
|
||||
);
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== 테스트 테이블용 API ====================
|
||||
|
||||
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회
|
||||
|
|
|
|||
|
|
@ -2087,6 +2087,23 @@ export async function multiTableSave(
|
|||
return;
|
||||
}
|
||||
|
||||
// UNIQUE 제약조건 검증 (트랜잭션 전에)
|
||||
const tmsMulti = new TableManagementService();
|
||||
const uniqueViolations = await tmsMulti.validateUniqueConstraints(
|
||||
mainTable.tableName,
|
||||
mainData,
|
||||
companyCode,
|
||||
isUpdate ? mainData[mainTable.primaryKeyColumn] : undefined
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
client.release();
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 메인 테이블 저장
|
||||
|
|
@ -3019,3 +3036,72 @@ export async function toggleColumnUnique(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사별 채번 타입 컬럼 조회 (카테고리 패턴과 동일)
|
||||
*
|
||||
* @route GET /api/table-management/numbering-columns
|
||||
*/
|
||||
export async function getNumberingColumnsByCompany(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("회사별 채번 컬럼 조회 요청", { companyCode });
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "회사 코드를 확인할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { getPool } = await import("../database/db");
|
||||
const pool = getPool();
|
||||
|
||||
const targetCompanyCode = companyCode === "*" ? "*" : companyCode;
|
||||
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
ttc.table_name AS "tableName",
|
||||
COALESCE(
|
||||
tl.table_label,
|
||||
initcap(replace(ttc.table_name, '_', ' '))
|
||||
) AS "tableLabel",
|
||||
ttc.column_name AS "columnName",
|
||||
COALESCE(
|
||||
ttc.column_label,
|
||||
initcap(replace(ttc.column_name, '_', ' '))
|
||||
) AS "columnLabel",
|
||||
ttc.input_type AS "inputType"
|
||||
FROM table_type_columns ttc
|
||||
LEFT JOIN table_labels tl
|
||||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'numbering'
|
||||
AND ttc.company_code = $1
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]);
|
||||
|
||||
logger.info("채번 컬럼 조회 완료", {
|
||||
companyCode,
|
||||
rowCount: columnsResult.rows.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: columnsResult.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("채번 컬럼 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "채번 컬럼 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,13 +41,27 @@ export const errorHandler = (
|
|||
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||
if (pgError.code === "23505") {
|
||||
// unique_violation
|
||||
error = new AppError("중복된 데이터가 존재합니다.", 400);
|
||||
const constraint = pgError.constraint || "";
|
||||
const tbl = pgError.table || "";
|
||||
let col = "";
|
||||
if (constraint && tbl) {
|
||||
const prefix = `${tbl}_`;
|
||||
const suffix = "_key";
|
||||
if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) {
|
||||
col = constraint.slice(prefix.length, -suffix.length);
|
||||
}
|
||||
}
|
||||
const detail = col ? ` [${col}]` : "";
|
||||
error = new AppError(`중복된 데이터가 존재합니다.${detail}`, 400);
|
||||
} else if (pgError.code === "23503") {
|
||||
// foreign_key_violation
|
||||
error = new AppError("참조 무결성 제약 조건 위반입니다.", 400);
|
||||
} else if (pgError.code === "23502") {
|
||||
// not_null_violation
|
||||
error = new AppError("필수 입력값이 누락되었습니다.", 400);
|
||||
const colName = pgError.column || "";
|
||||
const tableName = pgError.table || "";
|
||||
const detail = colName ? ` [${tableName}.${colName}]` : "";
|
||||
error = new AppError(`필수 입력값이 누락되었습니다.${detail}`, 400);
|
||||
} else if (pgError.code.startsWith("23")) {
|
||||
// 기타 무결성 제약 조건 위반
|
||||
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
||||
|
|
@ -84,6 +98,7 @@ export const errorHandler = (
|
|||
// 응답 전송
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: message,
|
||||
error: {
|
||||
message: message,
|
||||
...(process.env.NODE_ENV === "development" && { stack: error.stack }),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import express from "express";
|
||||
import { dataService } from "../services/dataService";
|
||||
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
||||
import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { auditLogService } from "../services/auditLogService";
|
||||
import { TableManagementService } from "../services/tableManagementService";
|
||||
import { formatPgError } from "../utils/pgErrorUtil";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -260,6 +263,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
|
||||
// ================================
|
||||
|
|
@ -838,6 +952,20 @@ router.post(
|
|||
console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`);
|
||||
}
|
||||
|
||||
// UNIQUE 제약조건 검증
|
||||
const tms = new TableManagementService();
|
||||
const uniqueViolations = await tms.validateUniqueConstraints(
|
||||
tableName,
|
||||
enrichedData,
|
||||
req.user?.companyCode || "*"
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 레코드 생성
|
||||
const result = await dataService.createRecord(tableName, enrichedData);
|
||||
|
||||
|
|
@ -907,6 +1035,21 @@ router.put(
|
|||
|
||||
console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data);
|
||||
|
||||
// UNIQUE 제약조건 검증 (자기 자신 제외)
|
||||
const tmsUpdate = new TableManagementService();
|
||||
const uniqueViolations = await tmsUpdate.validateUniqueConstraints(
|
||||
tableName,
|
||||
data,
|
||||
req.user?.companyCode || "*",
|
||||
String(id)
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 레코드 수정
|
||||
const result = await dataService.updateRecord(tableName, id, data);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import express from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
getMoldList,
|
||||
getMoldDetail,
|
||||
createMold,
|
||||
updateMold,
|
||||
deleteMold,
|
||||
getMoldSerials,
|
||||
createMoldSerial,
|
||||
deleteMoldSerial,
|
||||
getMoldInspections,
|
||||
createMoldInspection,
|
||||
deleteMoldInspection,
|
||||
getMoldParts,
|
||||
createMoldPart,
|
||||
deleteMoldPart,
|
||||
getMoldSerialSummary,
|
||||
} from "../controllers/moldController";
|
||||
|
||||
const router = express.Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 금형 마스터
|
||||
router.get("/", getMoldList);
|
||||
router.get("/:moldCode", getMoldDetail);
|
||||
router.post("/", createMold);
|
||||
router.put("/:moldCode", updateMold);
|
||||
router.delete("/:moldCode", deleteMold);
|
||||
|
||||
// 일련번호
|
||||
router.get("/:moldCode/serials", getMoldSerials);
|
||||
router.post("/:moldCode/serials", createMoldSerial);
|
||||
router.delete("/serials/:id", deleteMoldSerial);
|
||||
|
||||
// 일련번호 현황 집계
|
||||
router.get("/:moldCode/serial-summary", getMoldSerialSummary);
|
||||
|
||||
// 점검항목
|
||||
router.get("/:moldCode/inspections", getMoldInspections);
|
||||
router.post("/:moldCode/inspections", createMoldInspection);
|
||||
router.delete("/inspections/:id", deleteMoldInspection);
|
||||
|
||||
// 부품
|
||||
router.get("/:moldCode/parts", getMoldParts);
|
||||
router.post("/:moldCode/parts", createMoldPart);
|
||||
router.delete("/parts/:id", deleteMoldPart);
|
||||
|
||||
export default router;
|
||||
|
|
@ -25,6 +25,7 @@ import {
|
|||
toggleLogTable,
|
||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
|
||||
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
|
||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
||||
|
|
@ -254,6 +255,12 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
|||
*/
|
||||
router.get("/category-columns", getCategoryColumnsByCompany);
|
||||
|
||||
/**
|
||||
* 회사 기준 모든 채번 타입 컬럼 조회
|
||||
* GET /api/table-management/numbering-columns
|
||||
*/
|
||||
router.get("/numbering-columns", getNumberingColumnsByCompany);
|
||||
|
||||
/**
|
||||
* 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회
|
||||
* GET /api/table-management/menu/:menuObjid/category-columns
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export class EntityJoinService {
|
|||
|
||||
if (column.input_type === "category") {
|
||||
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
|
||||
referenceTable = referenceTable || "table_column_category_values";
|
||||
referenceTable = referenceTable || "category_values";
|
||||
referenceColumn = referenceColumn || "value_code";
|
||||
displayColumn = displayColumn || "value_label";
|
||||
|
||||
|
|
@ -308,7 +308,7 @@ export class EntityJoinService {
|
|||
const usedAliasesForColumns = new Set<string>();
|
||||
|
||||
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
|
||||
// (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
|
||||
// (category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
|
||||
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
|
||||
if (
|
||||
!acc.some(
|
||||
|
|
@ -336,7 +336,7 @@ export class EntityJoinService {
|
|||
counter++;
|
||||
}
|
||||
usedAliasesForColumns.add(alias);
|
||||
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
|
||||
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (category_values 대응)
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
aliasMap.set(aliasKey, alias);
|
||||
logger.info(
|
||||
|
|
@ -455,9 +455,10 @@ export class EntityJoinService {
|
|||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
// category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
|
||||
if (config.referenceTable === "category_values") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`;
|
||||
}
|
||||
|
||||
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
|
||||
|
|
@ -528,10 +529,10 @@ export class EntityJoinService {
|
|||
return "join";
|
||||
}
|
||||
|
||||
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// category_values는 특수 조인 조건이 필요하므로 캐시 불가
|
||||
if (config.referenceTable === "category_values") {
|
||||
logger.info(
|
||||
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
|
||||
`🎯 category_values는 캐시 전략 불가: ${config.sourceColumn}`
|
||||
);
|
||||
return "join";
|
||||
}
|
||||
|
|
@ -723,10 +724,10 @@ export class EntityJoinService {
|
|||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
// category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
|
||||
if (config.referenceTable === "category_values") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`;
|
||||
}
|
||||
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
|
||||
|
|
|
|||
|
|
@ -494,7 +494,7 @@ class MasterDetailExcelService {
|
|||
|
||||
/**
|
||||
* 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환
|
||||
* 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback
|
||||
* numbering_rules 테이블에서 table_name + column_name + company_code로 직접 조회
|
||||
*/
|
||||
private async detectNumberingRuleForColumn(
|
||||
tableName: string,
|
||||
|
|
@ -502,32 +502,58 @@ class MasterDetailExcelService {
|
|||
companyCode?: string
|
||||
): Promise<{ numberingRuleId: string } | null> {
|
||||
try {
|
||||
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
|
||||
// 1. table_type_columns에서 numbering 타입인지 확인
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($3, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const params = companyCode && companyCode !== "*"
|
||||
const ttcParams = companyCode && companyCode !== "*"
|
||||
? [tableName, columnName, companyCode]
|
||||
: [tableName, columnName];
|
||||
|
||||
const result = await query<any>(
|
||||
`SELECT input_type, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
const ttcResult = await query<any>(
|
||||
`SELECT input_type FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
params
|
||||
AND input_type = 'numbering' LIMIT 1`,
|
||||
ttcParams
|
||||
);
|
||||
|
||||
// 채번 타입인 행 찾기 (회사별 우선)
|
||||
for (const row of result) {
|
||||
if (row.input_type === "numbering") {
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
|
||||
if (settings?.numberingRuleId) {
|
||||
return { numberingRuleId: settings.numberingRuleId };
|
||||
}
|
||||
if (ttcResult.length === 0) return null;
|
||||
|
||||
// 2. numbering_rules에서 table_name + column_name으로 규칙 조회
|
||||
const ruleCompanyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($3, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const ruleParams = companyCode && companyCode !== "*"
|
||||
? [tableName, columnName, companyCode]
|
||||
: [tableName, columnName];
|
||||
|
||||
const ruleResult = await query<any>(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
WHERE table_name = $1 AND column_name = $2 ${ruleCompanyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
||||
LIMIT 1`,
|
||||
ruleParams
|
||||
);
|
||||
|
||||
if (ruleResult.length > 0) {
|
||||
return { numberingRuleId: ruleResult[0].rule_id };
|
||||
}
|
||||
|
||||
// 3. fallback: detail_settings.numberingRuleId (하위 호환)
|
||||
const fallbackResult = await query<any>(
|
||||
`SELECT detail_settings FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
AND input_type = 'numbering'
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
ttcParams
|
||||
);
|
||||
|
||||
for (const row of fallbackResult) {
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
if (settings?.numberingRuleId) {
|
||||
return { numberingRuleId: settings.numberingRuleId };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -540,7 +566,7 @@ class MasterDetailExcelService {
|
|||
|
||||
/**
|
||||
* 특정 테이블의 모든 채번 컬럼을 한 번에 조회
|
||||
* 회사별 설정 우선, 공통(*) 설정 fallback
|
||||
* numbering_rules 테이블에서 table_name + column_name으로 직접 조회
|
||||
* @returns Map<columnName, numberingRuleId>
|
||||
*/
|
||||
private async detectAllNumberingColumns(
|
||||
|
|
@ -549,6 +575,7 @@ class MasterDetailExcelService {
|
|||
): Promise<Map<string, string>> {
|
||||
const numberingCols = new Map<string, string>();
|
||||
try {
|
||||
// 1. table_type_columns에서 numbering 타입 컬럼 목록 조회
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($2, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
|
|
@ -556,22 +583,26 @@ class MasterDetailExcelService {
|
|||
? [tableName, companyCode]
|
||||
: [tableName];
|
||||
|
||||
const result = await query<any>(
|
||||
`SELECT column_name, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
const ttcResult = await query<any>(
|
||||
`SELECT DISTINCT column_name FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`,
|
||||
params
|
||||
);
|
||||
|
||||
// 컬럼별로 회사 설정 우선 적용
|
||||
for (const row of result) {
|
||||
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
if (settings?.numberingRuleId) {
|
||||
numberingCols.set(row.column_name, settings.numberingRuleId);
|
||||
// 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회
|
||||
for (const row of ttcResult) {
|
||||
const ruleResult = await query<any>(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
||||
LIMIT 1`,
|
||||
companyCode && companyCode !== "*"
|
||||
? [tableName, row.column_name, companyCode]
|
||||
: [tableName, row.column_name]
|
||||
);
|
||||
|
||||
if (ruleResult.length > 0) {
|
||||
numberingCols.set(row.column_name, ruleResult[0].rule_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3098,7 +3098,7 @@ export class MenuCopyService {
|
|||
}
|
||||
|
||||
const allValuesResult = await client.query(
|
||||
`SELECT * FROM table_column_category_values
|
||||
`SELECT * FROM category_values
|
||||
WHERE company_code = $1
|
||||
AND (${columnConditions.join(" OR ")})
|
||||
ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`,
|
||||
|
|
@ -3115,7 +3115,7 @@ export class MenuCopyService {
|
|||
// 5. 대상 회사에 이미 존재하는 값 한 번에 조회
|
||||
const existingValuesResult = await client.query(
|
||||
`SELECT value_id, table_name, column_name, value_code
|
||||
FROM table_column_category_values WHERE company_code = $1`,
|
||||
FROM category_values WHERE company_code = $1`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
const existingValueKeys = new Map(
|
||||
|
|
@ -3194,7 +3194,7 @@ export class MenuCopyService {
|
|||
});
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO table_column_category_values (
|
||||
`INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, description, color, icon,
|
||||
is_active, is_default, created_at, created_by, company_code, menu_objid
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -172,6 +172,16 @@ class NumberingRuleService {
|
|||
break;
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
prefixParts.push(String(formData[refColumn]));
|
||||
} else {
|
||||
prefixParts.push("");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -1245,6 +1255,14 @@ class NumberingRuleService {
|
|||
return "";
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
return "REF";
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
|
|
@ -1375,6 +1393,13 @@ class NumberingRuleService {
|
|||
|
||||
return catMapping2?.format || "CATEGORY";
|
||||
}
|
||||
case "reference": {
|
||||
const refCol2 = autoConfig.referenceColumnName;
|
||||
if (refCol2 && formData && formData[refCol2]) {
|
||||
return String(formData[refCol2]);
|
||||
}
|
||||
return "REF";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
@ -1524,6 +1549,15 @@ class NumberingRuleService {
|
|||
return "";
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
|
||||
return "";
|
||||
}
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
@ -1747,7 +1781,53 @@ class NumberingRuleService {
|
|||
`;
|
||||
const params = [companyCode, tableName, columnName];
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
let result = await pool.query(query, params);
|
||||
|
||||
// fallback: column_name이 비어있는 레거시 규칙 검색
|
||||
if (result.rows.length === 0) {
|
||||
const fallbackQuery = `
|
||||
SELECT
|
||||
r.rule_id AS "ruleId",
|
||||
r.rule_name AS "ruleName",
|
||||
r.description,
|
||||
r.separator,
|
||||
r.reset_period AS "resetPeriod",
|
||||
r.current_sequence AS "currentSequence",
|
||||
r.table_name AS "tableName",
|
||||
r.column_name AS "columnName",
|
||||
r.company_code AS "companyCode",
|
||||
r.category_column AS "categoryColumn",
|
||||
r.category_value_id AS "categoryValueId",
|
||||
cv.value_label AS "categoryValueLabel",
|
||||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules r
|
||||
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND (r.column_name IS NULL OR r.column_name = '')
|
||||
AND r.category_value_id IS NULL
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
result = await pool.query(fallbackQuery, [companyCode, tableName]);
|
||||
|
||||
// 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션)
|
||||
if (result.rows.length > 0) {
|
||||
const foundRule = result.rows[0];
|
||||
await pool.query(
|
||||
`UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`,
|
||||
[columnName, foundRule.ruleId, companyCode]
|
||||
);
|
||||
result.rows[0].columnName = columnName;
|
||||
logger.info("레거시 채번 규칙 자동 매핑 완료", {
|
||||
ruleId: foundRule.ruleId,
|
||||
tableName,
|
||||
columnName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
|
||||
|
|
@ -1760,7 +1840,6 @@ class NumberingRuleService {
|
|||
|
||||
const rule = result.rows[0];
|
||||
|
||||
// 파트 정보 조회 (테스트 테이블)
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
|
|
@ -1779,7 +1858,7 @@ class NumberingRuleService {
|
|||
]);
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", {
|
||||
ruleId: rule.ruleId,
|
||||
ruleName: rule.ruleName,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class TableCategoryValueService {
|
|||
tc.column_name AS "columnLabel",
|
||||
COUNT(cv.value_id) AS "valueCount"
|
||||
FROM table_type_columns tc
|
||||
LEFT JOIN table_column_category_values cv
|
||||
LEFT JOIN category_values cv
|
||||
ON tc.table_name = cv.table_name
|
||||
AND tc.column_name = cv.column_name
|
||||
AND cv.is_active = true
|
||||
|
|
@ -50,7 +50,7 @@ class TableCategoryValueService {
|
|||
tc.column_name AS "columnLabel",
|
||||
COUNT(cv.value_id) AS "valueCount"
|
||||
FROM table_type_columns tc
|
||||
LEFT JOIN table_column_category_values cv
|
||||
LEFT JOIN category_values cv
|
||||
ON tc.table_name = cv.table_name
|
||||
AND tc.column_name = cv.column_name
|
||||
AND cv.is_active = true
|
||||
|
|
@ -110,7 +110,7 @@ class TableCategoryValueService {
|
|||
) tc
|
||||
LEFT JOIN (
|
||||
SELECT table_name, column_name, COUNT(*) as cnt
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE is_active = true
|
||||
GROUP BY table_name, column_name
|
||||
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
||||
|
|
@ -133,7 +133,7 @@ class TableCategoryValueService {
|
|||
) tc
|
||||
LEFT JOIN (
|
||||
SELECT table_name, column_name, COUNT(*) as cnt
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE is_active = true AND company_code = $1
|
||||
GROUP BY table_name, column_name
|
||||
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
||||
|
|
@ -207,7 +207,7 @@ class TableCategoryValueService {
|
|||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
NULL::numeric AS "menuObjid",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
|
|
@ -289,7 +289,7 @@ class TableCategoryValueService {
|
|||
// 최고 관리자: 모든 회사에서 중복 체크
|
||||
duplicateQuery = `
|
||||
SELECT value_id
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_code = $3
|
||||
|
|
@ -300,7 +300,7 @@ class TableCategoryValueService {
|
|||
// 일반 회사: 자신의 회사에서만 중복 체크
|
||||
duplicateQuery = `
|
||||
SELECT value_id
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_code = $3
|
||||
|
|
@ -316,8 +316,41 @@ class TableCategoryValueService {
|
|||
throw new Error("이미 존재하는 코드입니다");
|
||||
}
|
||||
|
||||
// 라벨 중복 체크 (같은 테이블+컬럼+회사에서 동일한 라벨명 방지)
|
||||
let labelDupQuery: string;
|
||||
let labelDupParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
labelDupQuery = `
|
||||
SELECT value_id
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_label = $3
|
||||
AND is_active = true
|
||||
`;
|
||||
labelDupParams = [value.tableName, value.columnName, value.valueLabel];
|
||||
} else {
|
||||
labelDupQuery = `
|
||||
SELECT value_id
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_label = $3
|
||||
AND company_code = $4
|
||||
AND is_active = true
|
||||
`;
|
||||
labelDupParams = [value.tableName, value.columnName, value.valueLabel, companyCode];
|
||||
}
|
||||
|
||||
const labelDupResult = await pool.query(labelDupQuery, labelDupParams);
|
||||
|
||||
if (labelDupResult.rows.length > 0) {
|
||||
throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${value.valueLabel}"`);
|
||||
}
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO table_column_category_values (
|
||||
INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, description, color, icon,
|
||||
is_active, is_default, company_code, menu_objid, created_by
|
||||
|
|
@ -425,6 +458,32 @@ class TableCategoryValueService {
|
|||
values.push(updates.isDefault);
|
||||
}
|
||||
|
||||
// 라벨 수정 시 중복 체크 (자기 자신 제외)
|
||||
if (updates.valueLabel !== undefined) {
|
||||
const currentRow = await pool.query(
|
||||
`SELECT table_name, column_name, company_code FROM category_values WHERE value_id = $1`,
|
||||
[valueId]
|
||||
);
|
||||
|
||||
if (currentRow.rows.length > 0) {
|
||||
const { table_name, column_name, company_code } = currentRow.rows[0];
|
||||
const labelDupResult = await pool.query(
|
||||
`SELECT value_id FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_label = $3
|
||||
AND company_code = $4
|
||||
AND is_active = true
|
||||
AND value_id != $5`,
|
||||
[table_name, column_name, updates.valueLabel, company_code, valueId]
|
||||
);
|
||||
|
||||
if (labelDupResult.rows.length > 0) {
|
||||
throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${updates.valueLabel}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setClauses.push(`updated_at = NOW()`);
|
||||
setClauses.push(`updated_by = $${paramIndex++}`);
|
||||
values.push(userId);
|
||||
|
|
@ -436,7 +495,7 @@ class TableCategoryValueService {
|
|||
// 최고 관리자: 모든 카테고리 값 수정 가능
|
||||
values.push(valueId);
|
||||
updateQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET ${setClauses.join(", ")}
|
||||
WHERE value_id = $${paramIndex++}
|
||||
RETURNING
|
||||
|
|
@ -459,7 +518,7 @@ class TableCategoryValueService {
|
|||
// 일반 회사: 자신의 카테고리 값만 수정 가능
|
||||
values.push(valueId, companyCode);
|
||||
updateQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET ${setClauses.join(", ")}
|
||||
WHERE value_id = $${paramIndex++}
|
||||
AND company_code = $${paramIndex++}
|
||||
|
|
@ -516,14 +575,14 @@ class TableCategoryValueService {
|
|||
if (companyCode === "*") {
|
||||
valueQuery = `
|
||||
SELECT table_name, column_name, value_code
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE value_id = $1
|
||||
`;
|
||||
valueParams = [valueId];
|
||||
} else {
|
||||
valueQuery = `
|
||||
SELECT table_name, column_name, value_code
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE value_id = $1
|
||||
AND company_code = $2
|
||||
`;
|
||||
|
|
@ -635,10 +694,10 @@ class TableCategoryValueService {
|
|||
if (companyCode === "*") {
|
||||
query = `
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1
|
||||
SELECT value_id FROM category_values WHERE parent_value_id = $1
|
||||
UNION ALL
|
||||
SELECT cv.value_id
|
||||
FROM table_column_category_values cv
|
||||
FROM category_values cv
|
||||
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
|
||||
)
|
||||
SELECT value_id FROM category_tree
|
||||
|
|
@ -647,11 +706,11 @@ class TableCategoryValueService {
|
|||
} else {
|
||||
query = `
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT value_id FROM table_column_category_values
|
||||
SELECT value_id FROM category_values
|
||||
WHERE parent_value_id = $1 AND company_code = $2
|
||||
UNION ALL
|
||||
SELECT cv.value_id
|
||||
FROM table_column_category_values cv
|
||||
FROM category_values cv
|
||||
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
|
||||
WHERE cv.company_code = $2
|
||||
)
|
||||
|
|
@ -697,10 +756,10 @@ class TableCategoryValueService {
|
|||
let labelParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`;
|
||||
labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1`;
|
||||
labelParams = [id];
|
||||
} else {
|
||||
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
|
||||
labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1 AND company_code = $2`;
|
||||
labelParams = [id, companyCode];
|
||||
}
|
||||
|
||||
|
|
@ -730,10 +789,10 @@ class TableCategoryValueService {
|
|||
let deleteParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`;
|
||||
deleteQuery = `DELETE FROM category_values WHERE value_id = $1`;
|
||||
deleteParams = [id];
|
||||
} else {
|
||||
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
|
||||
deleteQuery = `DELETE FROM category_values WHERE value_id = $1 AND company_code = $2`;
|
||||
deleteParams = [id, companyCode];
|
||||
}
|
||||
|
||||
|
|
@ -770,7 +829,7 @@ class TableCategoryValueService {
|
|||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 일괄 삭제 가능
|
||||
deleteQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET is_active = false, updated_at = NOW(), updated_by = $2
|
||||
WHERE value_id = ANY($1::int[])
|
||||
`;
|
||||
|
|
@ -778,7 +837,7 @@ class TableCategoryValueService {
|
|||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값만 일괄 삭제 가능
|
||||
deleteQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET is_active = false, updated_at = NOW(), updated_by = $3
|
||||
WHERE value_id = ANY($1::int[])
|
||||
AND company_code = $2
|
||||
|
|
@ -819,7 +878,7 @@ class TableCategoryValueService {
|
|||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 순서 변경 가능
|
||||
updateQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET value_order = $1, updated_at = NOW()
|
||||
WHERE value_id = $2
|
||||
`;
|
||||
|
|
@ -827,7 +886,7 @@ class TableCategoryValueService {
|
|||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값만 순서 변경 가능
|
||||
updateQuery = `
|
||||
UPDATE table_column_category_values
|
||||
UPDATE category_values
|
||||
SET value_order = $1, updated_at = NOW()
|
||||
WHERE value_id = $2
|
||||
AND company_code = $3
|
||||
|
|
@ -1379,48 +1438,23 @@ class TableCategoryValueService {
|
|||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합)
|
||||
// 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n
|
||||
const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", ");
|
||||
query = `
|
||||
SELECT value_code, value_label FROM (
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND is_active = true
|
||||
UNION ALL
|
||||
SELECT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders2})
|
||||
AND is_active = true
|
||||
) combined
|
||||
SELECT DISTINCT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
`;
|
||||
params = [...valueCodes, ...valueCodes];
|
||||
params = [...valueCodes];
|
||||
} else {
|
||||
// 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
// 첫 번째: $1~$n (valueCodes), $n+1 (companyCode)
|
||||
// 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode)
|
||||
const companyIdx1 = n + 1;
|
||||
const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", ");
|
||||
const companyIdx2 = 2 * n + 2;
|
||||
|
||||
const companyIdx = n + 1;
|
||||
query = `
|
||||
SELECT value_code, value_label FROM (
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND is_active = true
|
||||
AND (company_code = $${companyIdx1} OR company_code = '*')
|
||||
UNION ALL
|
||||
SELECT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders2})
|
||||
AND is_active = true
|
||||
AND (company_code = $${companyIdx2} OR company_code = '*')
|
||||
) combined
|
||||
SELECT DISTINCT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND (company_code = $${companyIdx} OR company_code = '*')
|
||||
`;
|
||||
params = [...valueCodes, companyCode, ...valueCodes, companyCode];
|
||||
params = [...valueCodes, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
|
@ -1488,7 +1522,7 @@ class TableCategoryValueService {
|
|||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
|
|
@ -1498,7 +1532,7 @@ class TableCategoryValueService {
|
|||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
|
|
|
|||
|
|
@ -2691,6 +2691,32 @@ export class TableManagementService {
|
|||
logger.info(`created_date 자동 추가: ${data.created_date}`);
|
||||
}
|
||||
|
||||
// 채번 자동 적용: input_type = 'numbering'인 컬럼에 값이 비어있으면 자동 채번
|
||||
try {
|
||||
const companyCode = data.company_code || "*";
|
||||
const numberingColsResult = await query<any>(
|
||||
`SELECT DISTINCT column_name FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering'
|
||||
AND company_code IN ($2, '*')`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
for (const row of numberingColsResult) {
|
||||
const col = row.column_name;
|
||||
if (!data[col] || data[col] === "" || data[col] === "자동 생성됩니다") {
|
||||
const { numberingRuleService } = await import("./numberingRuleService");
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, col);
|
||||
if (rule) {
|
||||
const generatedCode = await numberingRuleService.allocateCode(rule.ruleId, companyCode, data);
|
||||
data[col] = generatedCode;
|
||||
logger.info(`채번 자동 적용: ${tableName}.${col} = ${generatedCode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (numErr: any) {
|
||||
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
|
||||
}
|
||||
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
||||
const skippedColumns: string[] = [];
|
||||
const existingColumns = Object.keys(data).filter((col) => {
|
||||
|
|
@ -3437,10 +3463,12 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
// ORDER BY 절 구성
|
||||
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
|
||||
// sortBy가 메인 테이블 컬럼이면 main. 접두사, 조인 별칭이면 접두사 없이 사용
|
||||
const hasCreatedDateColumn = selectColumns.includes("created_date");
|
||||
const orderBy = options.sortBy
|
||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
? selectColumns.includes(options.sortBy)
|
||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: hasCreatedDateColumn
|
||||
? `main."created_date" DESC`
|
||||
: "";
|
||||
|
|
@ -3505,7 +3533,7 @@ export class TableManagementService {
|
|||
const referenceTableColumns = new Map<string, string[]>();
|
||||
const uniqueRefTables = new Set(
|
||||
joinConfigs
|
||||
.filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외
|
||||
.filter((c) => c.referenceTable !== "category_values") // 카테고리는 제외
|
||||
.map((c) => `${c.referenceTable}:${c.sourceColumn}`)
|
||||
);
|
||||
|
||||
|
|
@ -3684,7 +3712,9 @@ export class TableManagementService {
|
|||
selectColumns,
|
||||
"", // WHERE 절은 나중에 추가
|
||||
options.sortBy
|
||||
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
|
||||
? selectColumns.includes(options.sortBy)
|
||||
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
|
||||
: `"${options.sortBy}" ${options.sortOrder || "ASC"}`
|
||||
: hasCreatedDateForSearch
|
||||
? `main."created_date" DESC`
|
||||
: undefined,
|
||||
|
|
@ -3783,15 +3813,15 @@ export class TableManagementService {
|
|||
);
|
||||
}
|
||||
} else if (operator === "equals") {
|
||||
// 🔧 equals 연산자: 정확히 일치
|
||||
// 🔧 equals 연산자: 메인 테이블의 FK 컬럼에서 직접 매칭 (연결 필터용)
|
||||
whereConditions.push(
|
||||
`${alias}.${joinConfig.displayColumn}::text = '${safeValue}'`
|
||||
`main.${joinConfig.sourceColumn}::text = '${safeValue}'`
|
||||
);
|
||||
entitySearchColumns.push(
|
||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
||||
`${key} (main.${joinConfig.sourceColumn})`
|
||||
);
|
||||
logger.info(
|
||||
`🎯 Entity 조인 정확히 일치 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})`
|
||||
`🎯 Entity 조인 직접 FK 매칭: ${key} → main.${joinConfig.sourceColumn} = '${safeValue}'`
|
||||
);
|
||||
} else {
|
||||
// 기본: 부분 일치 (ILIKE)
|
||||
|
|
@ -3875,7 +3905,9 @@ export class TableManagementService {
|
|||
const whereClause = whereConditions.join(" AND ");
|
||||
const hasCreatedDateForOrder = selectColumns.includes("created_date");
|
||||
const orderBy = options.sortBy
|
||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
? selectColumns.includes(options.sortBy)
|
||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: hasCreatedDateForOrder
|
||||
? `main."created_date" DESC`
|
||||
: "";
|
||||
|
|
@ -4310,8 +4342,8 @@ export class TableManagementService {
|
|||
];
|
||||
|
||||
for (const config of joinConfigs) {
|
||||
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||
if (config.referenceTable === "category_values") {
|
||||
dbJoins.push(config);
|
||||
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { query } from "../database/db";
|
||||
|
||||
/**
|
||||
* PostgreSQL 에러를 사용자 친절한 메시지로 변환
|
||||
* table_type_columns의 column_label을 조회하여 한글 라벨로 표시
|
||||
*/
|
||||
export async function formatPgError(
|
||||
error: any,
|
||||
companyCode?: string
|
||||
): Promise<string> {
|
||||
if (!error || !error.code) {
|
||||
return error?.message || "데이터 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
switch (error.code) {
|
||||
case "23502": {
|
||||
// not_null_violation
|
||||
const colName = error.column || "";
|
||||
const tblName = error.table || "";
|
||||
|
||||
if (colName && tblName && companyCode) {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT column_label FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 AND company_code = $3
|
||||
LIMIT 1`,
|
||||
[tblName, colName, companyCode]
|
||||
);
|
||||
const label = rows[0]?.column_label;
|
||||
if (label) {
|
||||
return `필수 입력값이 누락되었습니다: ${label}`;
|
||||
}
|
||||
} catch {
|
||||
// 라벨 조회 실패 시 컬럼명으로 폴백
|
||||
}
|
||||
}
|
||||
const detail = colName ? ` [${colName}]` : "";
|
||||
return `필수 입력값이 누락되었습니다.${detail}`;
|
||||
}
|
||||
case "23505": {
|
||||
// unique_violation
|
||||
const constraint = error.constraint || "";
|
||||
const tblName = error.table || "";
|
||||
// constraint 이름에서 컬럼명 추출 시도 (예: item_mst_item_code_key → item_code)
|
||||
let colName = "";
|
||||
if (constraint && tblName) {
|
||||
const prefix = `${tblName}_`;
|
||||
const suffix = "_key";
|
||||
if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) {
|
||||
colName = constraint.slice(prefix.length, -suffix.length);
|
||||
}
|
||||
}
|
||||
if (colName && tblName && companyCode) {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT column_label FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 AND company_code = $3
|
||||
LIMIT 1`,
|
||||
[tblName, colName, companyCode]
|
||||
);
|
||||
const label = rows[0]?.column_label;
|
||||
if (label) {
|
||||
return `중복된 데이터가 존재합니다: ${label}`;
|
||||
}
|
||||
} catch {
|
||||
// 폴백
|
||||
}
|
||||
}
|
||||
const detail = colName ? ` [${colName}]` : "";
|
||||
return `중복된 데이터가 존재합니다.${detail}`;
|
||||
}
|
||||
case "23503":
|
||||
return "참조 무결성 제약 조건 위반입니다.";
|
||||
default:
|
||||
if (error.code.startsWith("23")) {
|
||||
return "데이터 무결성 제약 조건 위반입니다.";
|
||||
}
|
||||
return error.message || "데이터 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
}
|
||||
|
|
@ -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 어시스턴트 프로세스 종료");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||
|
||||
|
|
@ -80,6 +80,20 @@ export default function AdminPage() {
|
|||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/aiAssistant" className="block">
|
||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Bot className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-foreground font-semibold">AI 어시스턴트</h3>
|
||||
<p className="text-muted-foreground text-sm">AI 채팅 및 LLM 연동 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { BarcodeListTable } from "@/components/barcode/BarcodeListTable";
|
||||
import { 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";
|
||||
|
||||
export default function BarcodeLabelManagementPage() {
|
||||
const router = useRouter();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [scanModalOpen, setScanModalOpen] = useState(false);
|
||||
const [scannedBarcode, setScannedBarcode] = useState<string | null>(null);
|
||||
|
||||
const { labels, total, page, limit, isLoading, refetch, setPage, handleSearch } = useBarcodeList();
|
||||
|
||||
|
|
@ -74,6 +77,33 @@ export default function BarcodeLabelManagementPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카메라 스캔: 바코드 값을 텍스트로 추출해 표시 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Scan className="h-5 w-5" />
|
||||
바코드 스캔
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
카메라로 바코드를 스캔하면 추출된 값을 아래에 텍스트로 표시합니다.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button onClick={() => setScanModalOpen(true)} variant="outline" className="gap-2">
|
||||
<Scan className="h-4 w-4" />
|
||||
카메라로 바코드 스캔
|
||||
</Button>
|
||||
{scannedBarcode ? (
|
||||
<div className="rounded-lg border bg-muted/30 p-4">
|
||||
<p className="text-muted-foreground mb-1 text-sm">추출된 바코드 값</p>
|
||||
<p className="font-mono text-lg font-semibold break-all">{scannedBarcode}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">아직 스캔한 바코드가 없습니다. 위 버튼으로 스캔해 보세요.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
|
|
@ -95,6 +125,18 @@ export default function BarcodeLabelManagementPage() {
|
|||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<BarcodeScanModal
|
||||
open={scanModalOpen}
|
||||
onOpenChange={setScanModalOpen}
|
||||
targetField="바코드 값"
|
||||
barcodeFormat="all"
|
||||
autoSubmit={false}
|
||||
onScanSuccess={(barcode) => {
|
||||
setScannedBarcode(barcode);
|
||||
setScanModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -669,38 +669,6 @@ export default function TableManagementPage() {
|
|||
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
|
||||
}
|
||||
|
||||
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
|
||||
console.log("🔍 Numbering 저장 체크:", {
|
||||
inputType: column.inputType,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
hasNumberingRuleId: !!column.numberingRuleId,
|
||||
});
|
||||
|
||||
if (column.inputType === "numbering") {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
|
||||
try {
|
||||
existingSettings = JSON.parse(finalDetailSettings);
|
||||
} catch {
|
||||
existingSettings = {};
|
||||
}
|
||||
}
|
||||
|
||||
// numberingRuleId가 있으면 저장, 없으면 제거
|
||||
if (column.numberingRuleId) {
|
||||
const numberingSettings = {
|
||||
...existingSettings,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
};
|
||||
finalDetailSettings = JSON.stringify(numberingSettings);
|
||||
console.log("🔧 Numbering 설정 JSON 생성:", numberingSettings);
|
||||
} else {
|
||||
// numberingRuleId가 없으면 빈 객체
|
||||
finalDetailSettings = JSON.stringify(existingSettings);
|
||||
console.log("🔧 Numbering 규칙 없이 저장:", existingSettings);
|
||||
}
|
||||
}
|
||||
|
||||
const columnSetting = {
|
||||
columnName: column.columnName,
|
||||
columnLabel: column.displayName,
|
||||
|
|
@ -844,28 +812,6 @@ export default function TableManagementPage() {
|
|||
// detailSettings 계산
|
||||
let finalDetailSettings = column.detailSettings || "";
|
||||
|
||||
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
|
||||
if (column.inputType === "numbering" && column.numberingRuleId) {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
|
||||
try {
|
||||
existingSettings = JSON.parse(finalDetailSettings);
|
||||
} catch {
|
||||
existingSettings = {};
|
||||
}
|
||||
}
|
||||
const numberingSettings = {
|
||||
...existingSettings,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
};
|
||||
finalDetailSettings = JSON.stringify(numberingSettings);
|
||||
console.log("🔧 전체저장 - Numbering 설정 JSON 생성:", {
|
||||
columnName: column.columnName,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
finalDetailSettings,
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함
|
||||
if (column.inputType === "entity" && column.referenceTable) {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
|
|
@ -1987,118 +1933,7 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 입력 타입이 'numbering'인 경우 채번규칙 선택 */}
|
||||
{column.inputType === "numbering" && (
|
||||
<div className="w-64">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">채번규칙</label>
|
||||
<Popover
|
||||
open={numberingComboboxOpen[column.columnName] || false}
|
||||
onOpenChange={(open) =>
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: open,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={numberingComboboxOpen[column.columnName] || false}
|
||||
disabled={numberingRulesLoading}
|
||||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
>
|
||||
<span className="truncate">
|
||||
{numberingRulesLoading
|
||||
? "로딩 중..."
|
||||
: column.numberingRuleId
|
||||
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)
|
||||
?.ruleName || column.numberingRuleId
|
||||
: "채번규칙 선택..."}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
채번규칙을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
const columnIndex = columns.findIndex(
|
||||
(c) => c.columnName === column.columnName,
|
||||
);
|
||||
handleColumnChange(columnIndex, "numberingRuleId", undefined);
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: false,
|
||||
}));
|
||||
// 자동 저장 제거 - 전체 저장 버튼으로 저장
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
!column.numberingRuleId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
</CommandItem>
|
||||
{numberingRules.map((rule) => (
|
||||
<CommandItem
|
||||
key={rule.ruleId}
|
||||
value={`${rule.ruleName} ${rule.ruleId}`}
|
||||
onSelect={() => {
|
||||
const columnIndex = columns.findIndex(
|
||||
(c) => c.columnName === column.columnName,
|
||||
);
|
||||
// 상태 업데이트만 (자동 저장 제거)
|
||||
handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId);
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: false,
|
||||
}));
|
||||
// 전체 저장 버튼으로 저장
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.numberingRuleId === rule.ruleId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{rule.ruleName}</span>
|
||||
{rule.tableName && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{rule.tableName}.{rule.columnName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{column.numberingRuleId && (
|
||||
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-0.5 text-[10px]">
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
<span>규칙 설정됨</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요) */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-4">
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.ReactNode }[] = [
|
||||
const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.ReactNode; barcodeType?: string }[] = [
|
||||
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
|
||||
{ type: "barcode", label: "바코드", icon: <Barcode className="h-4 w-4" /> },
|
||||
{ type: "barcode", label: "QR 코드", icon: <Barcode className="h-4 w-4" />, barcodeType: "QR" },
|
||||
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
|
||||
{ type: "line", label: "선", icon: <Minus className="h-4 w-4" /> },
|
||||
{ type: "rectangle", label: "사각형", icon: <Square className="h-4 w-4" /> },
|
||||
|
|
@ -16,22 +17,24 @@ const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.R
|
|||
|
||||
const MM_TO_PX = 4;
|
||||
|
||||
function defaultComponent(type: BarcodeLabelComponent["type"]): BarcodeLabelComponent {
|
||||
function defaultComponent(type: BarcodeLabelComponent["type"], barcodeType?: string): BarcodeLabelComponent {
|
||||
const id = `comp_${uuidv4()}`;
|
||||
const base = { id, type, x: 10 * MM_TO_PX, y: 10 * MM_TO_PX, width: 80, height: 24, zIndex: 0 };
|
||||
|
||||
switch (type) {
|
||||
case "text":
|
||||
return { ...base, content: "텍스트", fontSize: 10, fontColor: "#000000" };
|
||||
case "barcode":
|
||||
case "barcode": {
|
||||
const isQR = barcodeType === "QR";
|
||||
return {
|
||||
...base,
|
||||
width: 120,
|
||||
height: 40,
|
||||
barcodeType: "CODE128",
|
||||
barcodeValue: "123456789",
|
||||
showBarcodeText: true,
|
||||
width: isQR ? 100 : 120,
|
||||
height: isQR ? 100 : 40,
|
||||
barcodeType: barcodeType || "CODE128",
|
||||
barcodeValue: isQR ? "" : "123456789",
|
||||
showBarcodeText: !isQR,
|
||||
};
|
||||
}
|
||||
case "image":
|
||||
return { ...base, width: 60, height: 60, imageUrl: "", objectFit: "contain" };
|
||||
case "line":
|
||||
|
|
@ -47,14 +50,16 @@ function DraggableItem({
|
|||
type,
|
||||
label,
|
||||
icon,
|
||||
barcodeType,
|
||||
}: {
|
||||
type: BarcodeLabelComponent["type"];
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
barcodeType?: string;
|
||||
}) {
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: "barcode-component",
|
||||
item: { component: defaultComponent(type) },
|
||||
item: { component: defaultComponent(type, barcodeType) },
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
}));
|
||||
|
||||
|
|
@ -78,8 +83,14 @@ export function BarcodeComponentPalette() {
|
|||
<CardTitle className="text-sm">요소 추가</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{ITEMS.map((item) => (
|
||||
<DraggableItem key={item.type} type={item.type} label={item.label} icon={item.icon} />
|
||||
{ITEMS.map((item, idx) => (
|
||||
<DraggableItem
|
||||
key={item.barcodeType ? `${item.type}_${item.barcodeType}` : `${item.type}_${idx}`}
|
||||
type={item.type}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
barcodeType={item.barcodeType}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useBarcodeDesigner, MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
|
||||
import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
/** 작업 영역에 라벨이 들어가도록 스케일 (최소 0.5=작게 맞춤, 최대 3) */
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 3;
|
||||
|
||||
export function BarcodeDesignerCanvas() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const {
|
||||
widthMm,
|
||||
heightMm,
|
||||
|
|
@ -22,17 +28,45 @@ export function BarcodeDesignerCanvas() {
|
|||
const widthPx = widthMm * MM_TO_PX;
|
||||
const heightPx = heightMm * MM_TO_PX;
|
||||
|
||||
// 컨테이너 크기에 맞춰 캔버스 스케일 계산 (라벨이 너무 작게 보이지 않도록)
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || widthPx <= 0 || heightPx <= 0) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const w = el.clientWidth - 48;
|
||||
const h = el.clientHeight - 48;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const scaleX = w / widthPx;
|
||||
const scaleY = h / heightPx;
|
||||
const fitScale = Math.min(scaleX, scaleY);
|
||||
const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fitScale));
|
||||
setScale(s);
|
||||
});
|
||||
observer.observe(el);
|
||||
const w = el.clientWidth - 48;
|
||||
const h = el.clientHeight - 48;
|
||||
if (w > 0 && h > 0) {
|
||||
const scaleX = w / widthPx;
|
||||
const scaleY = h / heightPx;
|
||||
const fitScale = Math.min(scaleX, scaleY);
|
||||
const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fitScale));
|
||||
setScale(s);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [widthPx, heightPx]);
|
||||
|
||||
const [{ isOver }, drop] = useDrop(() => ({
|
||||
accept: "barcode-component",
|
||||
drop: (item: { component: BarcodeLabelComponent }, monitor) => {
|
||||
if (!canvasRef.current) return;
|
||||
const canvasEl = canvasRef.current;
|
||||
if (!canvasEl) return;
|
||||
const offset = monitor.getClientOffset();
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
if (!offset) return;
|
||||
|
||||
let x = offset.x - rect.left;
|
||||
let y = offset.y - rect.top;
|
||||
// 드롭 시 요소 중앙이 커서에 오도록 보정
|
||||
// 스케일 적용된 좌표 → 실제 캔버스 좌표
|
||||
const s = scale;
|
||||
let x = (offset.x - rect.left) / s;
|
||||
let y = (offset.y - rect.top) / s;
|
||||
x -= item.component.width / 2;
|
||||
y -= item.component.height / 2;
|
||||
x = Math.max(0, Math.min(x, widthPx - item.component.width));
|
||||
|
|
@ -48,36 +82,56 @@ export function BarcodeDesignerCanvas() {
|
|||
addComponent(newComp);
|
||||
},
|
||||
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 (
|
||||
<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
|
||||
key={`canvas-${widthMm}-${heightMm}`}
|
||||
ref={(r) => {
|
||||
(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);
|
||||
}}
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{ width: scaledW, height: scaledH }}
|
||||
>
|
||||
{components.map((c) => (
|
||||
<BarcodeLabelCanvasComponent key={c.id} component={c} />
|
||||
))}
|
||||
<div
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { BarcodeComponentPalette } from "./BarcodeComponentPalette";
|
|||
|
||||
export function BarcodeDesignerLeftPanel() {
|
||||
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">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
|
|
|
|||
|
|
@ -1,14 +1,125 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
|
||||
// QR 기본 키: 품번, 품명, 규격
|
||||
const DEFAULT_QR_JSON_KEYS = ["part_no", "part_name", "spec"];
|
||||
|
||||
function parseQRJsonValue(str: string): Record<string, string> {
|
||||
const trimmed = (str || "").trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
const o = JSON.parse(trimmed);
|
||||
if (o && typeof o === "object" && !Array.isArray(o)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(o).map(([k, v]) => [String(k), v != null ? String(v) : ""])
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function QRJsonFields({
|
||||
selected,
|
||||
update,
|
||||
}: {
|
||||
selected: BarcodeLabelComponent;
|
||||
update: (u: Partial<BarcodeLabelComponent>) => void;
|
||||
}) {
|
||||
const [pairs, setPairs] = useState<{ key: string; value: string }[]>(() => {
|
||||
const parsed = parseQRJsonValue(selected.barcodeValue || "");
|
||||
if (Object.keys(parsed).length > 0) {
|
||||
return Object.entries(parsed).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
return DEFAULT_QR_JSON_KEYS.map((key) => ({ key, value: "" }));
|
||||
});
|
||||
|
||||
// 바코드 값이 바깥에서 바뀌면 파싱해서 동기화
|
||||
useEffect(() => {
|
||||
const parsed = parseQRJsonValue(selected.barcodeValue || "");
|
||||
if (Object.keys(parsed).length > 0) {
|
||||
setPairs(Object.entries(parsed).map(([key, value]) => ({ key, value: String(value ?? "") })));
|
||||
}
|
||||
}, [selected.barcodeValue]);
|
||||
|
||||
const applyJson = () => {
|
||||
const obj: Record<string, string> = {};
|
||||
pairs.forEach(({ key, value }) => {
|
||||
const k = key.trim();
|
||||
if (k) obj[k] = value.trim();
|
||||
});
|
||||
update({ barcodeValue: JSON.stringify(obj) });
|
||||
};
|
||||
|
||||
const setPair = (index: number, field: "key" | "value", val: string) => {
|
||||
setPairs((prev) => {
|
||||
const next = [...prev];
|
||||
if (!next[index]) next[index] = { key: "", value: "" };
|
||||
next[index] = { ...next[index], [field]: val };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addRow = () => setPairs((prev) => [...prev, { key: "", value: "" }]);
|
||||
const removeRow = (index: number) =>
|
||||
setPairs((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)));
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-primary/30 bg-muted/20 p-3">
|
||||
<Label className="text-xs font-medium">여러 값 입력 → JSON으로 QR 생성</Label>
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">키는 자유 입력, 값 입력 후 적용 버튼을 누르면 QR에 반영됩니다.</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{pairs.map((p, i) => (
|
||||
<div key={i} className="flex gap-1 items-center">
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-xs"
|
||||
placeholder="키 (예: part_no)"
|
||||
value={p.key}
|
||||
onChange={(e) => setPair(i, "key", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-xs"
|
||||
placeholder="값"
|
||||
value={p.value}
|
||||
onChange={(e) => setPair(i, "value", e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-destructive"
|
||||
onClick={() => removeRow(i)}
|
||||
disabled={pairs.length <= 1}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1">
|
||||
<Button type="button" size="sm" variant="outline" className="flex-1 gap-1" onClick={addRow}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
필드 추가
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="flex-1" onClick={applyJson}>
|
||||
JSON으로 QR 적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodeDesignerRightPanel() {
|
||||
const {
|
||||
components,
|
||||
|
|
@ -56,8 +167,8 @@ export function BarcodeDesignerRightPanel() {
|
|||
updateComponent(selected.id, updates);
|
||||
|
||||
return (
|
||||
<div className="w-72 border-l bg-white">
|
||||
<div className="border-b p-2 flex items-center justify-between">
|
||||
<div className="flex w-72 flex-col border-l bg-white overflow-hidden">
|
||||
<div className="shrink-0 border-b p-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">속성</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -71,6 +182,7 @@ export function BarcodeDesignerRightPanel() {
|
|||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
|
|
@ -161,12 +273,15 @@ export function BarcodeDesignerRightPanel() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selected.barcodeType === "QR" && (
|
||||
<QRJsonFields selected={selected} update={update} />
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-xs">값</Label>
|
||||
<Label className="text-xs">{selected.barcodeType === "QR" ? "값 (직접 JSON 입력)" : "값"}</Label>
|
||||
<Input
|
||||
value={selected.barcodeValue || ""}
|
||||
onChange={(e) => update({ barcodeValue: e.target.value })}
|
||||
placeholder="123456789"
|
||||
placeholder={selected.barcodeType === "QR" ? '{"part_no":"","part_name":"","spec":""}' : "123456789"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -246,6 +361,7 @@ export function BarcodeDesignerRightPanel() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,11 +114,13 @@ export function BarcodeTemplatePalette() {
|
|||
key={t.template_id}
|
||||
variant="outline"
|
||||
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)}
|
||||
>
|
||||
<span className="truncate">{t.template_name_kor}</span>
|
||||
<span className="text-muted-foreground ml-1 shrink-0 text-xs">
|
||||
<span className="block break-words text-left text-xs leading-tight">
|
||||
{t.template_name_kor}
|
||||
</span>
|
||||
<span className="text-muted-foreground mt-0.5 block text-[10px]">
|
||||
{t.width_mm}×{t.height_mm}
|
||||
</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
useEffect(() => {
|
||||
if (open) {
|
||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||
// 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
|
@ -184,7 +183,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">바코드 스캔</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
카메라로 바코드를 스캔하세요.
|
||||
카메라로 바코드를 스캔합니다.
|
||||
{targetField && ` (대상 필드: ${targetField})`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
ArrowRight,
|
||||
Zap,
|
||||
Copy,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
||||
|
|
@ -35,6 +36,8 @@ import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
||||
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||
|
||||
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
|
||||
export interface MasterDetailExcelConfig {
|
||||
|
|
@ -133,6 +136,19 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
// 중복 처리 방법 (전역 설정)
|
||||
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
||||
|
||||
// 카테고리 검증 관련
|
||||
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||
// { [columnName]: { invalidValue: string, replacement: string | null, validOptions: {code: string, label: string}[], rowIndices: number[] }[] }
|
||||
const [categoryMismatches, setCategoryMismatches] = useState<
|
||||
Record<string, Array<{
|
||||
invalidValue: string;
|
||||
replacement: string | null;
|
||||
validOptions: Array<{ code: string; label: string }>;
|
||||
rowIndices: number[];
|
||||
}>>
|
||||
>({});
|
||||
|
||||
// 3단계: 확인
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
|
|
@ -601,8 +617,177 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
// 중복 체크 설정된 컬럼 수
|
||||
const duplicateCheckCount = columnMappings.filter((m) => m.checkDuplicate && m.systemColumn).length;
|
||||
|
||||
// 카테고리 컬럼 검증: 엑셀 데이터에서 유효하지 않은 카테고리 값 감지
|
||||
const validateCategoryColumns = async () => {
|
||||
try {
|
||||
setIsCategoryValidating(true);
|
||||
|
||||
const targetTableName = isMasterDetail && masterDetailRelation
|
||||
? masterDetailRelation.detailTable
|
||||
: tableName;
|
||||
|
||||
// 테이블의 카테고리 타입 컬럼 조회
|
||||
const colResponse = await getTableColumns(targetTableName);
|
||||
if (!colResponse.success || !colResponse.data?.columns) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const categoryColumns = colResponse.data.columns.filter(
|
||||
(col: any) => col.inputType === "category"
|
||||
);
|
||||
|
||||
if (categoryColumns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
|
||||
const mappedCategoryColumns: Array<{
|
||||
systemCol: string;
|
||||
excelCol: string;
|
||||
displayName: string;
|
||||
}> = [];
|
||||
|
||||
for (const mapping of columnMappings) {
|
||||
if (!mapping.systemColumn) continue;
|
||||
const rawName = mapping.systemColumn.includes(".")
|
||||
? mapping.systemColumn.split(".")[1]
|
||||
: mapping.systemColumn;
|
||||
|
||||
const catCol = categoryColumns.find(
|
||||
(cc: any) => (cc.columnName || cc.column_name) === rawName
|
||||
);
|
||||
if (catCol) {
|
||||
mappedCategoryColumns.push({
|
||||
systemCol: rawName,
|
||||
excelCol: mapping.excelColumn,
|
||||
displayName: catCol.displayName || catCol.display_name || rawName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedCategoryColumns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 각 카테고리 컬럼의 유효값 조회 및 엑셀 데이터 검증
|
||||
const mismatches: typeof categoryMismatches = {};
|
||||
|
||||
for (const catCol of mappedCategoryColumns) {
|
||||
const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol);
|
||||
if (!valuesResponse.success || !valuesResponse.data) continue;
|
||||
|
||||
const validValues = valuesResponse.data as Array<{
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
}>;
|
||||
|
||||
// 유효한 코드와 라벨 Set 생성
|
||||
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
||||
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
||||
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
|
||||
|
||||
// 엑셀 데이터에서 유효하지 않은 값 수집
|
||||
const invalidMap = new Map<string, number[]>();
|
||||
|
||||
allData.forEach((row, rowIdx) => {
|
||||
const val = row[catCol.excelCol];
|
||||
if (val === undefined || val === null || String(val).trim() === "") return;
|
||||
const strVal = String(val).trim();
|
||||
|
||||
// 코드 매칭 → 라벨 매칭 → 소문자 라벨 매칭
|
||||
if (validCodes.has(strVal)) return;
|
||||
if (validLabels.has(strVal)) return;
|
||||
if (validLabelsLower.has(strVal.toLowerCase())) return;
|
||||
|
||||
if (!invalidMap.has(strVal)) {
|
||||
invalidMap.set(strVal, []);
|
||||
}
|
||||
invalidMap.get(strVal)!.push(rowIdx);
|
||||
});
|
||||
|
||||
if (invalidMap.size > 0) {
|
||||
const options = validValues.map((v) => ({
|
||||
code: v.valueCode,
|
||||
label: v.valueLabel,
|
||||
}));
|
||||
|
||||
mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map(
|
||||
([invalidValue, rowIndices]) => ({
|
||||
invalidValue,
|
||||
replacement: null,
|
||||
validOptions: options,
|
||||
rowIndices,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(mismatches).length > 0) {
|
||||
return mismatches;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("카테고리 검증 실패:", error);
|
||||
return null;
|
||||
} finally {
|
||||
setIsCategoryValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 대체값 선택 후 데이터에 적용
|
||||
const applyCategoryReplacements = () => {
|
||||
// 모든 대체값이 선택되었는지 확인
|
||||
for (const [key, items] of Object.entries(categoryMismatches)) {
|
||||
for (const item of items) {
|
||||
if (item.replacement === null) {
|
||||
toast.error("모든 항목의 대체 값을 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 엑셀 컬럼명 → 시스템 컬럼명 매핑 구축
|
||||
const systemToExcelMap = new Map<string, string>();
|
||||
for (const mapping of columnMappings) {
|
||||
if (!mapping.systemColumn) continue;
|
||||
const rawName = mapping.systemColumn.includes(".")
|
||||
? mapping.systemColumn.split(".")[1]
|
||||
: mapping.systemColumn;
|
||||
systemToExcelMap.set(rawName, mapping.excelColumn);
|
||||
}
|
||||
|
||||
const newData = allData.map((row) => ({ ...row }));
|
||||
|
||||
for (const [key, items] of Object.entries(categoryMismatches)) {
|
||||
const systemCol = key.split("|||")[0];
|
||||
const excelCol = systemToExcelMap.get(systemCol);
|
||||
if (!excelCol) continue;
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.replacement) continue;
|
||||
// 선택된 대체값의 라벨 찾기
|
||||
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
|
||||
const replacementLabel = selectedOption?.label || item.replacement;
|
||||
|
||||
for (const rowIdx of item.rowIndices) {
|
||||
if (newData[rowIdx]) {
|
||||
newData[rowIdx][excelCol] = replacementLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAllData(newData);
|
||||
setDisplayData(newData);
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
toast.success("카테고리 값이 대체되었습니다.");
|
||||
setCurrentStep(3);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 다음 단계
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
if (currentStep === 1 && !file) {
|
||||
toast.error("파일을 선택해주세요.");
|
||||
return;
|
||||
|
|
@ -655,7 +840,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증
|
||||
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증 + 카테고리 검증
|
||||
if (currentStep === 2) {
|
||||
// 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장)
|
||||
const mappedSystemCols = new Set<string>();
|
||||
|
|
@ -681,6 +866,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
toast.error(`필수(NOT NULL) 컬럼이 매핑되지 않았습니다: ${colNames}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 카테고리 컬럼 검증
|
||||
const mismatches = await validateCategoryColumns();
|
||||
if (mismatches) {
|
||||
setCategoryMismatches(mismatches);
|
||||
setShowCategoryValidation(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
|
|
@ -1108,12 +1301,17 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
setSystemColumns([]);
|
||||
setColumnMappings([]);
|
||||
setDuplicateAction("skip");
|
||||
// 카테고리 검증 초기화
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
setIsCategoryValidating(false);
|
||||
// 🆕 마스터-디테일 모드 초기화
|
||||
setMasterFieldValues({});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||
|
|
@ -1750,10 +1948,17 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
{currentStep < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={isUploading || (currentStep === 1 && !file)}
|
||||
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
다음
|
||||
{isCategoryValidating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
검증 중...
|
||||
</>
|
||||
) : (
|
||||
"다음"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -1769,5 +1974,112 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 카테고리 대체값 선택 다이얼로그 */}
|
||||
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<AlertCircle className="h-5 w-5 text-warning" />
|
||||
존재하지 않는 카테고리 값 감지
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
엑셀 데이터에 등록되지 않은 카테고리 값이 있습니다. 각 항목에 대해 대체할 값을 선택해주세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
|
||||
{Object.entries(categoryMismatches).map(([key, items]) => {
|
||||
const [columnName, displayName] = key.split("|||");
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{displayName || columnName}
|
||||
</h4>
|
||||
{items.map((item, idx) => (
|
||||
<div
|
||||
key={`${key}-${idx}`}
|
||||
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium text-destructive line-through">
|
||||
{item.invalidValue}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{item.rowIndices.length}건
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
value={item.replacement || ""}
|
||||
onValueChange={(val) => {
|
||||
setCategoryMismatches((prev) => {
|
||||
const updated = { ...prev };
|
||||
updated[key] = updated[key].map((it, i) =>
|
||||
i === idx ? { ...it, replacement: val } : it
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
||||
<SelectValue placeholder="대체 값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{item.validOptions.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.code}
|
||||
value={opt.code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
setCurrentStep(3);
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
무시하고 진행
|
||||
</Button>
|
||||
<Button
|
||||
onClick={applyCategoryReplacements}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 { MenuItem } from "@/types/menu";
|
||||
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))) {
|
||||
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" />;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ export function TabBar() {
|
|||
touchAction: "none",
|
||||
...animStyle,
|
||||
...(hiddenByGhost ? { opacity: 0 } : {}),
|
||||
...(isActive ? { boxShadow: "0 -1px 28px rgba(0,0,0,0.9)" } : {}),
|
||||
...(isActive ? {} : {}),
|
||||
}}
|
||||
title={tab.title}
|
||||
>
|
||||
|
|
@ -542,13 +542,13 @@ export function TabBar() {
|
|||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="border-border bg-muted/30 relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
|
||||
className="border-border bg-background relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
|
||||
onDragOver={handleBarDragOver}
|
||||
onDragLeave={handleBarDragLeave}
|
||||
onDrop={handleBarDrop}
|
||||
>
|
||||
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
|
||||
<div className="pointer-events-none absolute inset-0 z-5 bg-black/15" />
|
||||
<div className="pointer-events-none absolute inset-0 z-5" />
|
||||
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
||||
|
||||
{hasOverflow && (
|
||||
|
|
@ -587,7 +587,7 @@ export function TabBar() {
|
|||
{ghostStyle && draggedTab && (
|
||||
<div
|
||||
style={ghostStyle}
|
||||
className="border-primary/50 bg-background rounded-t-md border border-b-0 px-3 shadow-lg"
|
||||
className="border-primary/50 bg-background rounded-t-md border border-b-0 px-3"
|
||||
>
|
||||
<div className="flex h-full items-center">
|
||||
<span className="truncate text-[11px] font-medium">{draggedTab.title}</span>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ interface AutoConfigPanelProps {
|
|||
config?: any;
|
||||
onChange: (config: any) => void;
|
||||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
interface TableInfo {
|
||||
|
|
@ -37,6 +38,7 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
|||
config = {},
|
||||
onChange,
|
||||
isPreview = false,
|
||||
tableName,
|
||||
}) => {
|
||||
// 1. 순번 (자동 증가)
|
||||
if (partType === "sequence") {
|
||||
|
|
@ -161,6 +163,18 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 6. 참조 (마스터-디테일 분번)
|
||||
if (partType === "reference") {
|
||||
return (
|
||||
<ReferenceConfigSection
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
isPreview={isPreview}
|
||||
tableName={tableName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -1088,3 +1102,94 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function ReferenceConfigSection({
|
||||
config,
|
||||
onChange,
|
||||
isPreview,
|
||||
tableName,
|
||||
}: {
|
||||
config: any;
|
||||
onChange: (c: any) => void;
|
||||
isPreview: boolean;
|
||||
tableName?: string;
|
||||
}) {
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingCols, setLoadingCols] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableName) return;
|
||||
setLoadingCols(true);
|
||||
|
||||
const loadEntityColumns = async () => {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(
|
||||
`/screen-management/tables/${tableName}/columns`
|
||||
);
|
||||
const allCols = response.data?.data || response.data || [];
|
||||
const entityCols = allCols.filter(
|
||||
(c: any) =>
|
||||
(c.inputType || c.input_type) === "entity" ||
|
||||
(c.inputType || c.input_type) === "numbering"
|
||||
);
|
||||
setColumns(
|
||||
entityCols.map((c: any) => ({
|
||||
columnName: c.columnName || c.column_name,
|
||||
displayName:
|
||||
c.columnLabel || c.column_label || c.columnName || c.column_name,
|
||||
dataType: c.dataType || c.data_type || "",
|
||||
inputType: c.inputType || c.input_type || "",
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
setColumns([]);
|
||||
} finally {
|
||||
setLoadingCols(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadEntityColumns();
|
||||
}, [tableName]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">참조 컬럼</Label>
|
||||
<Select
|
||||
value={config.referenceColumnName || ""}
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...config, referenceColumnName: value })
|
||||
}
|
||||
disabled={isPreview || loadingCols}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingCols
|
||||
? "로딩 중..."
|
||||
: columns.length === 0
|
||||
? "엔티티 컬럼 없음"
|
||||
: "컬럼 선택"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
className="text-xs"
|
||||
>
|
||||
{col.displayName} ({col.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
마스터 테이블과 연결된 엔티티/채번 컬럼의 값을 코드에 포함합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ interface NumberingRuleCardProps {
|
|||
onUpdate: (updates: Partial<NumberingRulePart>) => void;
|
||||
onDelete: () => void;
|
||||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||
|
|
@ -23,6 +24,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
onUpdate,
|
||||
onDelete,
|
||||
isPreview = false,
|
||||
tableName,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="border-border bg-card flex-1">
|
||||
|
|
@ -57,6 +59,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
date: { dateFormat: "YYYYMMDD" },
|
||||
text: { textValue: "CODE" },
|
||||
category: { categoryKey: "", categoryMappings: [] },
|
||||
reference: { referenceColumnName: "" },
|
||||
};
|
||||
onUpdate({
|
||||
partType: newPartType,
|
||||
|
|
@ -105,6 +108,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
config={part.autoConfig}
|
||||
onChange={(autoConfig) => onUpdate({ autoConfig })}
|
||||
isPreview={isPreview}
|
||||
tableName={tableName}
|
||||
/>
|
||||
) : (
|
||||
<ManualConfigPanel
|
||||
|
|
|
|||
|
|
@ -1,36 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Plus, Save, Edit2, FolderTree } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||
import { NumberingRulePreview } from "./NumberingRulePreview";
|
||||
import {
|
||||
saveNumberingRuleToTest,
|
||||
deleteNumberingRuleFromTest,
|
||||
getNumberingRulesFromTest,
|
||||
} from "@/lib/api/numberingRule";
|
||||
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 카테고리 값 트리 노드 타입
|
||||
interface CategoryValueNode {
|
||||
valueId: number;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
depth: number;
|
||||
path: string;
|
||||
parentValueId: number | null;
|
||||
children?: CategoryValueNode[];
|
||||
interface NumberingColumn {
|
||||
tableName: string;
|
||||
tableLabel: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
}
|
||||
|
||||
interface GroupedColumns {
|
||||
tableLabel: string;
|
||||
columns: NumberingColumn[];
|
||||
}
|
||||
|
||||
interface NumberingRuleDesignerProps {
|
||||
|
|
@ -54,138 +48,100 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
currentTableName,
|
||||
menuObjid,
|
||||
}) => {
|
||||
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
|
||||
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
|
||||
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록");
|
||||
const [columnSearch, setColumnSearch] = useState("");
|
||||
const [rightTitle, setRightTitle] = useState("규칙 편집");
|
||||
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
||||
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
||||
|
||||
// 구분자 관련 상태 (개별 파트 사이 구분자)
|
||||
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||
|
||||
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
||||
interface CategoryOption {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
displayName: string; // "테이블명.컬럼명" 형식
|
||||
}
|
||||
const [allCategoryOptions, setAllCategoryOptions] = useState<CategoryOption[]>([]);
|
||||
const [selectedCategoryKey, setSelectedCategoryKey] = useState<string>(""); // "tableName.columnName"
|
||||
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
|
||||
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
|
||||
const [categoryValueOpen, setCategoryValueOpen] = useState(false);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
|
||||
// 좌측: 채번 타입 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
|
||||
loadNumberingColumns();
|
||||
}, []);
|
||||
|
||||
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화
|
||||
useEffect(() => {
|
||||
if (currentRule?.categoryColumn) {
|
||||
setSelectedCategoryKey(currentRule.categoryColumn);
|
||||
} else {
|
||||
setSelectedCategoryKey("");
|
||||
}
|
||||
}, [currentRule?.categoryColumn]);
|
||||
|
||||
// 카테고리 키 선택 시 해당 카테고리 값 로드
|
||||
useEffect(() => {
|
||||
if (selectedCategoryKey) {
|
||||
const [tableName, columnName] = selectedCategoryKey.split(".");
|
||||
if (tableName && columnName) {
|
||||
loadCategoryValues(tableName, columnName);
|
||||
}
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
}, [selectedCategoryKey]);
|
||||
|
||||
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
|
||||
const loadAllCategoryOptions = async () => {
|
||||
try {
|
||||
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
|
||||
const response = await getAllCategoryKeys();
|
||||
if (response.success && response.data) {
|
||||
const options: CategoryOption[] = response.data.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
columnName: item.columnName,
|
||||
displayName: `${item.tableName}.${item.columnName}`,
|
||||
}));
|
||||
setAllCategoryOptions(options);
|
||||
console.log("전체 카테고리 옵션 로드:", options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 옵션 목록 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 카테고리 컬럼의 값 트리 조회
|
||||
const loadCategoryValues = async (tableName: string, columnName: string) => {
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
const response = await getCategoryTree(tableName, columnName);
|
||||
if (response.success && response.data) {
|
||||
setCategoryValues(response.data);
|
||||
console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length });
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 값 트리 조회 실패:", error);
|
||||
setCategoryValues([]);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용)
|
||||
const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => {
|
||||
for (const node of nodes) {
|
||||
result.push(node);
|
||||
if (node.children && node.children.length > 0) {
|
||||
flattenCategoryValues(node.children, result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const flatCategoryValues = flattenCategoryValues(categoryValues);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
const loadNumberingColumns = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", {
|
||||
menuObjid,
|
||||
hasMenuObjid: !!menuObjid,
|
||||
});
|
||||
|
||||
// test 테이블에서 조회
|
||||
const response = await getNumberingRulesFromTest(menuObjid);
|
||||
|
||||
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", {
|
||||
menuObjid,
|
||||
success: response.success,
|
||||
rulesCount: response.data?.length || 0,
|
||||
rules: response.data,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setSavedRules(response.data);
|
||||
} else {
|
||||
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
|
||||
const response = await apiClient.get("/table-management/numbering-columns");
|
||||
if (response.data.success && response.data.data) {
|
||||
setNumberingColumns(response.data.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`로딩 실패: ${error.message}`);
|
||||
console.error("채번 컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [menuObjid]);
|
||||
};
|
||||
|
||||
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
|
||||
const handleSelectColumn = async (tableName: string, columnName: string) => {
|
||||
setSelectedColumn({ tableName, columnName });
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
const rule = response.data.data as NumberingRuleConfig;
|
||||
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
||||
} else {
|
||||
// 규칙 없으면 신규 생성 모드
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: `${columnName} 채번`,
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table",
|
||||
tableName,
|
||||
columnName,
|
||||
};
|
||||
setCurrentRule(newRule);
|
||||
}
|
||||
} catch {
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: `${columnName} 채번`,
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table",
|
||||
tableName,
|
||||
columnName,
|
||||
};
|
||||
setCurrentRule(newRule);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블별로 그룹화
|
||||
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
|
||||
if (!acc[col.tableName]) {
|
||||
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
|
||||
}
|
||||
acc[col.tableName].columns.push(col);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 검색 필터 적용
|
||||
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
|
||||
if (!columnSearch) return true;
|
||||
const search = columnSearch.toLowerCase();
|
||||
return (
|
||||
tableName.toLowerCase().includes(search) ||
|
||||
group.tableLabel.toLowerCase().includes(search) ||
|
||||
group.columns.some(
|
||||
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRule) {
|
||||
|
|
@ -343,60 +299,20 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
return part;
|
||||
});
|
||||
|
||||
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
||||
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
|
||||
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
|
||||
const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global");
|
||||
|
||||
const ruleToSave = {
|
||||
...currentRule,
|
||||
parts: partsWithDefaults,
|
||||
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정
|
||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
||||
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준)
|
||||
scopeType: "table" as const,
|
||||
tableName: selectedColumn?.tableName || currentRule.tableName || "",
|
||||
columnName: selectedColumn?.columnName || currentRule.columnName || "",
|
||||
};
|
||||
|
||||
console.log("💾 채번 규칙 저장:", {
|
||||
currentTableName,
|
||||
menuObjid,
|
||||
"currentRule.tableName": currentRule.tableName,
|
||||
"currentRule.menuObjid": currentRule.menuObjid,
|
||||
"ruleToSave.tableName": ruleToSave.tableName,
|
||||
"ruleToSave.menuObjid": ruleToSave.menuObjid,
|
||||
"ruleToSave.scopeType": ruleToSave.scopeType,
|
||||
ruleToSave,
|
||||
});
|
||||
|
||||
// 테스트 테이블에 저장 (numbering_rules)
|
||||
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
|
||||
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||
|
||||
// setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지)
|
||||
setSavedRules((prev) => {
|
||||
const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||
const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId);
|
||||
|
||||
console.log("🔍 [handleSave] setSavedRules:", {
|
||||
ruleId: ruleToSave.ruleId,
|
||||
existsInPrev,
|
||||
prevCount: prev.length,
|
||||
});
|
||||
|
||||
if (existsInPrev) {
|
||||
// 기존 규칙 업데이트
|
||||
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r));
|
||||
} else {
|
||||
// 새 규칙 추가
|
||||
return [...prev, savedData];
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentRule(currentData);
|
||||
setSelectedRuleId(response.data.ruleId);
|
||||
|
||||
await onSave?.(response.data);
|
||||
toast.success("채번 규칙이 저장되었습니다");
|
||||
} else {
|
||||
|
|
@ -407,143 +323,62 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentRule, onSave, currentTableName, menuObjid]);
|
||||
|
||||
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
|
||||
console.log("🔍 [handleSelectRule] 규칙 선택:", {
|
||||
ruleId: rule.ruleId,
|
||||
ruleName: rule.ruleName,
|
||||
partsCount: rule.parts?.length || 0,
|
||||
parts: rule.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
|
||||
});
|
||||
|
||||
setSelectedRuleId(rule.ruleId);
|
||||
// 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록)
|
||||
const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig;
|
||||
|
||||
console.log("🔍 [handleSelectRule] 깊은 복사 후:", {
|
||||
ruleId: ruleCopy.ruleId,
|
||||
partsCount: ruleCopy.parts?.length || 0,
|
||||
parts: ruleCopy.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
|
||||
});
|
||||
|
||||
setCurrentRule(ruleCopy);
|
||||
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSavedRule = useCallback(
|
||||
async (ruleId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await deleteNumberingRuleFromTest(ruleId);
|
||||
|
||||
if (response.success) {
|
||||
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
|
||||
|
||||
if (selectedRuleId === ruleId) {
|
||||
setSelectedRuleId(null);
|
||||
setCurrentRule(null);
|
||||
}
|
||||
|
||||
toast.success("규칙이 삭제되었습니다");
|
||||
} else {
|
||||
showErrorToast("채번 규칙 삭제에 실패했습니다", response.error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showErrorToast("채번 규칙 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[selectedRuleId],
|
||||
);
|
||||
|
||||
const handleNewRule = useCallback(() => {
|
||||
console.log("📋 새 규칙 생성:", { currentTableName, menuObjid });
|
||||
|
||||
const newRule: NumberingRuleConfig = {
|
||||
ruleId: `rule-${Date.now()}`,
|
||||
ruleName: "새 채번 규칙",
|
||||
parts: [],
|
||||
separator: "-",
|
||||
resetPeriod: "none",
|
||||
currentSequence: 1,
|
||||
scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지
|
||||
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
|
||||
menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
|
||||
};
|
||||
|
||||
console.log("📋 생성된 규칙 정보:", newRule);
|
||||
|
||||
setSelectedRuleId(newRule.ruleId);
|
||||
setCurrentRule(newRule);
|
||||
|
||||
toast.success("새 규칙이 생성되었습니다");
|
||||
}, [currentTableName, menuObjid]);
|
||||
}, [currentRule, onSave, selectedColumn]);
|
||||
|
||||
return (
|
||||
<div className={`flex h-full gap-4 ${className}`}>
|
||||
{/* 좌측: 저장된 규칙 목록 */}
|
||||
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{editingLeftTitle ? (
|
||||
<Input
|
||||
value={leftTitle}
|
||||
onChange={(e) => setLeftTitle(e.target.value)}
|
||||
onBlur={() => setEditingLeftTitle(false)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
|
||||
className="h-8 text-sm font-semibold"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
|
||||
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold sm:text-base">채번 컬럼</h2>
|
||||
|
||||
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
|
||||
<Plus className="mr-2 h-4 w-4" />새 규칙 생성
|
||||
</Button>
|
||||
<Input
|
||||
value={columnSearch}
|
||||
onChange={(e) => setColumnSearch(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex-1 space-y-1 overflow-y-auto">
|
||||
{loading && numberingColumns.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs">로딩 중...</p>
|
||||
</div>
|
||||
) : savedRules.length === 0 ? (
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
<p className="text-muted-foreground text-xs">저장된 규칙이 없습니다</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{numberingColumns.length === 0
|
||||
? "채번 타입 컬럼이 없습니다"
|
||||
: "검색 결과가 없습니다"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
savedRules.map((rule) => (
|
||||
<Card
|
||||
key={rule.ruleId}
|
||||
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${
|
||||
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
|
||||
}`}
|
||||
onClick={() => handleSelectRule(rule)}
|
||||
>
|
||||
<CardHeader className="px-3 py-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteSavedRule(rule.ruleId);
|
||||
}}
|
||||
filteredGroups.map(([tableName, group]) => (
|
||||
<div key={tableName} className="mb-2">
|
||||
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
|
||||
<FolderTree className="h-3 w-3" />
|
||||
<span>{group.tableLabel}</span>
|
||||
<span className="text-muted-foreground/60">({group.columns.length})</span>
|
||||
</div>
|
||||
{group.columns.map((col) => {
|
||||
const isSelected =
|
||||
selectedColumn?.tableName === col.tableName &&
|
||||
selectedColumn?.columnName === col.columnName;
|
||||
return (
|
||||
<div
|
||||
key={`${col.tableName}.${col.columnName}`}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary border-primary border font-medium"
|
||||
: "hover:bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
|
||||
>
|
||||
<Trash2 className="text-destructive h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{col.columnLabel}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -557,8 +392,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
{!currentRule ? (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-2 text-lg font-medium">규칙을 선택해주세요</p>
|
||||
<p className="text-muted-foreground text-sm">좌측에서 규칙을 선택하거나 새로 생성하세요</p>
|
||||
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
||||
<p className="text-muted-foreground mb-2 text-lg font-medium">컬럼을 선택해주세요</p>
|
||||
<p className="text-muted-foreground text-sm">좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -624,6 +460,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
||||
onDelete={() => handleDeletePart(part.order)}
|
||||
isPreview={isPreview}
|
||||
tableName={selectedColumn?.tableName}
|
||||
/>
|
||||
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
||||
{index < currentRule.parts.length - 1 && (
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
|
|
@ -51,16 +52,13 @@ interface EditModalProps {
|
|||
*/
|
||||
const findSaveButtonInComponents = (components: any[]): any | null => {
|
||||
if (!components || !Array.isArray(components)) return null;
|
||||
|
||||
|
||||
for (const comp of components) {
|
||||
// button-primary이고 action.type이 save인 경우
|
||||
if (
|
||||
comp.componentType === "button-primary" &&
|
||||
comp.componentConfig?.action?.type === "save"
|
||||
) {
|
||||
if (comp.componentType === "button-primary" && comp.componentConfig?.action?.type === "save") {
|
||||
return comp;
|
||||
}
|
||||
|
||||
|
||||
// conditional-container의 sections 내부 탐색
|
||||
if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) {
|
||||
for (const section of comp.componentConfig.sections) {
|
||||
|
|
@ -71,14 +69,14 @@ const findSaveButtonInComponents = (components: any[]): any | null => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 자식 컴포넌트가 있으면 재귀 탐색
|
||||
if (comp.children && Array.isArray(comp.children)) {
|
||||
const found = findSaveButtonInComponents(comp.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -181,7 +179,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
};
|
||||
|
||||
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
|
||||
const loadSaveButtonConfig = async (targetScreenId: number): Promise<{
|
||||
const loadSaveButtonConfig = async (
|
||||
targetScreenId: number,
|
||||
): Promise<{
|
||||
enableDataflowControl?: boolean;
|
||||
dataflowConfig?: any;
|
||||
dataflowTiming?: string;
|
||||
|
|
@ -189,15 +189,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
try {
|
||||
// 1. 대상 화면의 레이아웃 조회
|
||||
const layoutData = await screenApi.getLayout(targetScreenId);
|
||||
|
||||
|
||||
if (!layoutData?.components) {
|
||||
console.log("[EditModal] 레이아웃 컴포넌트 없음:", targetScreenId);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// 2. 저장 버튼 찾기
|
||||
let saveButton = findSaveButtonInComponents(layoutData.components);
|
||||
|
||||
|
||||
// 3. conditional-container가 있는 경우 내부 화면도 탐색
|
||||
if (!saveButton) {
|
||||
for (const comp of layoutData.components) {
|
||||
|
|
@ -223,12 +223,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!saveButton) {
|
||||
// console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// 4. webTypeConfig에서 제어로직 설정 추출
|
||||
const webTypeConfig = saveButton.webTypeConfig;
|
||||
if (webTypeConfig?.enableDataflowControl) {
|
||||
|
|
@ -240,7 +240,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
// console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
|
||||
return null;
|
||||
} catch (error) {
|
||||
|
|
@ -249,6 +249,92 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 디테일 행의 FK를 통해 마스터 테이블 데이터를 자동 조회
|
||||
* - entity join 설정에서 FK 관계 탐지
|
||||
* - FK 값으로 마스터 테이블 전체 row 조회
|
||||
* - editData에 없는 필드만 병합 (디테일 데이터를 덮어쓰지 않음)
|
||||
*/
|
||||
const loadMasterDataForDetailRow = async (
|
||||
editData: Record<string, any>,
|
||||
targetScreenId: number,
|
||||
eventTableName?: string,
|
||||
): Promise<Record<string, any>> => {
|
||||
try {
|
||||
let detailTableName = eventTableName;
|
||||
if (!detailTableName) {
|
||||
const screenInfo = await screenApi.getScreen(targetScreenId);
|
||||
detailTableName = screenInfo?.tableName;
|
||||
}
|
||||
|
||||
if (!detailTableName) {
|
||||
console.log("[EditModal:MasterLoad] 테이블명을 알 수 없음 - 스킵");
|
||||
return {};
|
||||
}
|
||||
|
||||
console.log("[EditModal:MasterLoad] 시작:", { detailTableName, editDataKeys: Object.keys(editData) });
|
||||
|
||||
const entityJoinRes = await entityJoinApi.getEntityJoinConfigs(detailTableName);
|
||||
const joinConfigs = entityJoinRes?.joinConfigs || [];
|
||||
|
||||
if (joinConfigs.length === 0) {
|
||||
console.log("[EditModal:MasterLoad] entity join 없음 - 스킵");
|
||||
return {};
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[EditModal:MasterLoad] entity join:",
|
||||
joinConfigs.map((c) => `${c.sourceColumn} → ${c.referenceTable}`),
|
||||
);
|
||||
|
||||
const masterDataResult: Record<string, any> = {};
|
||||
const processedTables = new Set<string>();
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
for (const joinConfig of joinConfigs) {
|
||||
const { sourceColumn, referenceTable, referenceColumn } = joinConfig;
|
||||
if (processedTables.has(referenceTable)) continue;
|
||||
|
||||
const fkValue = editData[sourceColumn];
|
||||
if (!fkValue) continue;
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${referenceTable}/data`, {
|
||||
search: { [referenceColumn || "id"]: fkValue },
|
||||
size: 1,
|
||||
page: 1,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
const rows = response.data?.data?.data || response.data?.data?.rows || [];
|
||||
if (rows.length > 0) {
|
||||
const masterRow = rows[0];
|
||||
for (const [col, val] of Object.entries(masterRow)) {
|
||||
if (val !== undefined && val !== null && editData[col] === undefined) {
|
||||
masterDataResult[col] = val;
|
||||
}
|
||||
}
|
||||
console.log("[EditModal:MasterLoad] 조회 성공:", {
|
||||
table: referenceTable,
|
||||
fk: `${sourceColumn}=${fkValue}`,
|
||||
loadedFields: Object.keys(masterDataResult),
|
||||
});
|
||||
}
|
||||
} catch (queryError) {
|
||||
console.warn("[EditModal:MasterLoad] 조회 실패:", referenceTable, queryError);
|
||||
}
|
||||
|
||||
processedTables.add(referenceTable);
|
||||
}
|
||||
|
||||
console.log("[EditModal:MasterLoad] 최종 결과:", Object.keys(masterDataResult));
|
||||
return masterDataResult;
|
||||
} catch (error) {
|
||||
console.warn("[EditModal:MasterLoad] 전체 오류:", error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
// 전역 모달 이벤트 리스너 (활성 탭에서만 처리)
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = async (event: CustomEvent) => {
|
||||
|
|
@ -256,7 +342,20 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const currentActiveTabId = storeState[storeState.mode].activeTabId;
|
||||
if (tabId && tabId !== currentActiveTabId) return;
|
||||
|
||||
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext, menuObjid } = event.detail;
|
||||
const {
|
||||
screenId,
|
||||
title,
|
||||
description,
|
||||
modalSize,
|
||||
editData,
|
||||
onSave,
|
||||
groupByColumns,
|
||||
tableName,
|
||||
isCreateMode,
|
||||
buttonConfig,
|
||||
buttonContext,
|
||||
menuObjid,
|
||||
} = event.detail;
|
||||
|
||||
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
|
||||
let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined;
|
||||
|
|
@ -303,6 +402,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
// editData로 formData를 즉시 세팅 (채번 컴포넌트가 빈 formData로 마운트되어 새 번호 생성하는 것 방지)
|
||||
setFormData(enriched);
|
||||
// originalData: changedData 계산(PATCH)에만 사용
|
||||
// INSERT/UPDATE 판단에는 사용하지 않음
|
||||
|
|
@ -311,6 +412,21 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE
|
||||
setIsCreateModeFlag(!!isCreateMode);
|
||||
|
||||
// 마스터 데이터 자동 조회 (수정 모드일 때, formData 세팅 이후 비동기로 병합)
|
||||
// 디테일 행 선택 시 마스터 테이블의 컬럼 데이터를 자동으로 가져와서 추가
|
||||
if (!isCreateMode && editData && screenId) {
|
||||
loadMasterDataForDetailRow(editData, screenId, tableName)
|
||||
.then((masterData) => {
|
||||
if (Object.keys(masterData).length > 0) {
|
||||
setFormData((prev) => ({ ...prev, ...masterData }));
|
||||
console.log("[EditModal] 마스터 데이터 비동기 병합 완료:", Object.keys(masterData));
|
||||
}
|
||||
})
|
||||
.catch((masterError) => {
|
||||
console.warn("[EditModal] 마스터 데이터 자동 조회 중 오류 (무시):", masterError);
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[EditModal] 모달 열림:", {
|
||||
mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)",
|
||||
hasEditData: !!editData,
|
||||
|
|
@ -521,9 +637,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const layerConditionValue = conditionConfig.condition_value;
|
||||
|
||||
// 이 레이어가 속한 Zone 찾기
|
||||
const associatedZone = loadedZones.find(
|
||||
(z) => z.zone_id === layerZoneId
|
||||
);
|
||||
const associatedZone = loadedZones.find((z) => z.zone_id === layerZoneId);
|
||||
|
||||
layerDefinitions.push({
|
||||
id: `layer-${layer.layer_id}`,
|
||||
|
|
@ -548,13 +662,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("[EditModal] 조건부 레이어 로드 완료:", layerDefinitions.length, "개",
|
||||
console.log(
|
||||
"[EditModal] 조건부 레이어 로드 완료:",
|
||||
layerDefinitions.length,
|
||||
"개",
|
||||
layerDefinitions.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
conditionValue: l.conditionValue,
|
||||
condition: l.condition,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
setConditionalLayers(layerDefinitions);
|
||||
|
|
@ -585,7 +702,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
(targetComponent as any)?.componentConfig?.columnName ||
|
||||
targetComponentId;
|
||||
|
||||
const currentFormData = groupData.length > 0 ? groupData[0] : formData;
|
||||
const currentFormData = groupData.length > 0 ? { ...formData, ...groupData[0] } : formData;
|
||||
const targetValue = currentFormData[fieldKey];
|
||||
|
||||
let isMatch = false;
|
||||
|
|
@ -600,7 +717,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
if (Array.isArray(value)) {
|
||||
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
|
||||
} else if (typeof value === "string" && value.includes(",")) {
|
||||
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
|
||||
isMatch = value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.includes(String(targetValue ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -878,14 +998,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue} → ${currentValue}`);
|
||||
// 날짜 필드는 정규화된 값 사용, 나머지는 원본 값 사용
|
||||
let finalValue = dateFields.includes(key) ? currentValue : currentData[key];
|
||||
|
||||
|
||||
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
|
||||
if (Array.isArray(finalValue)) {
|
||||
const isRepeaterData = finalValue.length > 0 &&
|
||||
typeof finalValue[0] === "object" &&
|
||||
const isRepeaterData =
|
||||
finalValue.length > 0 &&
|
||||
typeof finalValue[0] === "object" &&
|
||||
finalValue[0] !== null &&
|
||||
("_targetTable" in finalValue[0] || "_isNewItem" in finalValue[0] || "_existingRecord" in finalValue[0]);
|
||||
|
||||
("_targetTable" in finalValue[0] ||
|
||||
"_isNewItem" in finalValue[0] ||
|
||||
"_existingRecord" in finalValue[0]);
|
||||
|
||||
if (!isRepeaterData) {
|
||||
// 🔧 손상된 값 필터링 헬퍼
|
||||
const isValidValue = (v: any): boolean => {
|
||||
|
|
@ -895,17 +1018,21 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
const validValues = finalValue
|
||||
.map((v: any) => typeof v === "number" ? String(v) : v)
|
||||
.map((v: any) => (typeof v === "number" ? String(v) : v))
|
||||
.filter(isValidValue);
|
||||
|
||||
|
||||
const stringValue = validValues.join(",");
|
||||
console.log(`🔧 [EditModal 그룹UPDATE] 배열→문자열 변환: ${key}`, { original: finalValue.length, valid: validValues.length, converted: stringValue });
|
||||
console.log(`🔧 [EditModal 그룹UPDATE] 배열→문자열 변환: ${key}`, {
|
||||
original: finalValue.length,
|
||||
valid: validValues.length,
|
||||
converted: stringValue,
|
||||
});
|
||||
finalValue = stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
changedData[key] = finalValue;
|
||||
}
|
||||
});
|
||||
|
|
@ -1002,37 +1129,35 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||
|
||||
|
||||
console.log("[EditModal] 그룹 저장 완료 후 제어로직 실행 시도", {
|
||||
hasSaveButtonConfig: !!modalState.saveButtonConfig,
|
||||
hasButtonConfig: !!modalState.buttonConfig,
|
||||
controlConfig,
|
||||
});
|
||||
|
||||
|
||||
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
|
||||
const flowTiming = controlConfig?.dataflowTiming
|
||||
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|
||||
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
const flowTiming =
|
||||
controlConfig?.dataflowTiming ||
|
||||
controlConfig?.dataflowConfig?.flowConfig?.executionTiming ||
|
||||
(controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
if (controlConfig?.enableDataflowControl && flowTiming === "after") {
|
||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||
|
||||
|
||||
// buttonActions의 executeAfterSaveControl 동적 import
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
||||
|
||||
// 제어로직 실행
|
||||
await ButtonActionExecutor.executeAfterSaveControl(
|
||||
controlConfig,
|
||||
{
|
||||
formData: modalState.editData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
}
|
||||
);
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(controlConfig, {
|
||||
formData: modalState.editData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
});
|
||||
|
||||
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||
} else {
|
||||
console.log("ℹ️ [EditModal] 저장 후 실행할 제어로직 없음");
|
||||
|
|
@ -1104,29 +1229,27 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
|
||||
// 🚀 Promise.all로 병렬 처리 (여러 채번 필드가 있을 경우 성능 향상)
|
||||
const allocationPromises = Object.entries(fieldsWithNumbering).map(
|
||||
async ([fieldName, ruleId]) => {
|
||||
const userInputCode = dataToSave[fieldName] as string;
|
||||
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
|
||||
|
||||
try {
|
||||
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
|
||||
|
||||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||||
return { fieldName, success: true, code: allocateResult.data.generatedCode };
|
||||
} else {
|
||||
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
|
||||
}
|
||||
} catch (allocateError) {
|
||||
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
|
||||
return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
|
||||
const allocationPromises = Object.entries(fieldsWithNumbering).map(async ([fieldName, ruleId]) => {
|
||||
const userInputCode = dataToSave[fieldName] as string;
|
||||
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
|
||||
|
||||
try {
|
||||
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
|
||||
|
||||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||||
return { fieldName, success: true, code: allocateResult.data.generatedCode };
|
||||
} else {
|
||||
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
return { fieldName, success: false, hasExistingValue: !!dataToSave[fieldName] };
|
||||
}
|
||||
} catch (allocateError) {
|
||||
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
|
||||
return { fieldName, success: false, hasExistingValue: !!dataToSave[fieldName] };
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const allocationResults = await Promise.all(allocationPromises);
|
||||
|
||||
|
||||
// 결과 처리
|
||||
const failedFields: string[] = [];
|
||||
for (const result of allocationResults) {
|
||||
|
|
@ -1162,11 +1285,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
masterDataToSave[key] = value;
|
||||
} else {
|
||||
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
|
||||
const isRepeaterData = value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
const isRepeaterData =
|
||||
value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
value[0] !== null &&
|
||||
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
|
||||
|
||||
|
||||
if (isRepeaterData) {
|
||||
console.log(`🔄 [EditModal] 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
||||
} else {
|
||||
|
|
@ -1178,14 +1302,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링)
|
||||
const validValues = value
|
||||
.map((v: any) => typeof v === "number" ? String(v) : v)
|
||||
.filter(isValidValue);
|
||||
|
||||
const validValues = value.map((v: any) => (typeof v === "number" ? String(v) : v)).filter(isValidValue);
|
||||
|
||||
const stringValue = validValues.join(",");
|
||||
console.log(`🔧 [EditModal CREATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue });
|
||||
console.log(`🔧 [EditModal CREATE] 배열→문자열 변환: ${key}`, {
|
||||
original: value.length,
|
||||
valid: validValues.length,
|
||||
converted: stringValue,
|
||||
});
|
||||
masterDataToSave[key] = stringValue;
|
||||
}
|
||||
}
|
||||
|
|
@ -1201,7 +1327,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
if (response.success) {
|
||||
const masterRecordId = response.data?.id || formData.id;
|
||||
|
||||
|
||||
toast.success("데이터가 생성되었습니다.");
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
|
|
@ -1217,31 +1343,29 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||
|
||||
|
||||
console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig });
|
||||
|
||||
|
||||
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
|
||||
const flowTimingInsert = controlConfig?.dataflowTiming
|
||||
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|
||||
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
const flowTimingInsert =
|
||||
controlConfig?.dataflowTiming ||
|
||||
controlConfig?.dataflowConfig?.flowConfig?.executionTiming ||
|
||||
(controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
if (controlConfig?.enableDataflowControl && flowTimingInsert === "after") {
|
||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||
|
||||
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(
|
||||
controlConfig,
|
||||
{
|
||||
formData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(controlConfig, {
|
||||
formData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
});
|
||||
|
||||
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||
}
|
||||
} catch (controlError) {
|
||||
|
|
@ -1301,18 +1425,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const dataToSave: Record<string, any> = {};
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
const isRepeaterData = value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
const isRepeaterData =
|
||||
value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
value[0] !== null &&
|
||||
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
|
||||
|
||||
|
||||
if (isRepeaterData) {
|
||||
// 리피터 데이터는 제외 (별도 저장)
|
||||
return;
|
||||
}
|
||||
// 다중 선택 배열 → 쉼표 구분 문자열
|
||||
const validValues = value
|
||||
.map((v: any) => typeof v === "number" ? String(v) : v)
|
||||
.map((v: any) => (typeof v === "number" ? String(v) : v))
|
||||
.filter((v: any) => {
|
||||
if (typeof v === "number") return true;
|
||||
if (typeof v !== "string") return false;
|
||||
|
|
@ -1344,31 +1469,29 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||
|
||||
|
||||
console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig });
|
||||
|
||||
|
||||
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
|
||||
const flowTimingUpdate = controlConfig?.dataflowTiming
|
||||
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|
||||
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
const flowTimingUpdate =
|
||||
controlConfig?.dataflowTiming ||
|
||||
controlConfig?.dataflowConfig?.flowConfig?.executionTiming ||
|
||||
(controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
if (controlConfig?.enableDataflowControl && flowTimingUpdate === "after") {
|
||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||
|
||||
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(
|
||||
controlConfig,
|
||||
{
|
||||
formData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(controlConfig, {
|
||||
formData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
});
|
||||
|
||||
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||
}
|
||||
} catch (controlError) {
|
||||
|
|
@ -1409,7 +1532,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
// 리피터 저장 완료 후 메인 테이블 새로고침
|
||||
if (modalState.onSave) {
|
||||
try { modalState.onSave(); } catch {}
|
||||
try {
|
||||
modalState.onSave();
|
||||
} catch {}
|
||||
}
|
||||
handleClose();
|
||||
} else {
|
||||
|
|
@ -1481,167 +1606,170 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
screenId={modalState.screenId || undefined}
|
||||
tableName={screenData.screenInfo?.tableName}
|
||||
>
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="relative m-auto bg-white"
|
||||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
// 조건부 레이어가 활성화되면 높이 자동 확장
|
||||
height: (() => {
|
||||
const baseHeight = (screenDimensions?.height || 600) + 30;
|
||||
if (activeConditionalComponents.length > 0) {
|
||||
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
let maxBottom = 0;
|
||||
activeConditionalComponents.forEach((comp) => {
|
||||
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
|
||||
const h = parseFloat(comp.size?.height?.toString() || "40");
|
||||
maxBottom = Math.max(maxBottom, y + h);
|
||||
});
|
||||
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
|
||||
}
|
||||
return baseHeight;
|
||||
})(),
|
||||
transformOrigin: "center center",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{/* 기본 레이어 컴포넌트 렌더링 */}
|
||||
{screenData.components.map((component) => {
|
||||
// 컴포넌트 위치를 offset만큼 조정
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="relative m-auto bg-white"
|
||||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
// 조건부 레이어가 활성화되면 높이 자동 확장
|
||||
height: (() => {
|
||||
const baseHeight = (screenDimensions?.height || 600) + 30;
|
||||
if (activeConditionalComponents.length > 0) {
|
||||
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
let maxBottom = 0;
|
||||
activeConditionalComponents.forEach((comp) => {
|
||||
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
|
||||
const h = parseFloat(comp.size?.height?.toString() || "40");
|
||||
maxBottom = Math.max(maxBottom, y + h);
|
||||
});
|
||||
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
|
||||
}
|
||||
return baseHeight;
|
||||
})(),
|
||||
transformOrigin: "center center",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{/* 기본 레이어 컴포넌트 렌더링 */}
|
||||
{screenData.components.map((component) => {
|
||||
// 컴포넌트 위치를 offset만큼 조정
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
|
||||
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
|
||||
},
|
||||
};
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
|
||||
},
|
||||
};
|
||||
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
|
||||
const hasUniversalFormModal = screenData.components.some(
|
||||
(c) => {
|
||||
const hasUniversalFormModal = screenData.components.some((c) => {
|
||||
if (c.componentType === "universal-form-modal") return true;
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
const hasTableSectionData = Object.keys(formData).some(k =>
|
||||
k.startsWith("_tableSection_") || k.startsWith("__tableSection_")
|
||||
);
|
||||
|
||||
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
|
||||
});
|
||||
|
||||
const enrichedFormData = {
|
||||
...(groupData.length > 0 ? groupData[0] : formData),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
const hasTableSectionData = Object.keys(formData).some(
|
||||
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
|
||||
);
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
if (groupData.length > 0) {
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
const shouldUseEditModalSave =
|
||||
!hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
|
||||
|
||||
const enrichedFormData = {
|
||||
// 마스터 데이터(formData)를 기본으로 깔고, groupData[0]으로 덮어쓰기
|
||||
// → 디테일 행 수정 시에도 마스터 폼 필드가 표시됨
|
||||
...formData,
|
||||
...(groupData.length > 0 ? groupData[0] : {}),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
if (groupData.length > 0) {
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
} else {
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
})),
|
||||
);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
menuObjid={modalState.menuObjid}
|
||||
onSave={shouldUseEditModalSave ? handleSave : undefined}
|
||||
isInModal={true}
|
||||
groupedData={groupedDataProp}
|
||||
disabledFields={["order_no", "partner_id"]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
menuObjid={modalState.menuObjid}
|
||||
onSave={shouldUseEditModalSave ? handleSave : undefined}
|
||||
isInModal={true}
|
||||
groupedData={groupedDataProp}
|
||||
disabledFields={["order_no", "partner_id"]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
||||
{activeConditionalComponents.map((component) => {
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
const labelSpace = 30;
|
||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
||||
{activeConditionalComponents.map((component) => {
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
const labelSpace = 30;
|
||||
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
|
||||
},
|
||||
};
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
|
||||
},
|
||||
};
|
||||
|
||||
const enrichedFormData = {
|
||||
...(groupData.length > 0 ? groupData[0] : formData),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
const enrichedFormData = {
|
||||
...formData,
|
||||
...(groupData.length > 0 ? groupData[0] : {}),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`conditional-${component.id}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
if (groupData.length > 0) {
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`conditional-${component.id}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
if (groupData.length > 0) {
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
} else {
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
})),
|
||||
);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
menuObjid={modalState.menuObjid}
|
||||
isInModal={true}
|
||||
groupedData={groupedDataProp}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
menuObjid={modalState.menuObjid}
|
||||
isInModal={true}
|
||||
groupedData={groupedDataProp}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScreenContextProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
for (const col of categoryColumns) {
|
||||
try {
|
||||
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
|
||||
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
|
||||
const queryParams = menuObjid ? `?menuObjid=${menuObjid}&includeInactive=true` : "?includeInactive=true";
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -246,6 +246,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
// 통합된 폼 데이터
|
||||
const finalFormData = { ...localFormData, ...externalFormData };
|
||||
|
||||
// 테이블 타입관리 NOT NULL 기반 필수 여부 판단
|
||||
const isColumnRequired = useCallback((columnName: string): boolean => {
|
||||
if (!columnName || tableColumns.length === 0) return false;
|
||||
const colInfo = tableColumns.find(
|
||||
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === columnName.toLowerCase()
|
||||
);
|
||||
if (!colInfo) return false;
|
||||
const nullable = (colInfo as any).isNullable || (colInfo as any).is_nullable;
|
||||
return nullable === "NO" || nullable === "N";
|
||||
}, [tableColumns]);
|
||||
|
||||
// 🆕 조건부 레이어 로직 (formData 변경 시 자동 평가)
|
||||
useEffect(() => {
|
||||
layers.forEach((layer) => {
|
||||
|
|
@ -1613,8 +1624,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
return;
|
||||
}
|
||||
|
||||
// 필수 항목 검증
|
||||
const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id));
|
||||
// 필수 항목 검증 (테이블 타입관리 NOT NULL 기반 + 기존 required 속성 폴백)
|
||||
const requiredFields = allComponents.filter(c => {
|
||||
const colName = c.columnName || c.id;
|
||||
return (c.required || isColumnRequired(colName)) && colName;
|
||||
});
|
||||
const missingFields = requiredFields.filter(field => {
|
||||
const fieldName = field.columnName || field.id;
|
||||
const value = currentFormData[fieldName];
|
||||
|
|
@ -2487,7 +2501,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
style={labelStyle}
|
||||
>
|
||||
{labelText}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
{(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
@ -2506,7 +2520,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
}}
|
||||
>
|
||||
{labelText}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
{(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, Butt
|
|||
import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent";
|
||||
import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { DynamicComponentRenderer, isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
||||
|
|
@ -560,9 +560,13 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
if (onSave) {
|
||||
try {
|
||||
await onSave();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("저장 오류:", error);
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
const msg =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
"저장에 실패했습니다.";
|
||||
toast.error(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -597,8 +601,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
);
|
||||
|
||||
toast.success("데이터가 성공적으로 저장되었습니다.");
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} catch (error: any) {
|
||||
const msg =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
"저장에 실패했습니다.";
|
||||
toast.error(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -658,8 +666,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
} else {
|
||||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} catch (error: any) {
|
||||
const msg =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
"저장에 실패했습니다.";
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1297,9 +1309,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{((component as any).required || (component as any).componentConfig?.required) && (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
{labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && (
|
||||
<span className="text-orange-500">*</span>
|
||||
)}
|
||||
</label>
|
||||
) : null;
|
||||
|
|
@ -1353,9 +1364,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{((component as any).required || (component as any).componentConfig?.required) && (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
{labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && (
|
||||
<span className="text-orange-500">*</span>
|
||||
)}
|
||||
</label>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
|
|||
import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||
import { ComponentData } from "@/lib/types/screen";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
interface SaveModalProps {
|
||||
|
|
@ -41,6 +42,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {});
|
||||
const [screenData, setScreenData] = useState<any>(null);
|
||||
const [components, setComponents] = useState<ComponentData[]>([]);
|
||||
const [tableColumnsInfo, setTableColumnsInfo] = useState<ColumnTypeInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
|
|
@ -69,6 +71,19 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
const layout = await screenApi.getLayout(screenId);
|
||||
setComponents(layout.components || []);
|
||||
|
||||
// 테이블 컬럼 정보 로드 (NOT NULL 필수값 자동 인식용)
|
||||
const tblName = screen?.tableName || layout.components?.find((c: any) => c.columnName)?.tableName;
|
||||
if (tblName) {
|
||||
try {
|
||||
const colResult = await getTableColumns(tblName);
|
||||
if (colResult.success && colResult.data?.columns) {
|
||||
setTableColumnsInfo(colResult.data.columns);
|
||||
}
|
||||
} catch (colErr) {
|
||||
console.warn("테이블 컬럼 정보 로드 실패 (필수값 검증 시 기존 방식 사용):", colErr);
|
||||
}
|
||||
}
|
||||
|
||||
// initialData가 있으면 폼에 채우기
|
||||
if (initialData) {
|
||||
setFormData(initialData);
|
||||
|
|
@ -105,6 +120,49 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
};
|
||||
}, [onClose]);
|
||||
|
||||
// 테이블 타입관리의 NOT NULL 설정 기반으로 필수 여부 판단
|
||||
const isColumnRequired = (columnName: string): boolean => {
|
||||
if (!columnName || tableColumnsInfo.length === 0) return false;
|
||||
const colInfo = tableColumnsInfo.find((c) => c.columnName.toLowerCase() === columnName.toLowerCase());
|
||||
if (!colInfo) return false;
|
||||
// is_nullable가 "NO"이면 필수
|
||||
return colInfo.isNullable === "NO" || colInfo.isNullable === "N";
|
||||
};
|
||||
|
||||
// 필수 항목 검증 (테이블 타입관리 NOT NULL + 기존 required 속성 병합)
|
||||
const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
|
||||
const missingFields: string[] = [];
|
||||
|
||||
components.forEach((component) => {
|
||||
const columnName = component.columnName || component.style?.columnName;
|
||||
const label = component.label || component.style?.label || columnName;
|
||||
|
||||
// 기존 required 속성 (화면 디자이너에서 수동 설정한 것)
|
||||
const manualRequired =
|
||||
component.required === true ||
|
||||
component.style?.required === true ||
|
||||
component.componentConfig?.required === true;
|
||||
|
||||
// 테이블 타입관리 NOT NULL 기반 필수 (컬럼 정보가 있을 때만)
|
||||
const notNullRequired = columnName ? isColumnRequired(columnName) : false;
|
||||
|
||||
// 둘 중 하나라도 필수이면 검증
|
||||
const isRequired = manualRequired || notNullRequired;
|
||||
|
||||
if (isRequired && columnName) {
|
||||
const value = formData[columnName];
|
||||
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
|
||||
missingFields.push(label || columnName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: missingFields.length === 0,
|
||||
missingFields,
|
||||
};
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!screenData || !screenId) return;
|
||||
|
|
@ -115,6 +173,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// ✅ 필수 항목 검증
|
||||
const validation = validateRequiredFields();
|
||||
if (!validation.isValid) {
|
||||
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
|
|
@ -142,7 +207,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
// 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음)
|
||||
const writerValue = user.userId;
|
||||
const companyCodeValue = user.companyCode || "";
|
||||
|
||||
|
||||
console.log("👤 현재 사용자 정보:", {
|
||||
userId: user.userId,
|
||||
userName: userName,
|
||||
|
|
@ -187,11 +252,11 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
|
||||
if (result.success) {
|
||||
const masterRecordId = result.data?.id || dataToSave.id;
|
||||
|
||||
|
||||
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId,
|
||||
mainFormData: dataToSave,
|
||||
|
|
@ -200,7 +265,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
}),
|
||||
);
|
||||
console.log("📋 [SaveModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName });
|
||||
|
||||
|
||||
// ✅ 저장 성공
|
||||
toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!");
|
||||
|
||||
|
|
@ -214,7 +279,8 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
}, 300); // 모달 닫힘 애니메이션 후 실행
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message || "저장에 실패했습니다.");
|
||||
const errorMsg = result.message || result.error?.message || "저장에 실패했습니다.";
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// ❌ 저장 실패 - 모달은 닫히지 않음
|
||||
|
|
@ -231,29 +297,29 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
const calculateDynamicSize = () => {
|
||||
if (!components.length) return { width: 800, height: 600 };
|
||||
|
||||
const maxX = Math.max(...components.map((c) => {
|
||||
const x = c.position?.x || 0;
|
||||
const width = typeof c.size?.width === 'number'
|
||||
? c.size.width
|
||||
: parseInt(String(c.size?.width || 200), 10);
|
||||
return x + width;
|
||||
}));
|
||||
|
||||
const maxY = Math.max(...components.map((c) => {
|
||||
const y = c.position?.y || 0;
|
||||
const height = typeof c.size?.height === 'number'
|
||||
? c.size.height
|
||||
: parseInt(String(c.size?.height || 40), 10);
|
||||
return y + height;
|
||||
}));
|
||||
const maxX = Math.max(
|
||||
...components.map((c) => {
|
||||
const x = c.position?.x || 0;
|
||||
const width = typeof c.size?.width === "number" ? c.size.width : parseInt(String(c.size?.width || 200), 10);
|
||||
return x + width;
|
||||
}),
|
||||
);
|
||||
|
||||
const maxY = Math.max(
|
||||
...components.map((c) => {
|
||||
const y = c.position?.y || 0;
|
||||
const height = typeof c.size?.height === "number" ? c.size.height : parseInt(String(c.size?.height || 40), 10);
|
||||
return y + height;
|
||||
}),
|
||||
);
|
||||
|
||||
// 컨텐츠 영역 크기 (화면관리 설정 크기)
|
||||
const contentWidth = Math.max(maxX, 400);
|
||||
const contentHeight = Math.max(maxY, 300);
|
||||
|
||||
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더
|
||||
const headerHeight = 60; // DialogHeader
|
||||
|
||||
|
||||
return {
|
||||
width: contentWidth,
|
||||
height: contentHeight + headerHeight, // 헤더 높이 포함
|
||||
|
|
@ -271,18 +337,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
minWidth: "400px",
|
||||
minHeight: "300px",
|
||||
}}
|
||||
className="gap-0 p-0 max-w-none"
|
||||
className="max-w-none gap-0 p-0"
|
||||
>
|
||||
<DialogHeader className="border-b px-6 py-4 flex-shrink-0">
|
||||
<DialogHeader className="flex-shrink-0 border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Button onClick={handleSave} disabled={isSaving} size="sm" className="gap-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
|
@ -302,7 +363,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-auto p-6 flex-1">
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
|
|
@ -320,89 +381,92 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
<div className="relative" style={{ width: `${dynamicSize.width}px`, height: `${dynamicSize.height}px` }}>
|
||||
{components.map((component, index) => {
|
||||
// ✅ 격자 시스템 잔재 제거: size의 픽셀 값만 사용
|
||||
const widthPx = typeof component.size?.width === 'number'
|
||||
? component.size.width
|
||||
: parseInt(String(component.size?.width || 200), 10);
|
||||
const heightPx = typeof component.size?.height === 'number'
|
||||
? component.size.height
|
||||
: parseInt(String(component.size?.height || 40), 10);
|
||||
const widthPx =
|
||||
typeof component.size?.width === "number"
|
||||
? component.size.width
|
||||
: parseInt(String(component.size?.width || 200), 10);
|
||||
const heightPx =
|
||||
typeof component.size?.height === "number"
|
||||
? component.size.height
|
||||
: parseInt(String(component.size?.height || 40), 10);
|
||||
|
||||
// 디버깅: 실제 크기 확인
|
||||
if (index === 0) {
|
||||
console.log('🔍 SaveModal 컴포넌트 크기:', {
|
||||
console.log("🔍 SaveModal 컴포넌트 크기:", {
|
||||
componentId: component.id,
|
||||
'size.width (원본)': component.size?.width,
|
||||
'size.width 타입': typeof component.size?.width,
|
||||
'widthPx (계산)': widthPx,
|
||||
'style.width': component.style?.width,
|
||||
"size.width (원본)": component.size?.width,
|
||||
"size.width 타입": typeof component.size?.width,
|
||||
"widthPx (계산)": widthPx,
|
||||
"style.width": component.style?.width,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: component.position?.y || 0,
|
||||
left: component.position?.x || 0,
|
||||
width: `${widthPx}px`, // ✅ 픽셀 단위 강제
|
||||
height: `${heightPx}px`, // ✅ 픽셀 단위 강제
|
||||
zIndex: component.position?.z || 1000 + index,
|
||||
}}
|
||||
>
|
||||
{component.type === "widget" ? (
|
||||
<InteractiveScreenViewer
|
||||
component={component}
|
||||
allComponents={components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
hideLabel={false}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
||||
/>
|
||||
) : (
|
||||
<DynamicComponentRenderer
|
||||
component={{
|
||||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: true,
|
||||
},
|
||||
}}
|
||||
screenId={screenId}
|
||||
tableName={screenData.tableName}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
||||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
formData={formData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 SaveModal - formData 변경:", {
|
||||
fieldName,
|
||||
value,
|
||||
componentType: component.type,
|
||||
componentId: component.id,
|
||||
});
|
||||
setFormData((prev) => {
|
||||
const newData = {
|
||||
<div
|
||||
key={component.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: component.position?.y || 0,
|
||||
left: component.position?.x || 0,
|
||||
width: `${widthPx}px`, // ✅ 픽셀 단위 강제
|
||||
height: `${heightPx}px`, // ✅ 픽셀 단위 강제
|
||||
zIndex: component.position?.z || 1000 + index,
|
||||
}}
|
||||
>
|
||||
{component.type === "widget" ? (
|
||||
<InteractiveScreenViewer
|
||||
component={component}
|
||||
allComponents={components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📦 새 formData:", newData);
|
||||
return newData;
|
||||
});
|
||||
}}
|
||||
mode="edit"
|
||||
isInModal={true}
|
||||
isInteractive={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}));
|
||||
}}
|
||||
hideLabel={false}
|
||||
menuObjid={menuObjid}
|
||||
tableColumns={tableColumnsInfo as any}
|
||||
/>
|
||||
) : (
|
||||
<DynamicComponentRenderer
|
||||
component={{
|
||||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: true,
|
||||
},
|
||||
}}
|
||||
screenId={screenId}
|
||||
tableName={screenData.tableName}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
||||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
formData={formData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 SaveModal - formData 변경:", {
|
||||
fieldName,
|
||||
value,
|
||||
componentType: component.type,
|
||||
componentId: component.id,
|
||||
});
|
||||
setFormData((prev) => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📦 새 formData:", newData);
|
||||
return newData;
|
||||
});
|
||||
}}
|
||||
mode="edit"
|
||||
isInModal={true}
|
||||
isInteractive={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,19 @@ 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 { Card } from "@/components/ui/card";
|
||||
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database, Info, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Search,
|
||||
Plus,
|
||||
X,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Type,
|
||||
Database,
|
||||
Info,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -18,6 +30,7 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
|||
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
||||
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
|
||||
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
|
||||
import { icons as allLucideIcons } from "lucide-react";
|
||||
|
|
@ -30,7 +43,6 @@ import {
|
|||
addToIconMap,
|
||||
getDefaultIconForAction,
|
||||
} from "@/lib/button-icon-map";
|
||||
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
|
||||
|
||||
// 🆕 제목 블록 타입
|
||||
interface TitleBlock {
|
||||
|
|
@ -84,13 +96,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
});
|
||||
|
||||
// 아이콘 설정 상태
|
||||
const [displayMode, setDisplayMode] = useState<"text" | "icon" | "icon-text">(
|
||||
config.displayMode || "text",
|
||||
);
|
||||
const [displayMode, setDisplayMode] = useState<"text" | "icon" | "icon-text">(config.displayMode || "text");
|
||||
const [selectedIcon, setSelectedIcon] = useState<string>(config.icon?.name || "");
|
||||
const [selectedIconType, setSelectedIconType] = useState<"lucide" | "svg">(
|
||||
config.icon?.type || "lucide",
|
||||
);
|
||||
const [selectedIconType, setSelectedIconType] = useState<"lucide" | "svg">(config.icon?.type || "lucide");
|
||||
const [iconSize, setIconSize] = useState<string>(config.icon?.size || "보통");
|
||||
const [iconColor, setIconColor] = useState<string>(config.icon?.color || "");
|
||||
const [iconGap, setIconGap] = useState<number>(config.iconGap ?? 6);
|
||||
|
|
@ -129,7 +137,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
||||
|
||||
// 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원)
|
||||
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
|
||||
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<
|
||||
Record<string, Array<{ name: string; label: string }>>
|
||||
>({});
|
||||
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<string, boolean>>({});
|
||||
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<string, boolean>>({});
|
||||
|
|
@ -386,7 +396,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
loadAll();
|
||||
}, [config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns]);
|
||||
}, [
|
||||
config.action?.dataTransfer?.multiTableMappings,
|
||||
config.action?.dataTransfer?.sourceTable,
|
||||
config.action?.dataTransfer?.targetTable,
|
||||
loadMappingColumns,
|
||||
]);
|
||||
|
||||
// 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -951,7 +966,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="button-action" className="mb-1.5 block">버튼 액션</Label>
|
||||
<Label htmlFor="button-action" className="mb-1.5 block">
|
||||
버튼 액션
|
||||
</Label>
|
||||
<Select
|
||||
key={`action-${component.id}`}
|
||||
value={localInputs.actionType}
|
||||
|
|
@ -992,6 +1009,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
{/* 엑셀 관련 */}
|
||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
<SelectItem value="multi_table_excel_upload">다중 테이블 엑셀 업로드</SelectItem>
|
||||
|
||||
{/* 고급 기능 */}
|
||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||
|
|
@ -1026,7 +1044,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
{/* 추천 아이콘 / 안내 문구 */}
|
||||
{isNoIconAction ? (
|
||||
<div>
|
||||
<div className="rounded-md border border-dashed p-3 text-center text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-center text-xs">
|
||||
{NO_ICON_MESSAGE}
|
||||
</div>
|
||||
|
||||
|
|
@ -1034,9 +1052,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
{(customIcons.length > 0 || customSvgIcons.length > 0) && (
|
||||
<>
|
||||
<div className="my-2 flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-[10px] text-muted-foreground">커스텀 아이콘</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground text-[10px]">커스텀 아이콘</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{customIcons.map((iconName) => {
|
||||
|
|
@ -1048,14 +1066,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
type="button"
|
||||
onClick={() => handleSelectIcon(iconName, "lucide")}
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
|
||||
"hover:bg-muted flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
||||
selectedIcon === iconName && selectedIconType === "lucide"
|
||||
? "border-primary ring-2 ring-primary/30 bg-primary/5"
|
||||
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
<span className="truncate text-[10px] text-muted-foreground">{iconName}</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]">{iconName}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1064,7 +1082,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
onUpdateProperty("componentConfig.customIcons", next);
|
||||
if (selectedIcon === iconName) revertToDefaultIcon();
|
||||
}}
|
||||
className="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/80 absolute -top-1 -right-1 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -1077,9 +1095,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
type="button"
|
||||
onClick={() => handleSelectIcon(svgIcon.name, "svg")}
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
|
||||
"hover:bg-muted flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
||||
selectedIcon === svgIcon.name && selectedIconType === "svg"
|
||||
? "border-primary ring-2 ring-primary/30 bg-primary/5"
|
||||
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
|
|
@ -1089,7 +1107,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
__html: DOMPurify.sanitize(svgIcon.svg, { USE_PROFILES: { svg: true } }),
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-[10px] text-muted-foreground">{svgIcon.name}</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]">{svgIcon.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1098,7 +1116,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
onUpdateProperty("componentConfig.customSvgIcons", next);
|
||||
if (selectedIcon === svgIcon.name) revertToDefaultIcon();
|
||||
}}
|
||||
className="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/80 absolute -top-1 -right-1 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -1151,7 +1169,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
>
|
||||
{Icon ? <Icon className="h-4 w-4" /> : <span className="h-4 w-4" />}
|
||||
{iconName}
|
||||
{customIcons.includes(iconName) && <Check className="ml-auto h-3 w-3 text-primary" />}
|
||||
{customIcons.includes(iconName) && <Check className="text-primary ml-auto h-3 w-3" />}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1194,10 +1212,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder={'<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>'}
|
||||
className="h-20 w-full rounded-md border bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="bg-background focus:ring-ring h-20 w-full rounded-md border px-2 py-1.5 text-xs focus:ring-2 focus:outline-none"
|
||||
/>
|
||||
{svgInput && (
|
||||
<div className="flex items-center justify-center rounded border bg-muted/50 p-2">
|
||||
<div className="bg-muted/50 flex items-center justify-center rounded border p-2">
|
||||
<span
|
||||
className="flex h-8 w-8 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
||||
dangerouslySetInnerHTML={{
|
||||
|
|
@ -1206,7 +1224,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{svgError && <p className="text-xs text-destructive">{svgError}</p>}
|
||||
{svgError && <p className="text-destructive text-xs">{svgError}</p>}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
|
|
@ -1254,14 +1272,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
type="button"
|
||||
onClick={() => handleSelectIcon(iconName, "lucide")}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
|
||||
"hover:bg-muted flex flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
||||
selectedIcon === iconName && selectedIconType === "lucide"
|
||||
? "border-primary ring-2 ring-primary/30 bg-primary/5"
|
||||
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
<span className="truncate text-[10px] text-muted-foreground">{iconName}</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]">{iconName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1271,9 +1289,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
{(customIcons.length > 0 || customSvgIcons.length > 0) && (
|
||||
<>
|
||||
<div className="my-2 flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-[10px] text-muted-foreground">커스텀 아이콘</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground text-[10px]">커스텀 아이콘</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{customIcons.map((iconName) => {
|
||||
|
|
@ -1285,14 +1303,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
type="button"
|
||||
onClick={() => handleSelectIcon(iconName, "lucide")}
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
|
||||
"hover:bg-muted flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
||||
selectedIcon === iconName && selectedIconType === "lucide"
|
||||
? "border-primary ring-2 ring-primary/30 bg-primary/5"
|
||||
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
<span className="truncate text-[10px] text-muted-foreground">{iconName}</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]">{iconName}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1301,7 +1319,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
onUpdateProperty("componentConfig.customIcons", next);
|
||||
if (selectedIcon === iconName) revertToDefaultIcon();
|
||||
}}
|
||||
className="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/80 absolute -top-1 -right-1 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -1314,9 +1332,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
type="button"
|
||||
onClick={() => handleSelectIcon(svgIcon.name, "svg")}
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors hover:bg-muted",
|
||||
"hover:bg-muted flex w-full flex-col items-center gap-1 rounded-md border p-2 transition-colors",
|
||||
selectedIcon === svgIcon.name && selectedIconType === "svg"
|
||||
? "border-primary ring-2 ring-primary/30 bg-primary/5"
|
||||
? "border-primary ring-primary/30 bg-primary/5 ring-2"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
|
|
@ -1324,7 +1342,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
className="flex h-6 w-6 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
||||
dangerouslySetInnerHTML={{ __html: svgIcon.svg }}
|
||||
/>
|
||||
<span className="truncate text-[10px] text-muted-foreground">{svgIcon.name}</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]">{svgIcon.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1333,7 +1351,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
onUpdateProperty("componentConfig.customSvgIcons", next);
|
||||
if (selectedIcon === svgIcon.name) revertToDefaultIcon();
|
||||
}}
|
||||
className="absolute -top-1 -right-1 rounded-full bg-destructive p-0.5 text-destructive-foreground hover:bg-destructive/80"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/80 absolute -top-1 -right-1 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -1387,7 +1405,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
>
|
||||
{Icon ? <Icon className="h-4 w-4" /> : <span className="h-4 w-4" />}
|
||||
{iconName}
|
||||
{customIcons.includes(iconName) && <Check className="ml-auto h-3 w-3 text-primary" />}
|
||||
{customIcons.includes(iconName) && <Check className="text-primary ml-auto h-3 w-3" />}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1430,10 +1448,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder={'<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>'}
|
||||
className="h-20 w-full rounded-md border bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="bg-background focus:ring-ring h-20 w-full rounded-md border px-2 py-1.5 text-xs focus:ring-2 focus:outline-none"
|
||||
/>
|
||||
{svgInput && (
|
||||
<div className="flex items-center justify-center rounded border bg-muted/50 p-2">
|
||||
<div className="bg-muted/50 flex items-center justify-center rounded border p-2">
|
||||
<span
|
||||
className="flex h-8 w-8 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
||||
dangerouslySetInnerHTML={{
|
||||
|
|
@ -1442,7 +1460,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{svgError && <p className="text-xs text-destructive">{svgError}</p>}
|
||||
{svgError && <p className="text-destructive text-xs">{svgError}</p>}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
|
|
@ -1490,9 +1508,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
onClick={() => handleIconSizePreset(preset)}
|
||||
className={cn(
|
||||
"flex-1 px-1 py-1 text-xs font-medium whitespace-nowrap transition-colors first:rounded-l-md last:rounded-r-md",
|
||||
iconSize === preset
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground",
|
||||
iconSize === preset ? "bg-primary text-primary-foreground" : "hover:bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{preset}
|
||||
|
|
@ -1551,7 +1567,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
setIconGap(val);
|
||||
onUpdateProperty("componentConfig.iconGap", val);
|
||||
}}
|
||||
className="h-1.5 flex-1 cursor-pointer accent-primary"
|
||||
className="accent-primary h-1.5 flex-1 cursor-pointer"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
|
|
@ -1565,7 +1581,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}}
|
||||
className="h-7 w-14 text-center text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">px</span>
|
||||
<span className="text-muted-foreground text-xs">px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1594,7 +1610,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -3150,6 +3165,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* 다중 테이블 엑셀 업로드: 설정 불필요 (버튼 클릭 시 화면 테이블에서 자동 감지) */}
|
||||
|
||||
{/* 바코드 스캔 액션 설정 */}
|
||||
{localInputs.actionType === "barcode_scan" && (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
|
|
@ -3758,7 +3775,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다
|
||||
레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로
|
||||
사용합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -4039,11 +4057,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
<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 "필드 선택 (비워두면 전체 데이터)";
|
||||
|
|
@ -4074,7 +4088,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", !config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0")} />
|
||||
<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) => (
|
||||
|
|
@ -4093,7 +4114,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name ? "opacity-100" : "opacity-0")} />
|
||||
<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>
|
||||
|
|
@ -4183,7 +4211,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.
|
||||
여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을
|
||||
자동 감지합니다.
|
||||
</p>
|
||||
|
||||
{!config.action?.dataTransfer?.targetTable ? (
|
||||
|
|
@ -4192,9 +4221,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</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>
|
||||
<p className="text-muted-foreground text-xs">매핑 그룹이 없습니다. 소스 테이블을 추가하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -4263,7 +4290,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<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
|
||||
? availableTables.find((t) => t.name === activeSourceTable)?.label ||
|
||||
activeSourceTable
|
||||
: "소스 테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
|
@ -4272,7 +4300,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
테이블을 찾을 수 없습니다
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
|
|
@ -4314,7 +4344,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
size="sm"
|
||||
className="h-5 text-[10px]"
|
||||
onClick={() => {
|
||||
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
|
||||
updateGroupField("mappingRules", [
|
||||
...activeRules,
|
||||
{ sourceField: "", targetField: "" },
|
||||
]);
|
||||
}}
|
||||
disabled={!activeSourceTable}
|
||||
>
|
||||
|
|
@ -4341,9 +4374,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||
<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
|
||||
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
|
||||
rule.sourceField
|
||||
: "소스 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
|
@ -4362,13 +4400,23 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const newRules = [...activeRules];
|
||||
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
|
||||
updateGroupField("mappingRules", newRules);
|
||||
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false }));
|
||||
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")} />
|
||||
<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>}
|
||||
{col.label !== col.name && (
|
||||
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
|
@ -4388,9 +4436,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||
<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
|
||||
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
|
||||
rule.targetField
|
||||
: "타겟 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
|
@ -4409,13 +4462,23 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const newRules = [...activeRules];
|
||||
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
|
||||
updateGroupField("mappingRules", newRules);
|
||||
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false }));
|
||||
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")} />
|
||||
<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>}
|
||||
{col.label !== col.name && (
|
||||
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
|
@ -4490,7 +4553,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<Select
|
||||
value={String(component.componentConfig?.action?.approvalDefinitionId || "")}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("componentConfig.action.approvalDefinitionId", value === "none" ? null : Number(value));
|
||||
onUpdateProperty(
|
||||
"componentConfig.action.approvalDefinitionId",
|
||||
value === "none" ? null : Number(value),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
|
|
@ -4541,9 +4607,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
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>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">현재 선택된 레코드의 PK 컬럼명</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -4717,8 +4781,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
</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">
|
||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||
</div>
|
||||
|
|
@ -4968,8 +5032,8 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
{/* 마스터 키 자동 생성 안내 */}
|
||||
{relationInfo && (
|
||||
<p className="text-muted-foreground border-t pt-2 text-xs">
|
||||
마스터 테이블의 <strong>{relationInfo.masterKeyColumn}</strong> 값은 테이블 타입 관리에서 설정된 채번 규칙으로 자동
|
||||
생성됩니다.
|
||||
마스터 테이블의 <strong>{relationInfo.masterKeyColumn}</strong> 값은 테이블 타입 관리에서 설정된 채번 규칙으로
|
||||
자동 생성됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -943,19 +943,31 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
|
||||
{/* 옵션 - 입력 필드에서는 항상 표시, 기타 컴포넌트는 속성이 정의된 경우만 표시 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(isInputField || widget.required !== undefined) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={widget.required === true || selectedComponent.componentConfig?.required === true}
|
||||
onCheckedChange={(checked) => {
|
||||
handleUpdate("required", checked);
|
||||
handleUpdate("componentConfig.required", checked);
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label className="text-xs">필수</Label>
|
||||
</div>
|
||||
)}
|
||||
{(isInputField || widget.required !== undefined) && (() => {
|
||||
const colName = widget.columnName || selectedComponent?.columnName;
|
||||
const colMeta = colName ? currentTable?.columns?.find(
|
||||
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase()
|
||||
) : null;
|
||||
const isNotNull = colMeta && ((colMeta as any).isNullable === "NO" || (colMeta as any).isNullable === "N" || (colMeta as any).is_nullable === "NO" || (colMeta as any).is_nullable === "N");
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true}
|
||||
onCheckedChange={(checked) => {
|
||||
if (isNotNull) return;
|
||||
handleUpdate("required", checked);
|
||||
handleUpdate("componentConfig.required", checked);
|
||||
}}
|
||||
disabled={!!isNotNull}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label className="text-xs">
|
||||
필수
|
||||
{isNotNull && <span className="text-muted-foreground ml-1">(NOT NULL)</span>}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{(isInputField || widget.readonly !== undefined) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* - 체크박스를 통한 다중 선택 및 일괄 삭제 지원
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
|
|
@ -291,6 +291,10 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
|
||||
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
|
||||
|
||||
// 추가 모달 input ref
|
||||
const addNameRef = useRef<HTMLInputElement>(null);
|
||||
const addDescRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
valueCode: "",
|
||||
|
|
@ -508,7 +512,15 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
const response = await createCategoryValue(input);
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 추가되었습니다");
|
||||
setIsAddModalOpen(false);
|
||||
// 폼 초기화 (모달은 닫지 않고 연속 입력)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
valueCode: "",
|
||||
valueLabel: "",
|
||||
description: "",
|
||||
color: "",
|
||||
}));
|
||||
setTimeout(() => addNameRef.current?.focus(), 50);
|
||||
// 기존 펼침 상태 유지하면서 데이터 새로고침
|
||||
await loadTree(true);
|
||||
// 부모 노드만 펼치기 (하위 추가 시)
|
||||
|
|
@ -746,9 +758,17 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
이름 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
ref={addNameRef}
|
||||
id="valueLabel"
|
||||
value={formData.valueLabel}
|
||||
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addDescRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
placeholder="카테고리 이름을 입력하세요"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
|
|
@ -759,9 +779,17 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
설명
|
||||
</Label>
|
||||
<Input
|
||||
ref={addDescRef}
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
placeholder="선택 사항"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
|
|
@ -784,7 +812,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
onClick={() => setIsAddModalOpen(false)}
|
||||
className="h-9 flex-1 text-sm sm:flex-none"
|
||||
>
|
||||
취소
|
||||
닫기
|
||||
</Button>
|
||||
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
추가
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ const badgeVariants = cva(
|
|||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -724,8 +724,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
}}
|
||||
className="text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
{label}{required && <span className="text-orange-500">*</span>}
|
||||
</Label>
|
||||
) : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -491,8 +491,7 @@ export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>(
|
|||
}}
|
||||
className="text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
{label}{required && <span className="text-orange-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="h-full w-full">
|
||||
|
|
|
|||
|
|
@ -619,45 +619,40 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
try {
|
||||
// 채번 규칙 ID 캐싱 (한 번만 조회)
|
||||
if (!numberingRuleIdRef.current) {
|
||||
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
||||
const columnsResponse = await getTableColumns(tableName);
|
||||
// table_name + column_name 기반으로 채번 규칙 조회
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const ruleResponse = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
|
||||
if (ruleResponse.data?.success && ruleResponse.data?.data?.ruleId) {
|
||||
numberingRuleIdRef.current = ruleResponse.data.data.ruleId;
|
||||
|
||||
if (!columnsResponse.success || !columnsResponse.data) {
|
||||
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = columnsResponse.data.columns || columnsResponse.data;
|
||||
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
|
||||
|
||||
if (!targetColumn) {
|
||||
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
|
||||
return;
|
||||
}
|
||||
|
||||
// detailSettings에서 numberingRuleId 추출
|
||||
if (targetColumn.detailSettings) {
|
||||
try {
|
||||
// 문자열이면 파싱, 객체면 그대로 사용
|
||||
const parsed = typeof targetColumn.detailSettings === "string"
|
||||
? JSON.parse(targetColumn.detailSettings)
|
||||
: targetColumn.detailSettings;
|
||||
numberingRuleIdRef.current = parsed.numberingRuleId || null;
|
||||
|
||||
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
|
||||
if (parsed.numberingRuleId && onFormDataChange && columnName) {
|
||||
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(`${columnName}_numberingRuleId`, ruleResponse.data.data.ruleId);
|
||||
}
|
||||
} catch {
|
||||
// JSON 파싱 실패
|
||||
}
|
||||
} catch {
|
||||
// by-column 조회 실패 시 detailSettings fallback
|
||||
try {
|
||||
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
||||
const columnsResponse = await getTableColumns(tableName);
|
||||
if (columnsResponse.success && columnsResponse.data) {
|
||||
const columns = columnsResponse.data.columns || columnsResponse.data;
|
||||
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
|
||||
if (targetColumn?.detailSettings) {
|
||||
const parsed = typeof targetColumn.detailSettings === "string"
|
||||
? JSON.parse(targetColumn.detailSettings)
|
||||
: targetColumn.detailSettings;
|
||||
numberingRuleIdRef.current = parsed.numberingRuleId || null;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const numberingRuleId = numberingRuleIdRef.current;
|
||||
|
||||
if (!numberingRuleId) {
|
||||
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName });
|
||||
console.warn("채번 규칙을 찾을 수 없습니다. 옵션설정 > 채번설정에서 규칙을 생성하세요.", { tableName, columnName });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -999,8 +994,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
}}
|
||||
className="text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
{actualLabel}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
{actualLabel}{required && <span className="text-orange-500">*</span>}
|
||||
</Label>
|
||||
) : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -840,8 +840,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>((props, ref) =>
|
|||
}}
|
||||
className="shrink-0 text-sm font-medium"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
{label}{required && <span className="text-orange-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1213,8 +1213,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
}}
|
||||
className="text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
{label}{required && <span className="text-orange-500">*</span>}
|
||||
</Label>
|
||||
) : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -365,6 +365,14 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
fetchEntityJoinColumns();
|
||||
}, [entityJoinTargetTable]);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<V2RepeaterConfig>) => {
|
||||
onChange({ ...config, ...updates });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
// Entity 조인 컬럼 토글 (추가/제거)
|
||||
const toggleEntityJoinColumn = useCallback(
|
||||
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
|
||||
|
|
@ -423,14 +431,6 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
[config.entityJoins],
|
||||
);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<V2RepeaterConfig>) => {
|
||||
onChange({ ...config, ...updates });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const updateDataSource = useCallback(
|
||||
(field: string, value: any) => {
|
||||
updateConfig({
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue