Compare commits
87 Commits
ksh-v2-wor
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
8c0489e954 | |
|
|
8827cf3af1 | |
|
|
2139364a3c | |
|
|
ee94de4709 | |
|
|
6d2cdc1782 | |
|
|
3982aabc24 | |
|
|
d56e46b17c | |
|
|
7ff76741af | |
|
|
5abce62d89 | |
|
|
d8067f1d94 | |
|
|
28ef7e1226 | |
|
|
43523a0bba | |
|
|
c0eab878a1 | |
|
|
6d1f8edc6c | |
|
|
9db69a83cd | |
|
|
4f8c31c893 | |
|
|
f565b2d119 | |
|
|
ef7a6e73fb | |
|
|
5ea40ddb01 | |
|
|
43707cb9a3 | |
|
|
52f25030a4 | |
|
|
202d678e8b | |
|
|
3841611af5 | |
|
|
109b22a99c | |
|
|
316ce30663 | |
|
|
7821bf47ef | |
|
|
123da4b0d5 | |
|
|
c98b2ccb43 | |
|
|
4d6783e508 | |
|
|
2b4b7819c5 | |
|
|
f6a02b5182 | |
|
|
13506912d9 | |
|
|
b4f4011603 | |
|
|
27558787b0 | |
|
|
1ee946d712 | |
|
|
db31b02180 | |
|
|
ee1760da2f | |
|
|
7ab05aea72 | |
|
|
ea6c5ac43c | |
|
|
ea0123d6cc | |
|
|
93eaf59966 | |
|
|
c56f434ff1 | |
|
|
536982dd71 | |
|
|
0e8c68a9ff | |
|
|
bfd97c9717 | |
|
|
9f4367c680 | |
|
|
573a27741d | |
|
|
7853aeede9 | |
|
|
8f3231d5a1 | |
|
|
6ceed2acd0 | |
|
|
a0cf9db6e8 | |
|
|
ec5a980c41 | |
|
|
2b324d083b | |
|
|
7984b34f1b | |
|
|
cfd49020a0 | |
|
|
35dfe5bd79 | |
|
|
dfc495d32b | |
|
|
52c6af472d | |
|
|
2647031ef7 | |
|
|
7989305963 | |
|
|
eb2bd8f10f | |
|
|
dca89a698f | |
|
|
aa020bfdd8 | |
|
|
eb471d087f | |
|
|
626652272d | |
|
|
83437e76dd | |
|
|
dc04bd162a | |
|
|
3fb8bef44f | |
|
|
a0e3147b47 | |
|
|
d04dc4c050 | |
|
|
7acdd852a5 | |
|
|
5fc88a18c4 | |
|
|
db018cb21b | |
|
|
989601dd70 | |
|
|
059e80bdba | |
|
|
d0b5b5c922 | |
|
|
e50b23c449 | |
|
|
af3e7252b9 | |
|
|
77e9989521 | |
|
|
23c555b4e5 | |
|
|
e797e7f2c2 | |
|
|
6e96530b41 | |
|
|
3db8a8a276 | |
|
|
b0ed7dd817 | |
|
|
51d767a5f2 | |
|
|
6d40c3ea1c | |
|
|
5976e96ed8 |
|
|
@ -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>) => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -108,46 +108,6 @@ export async function getUserMenus(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 메뉴 목록 조회
|
||||
* [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환
|
||||
*/
|
||||
export async function getPopMenus(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
const userType = req.user?.userType;
|
||||
|
||||
const result = await AdminService.getPopMenuList({
|
||||
userCompanyCode,
|
||||
userType,
|
||||
});
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: "POP 메뉴 목록 조회 성공",
|
||||
data: result,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("POP 메뉴 목록 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "POP_MENU_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보 조회
|
||||
*/
|
||||
|
|
@ -3603,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({
|
||||
|
|
@ -3635,17 +3602,28 @@ export async function getTableSchema(
|
|||
return;
|
||||
}
|
||||
|
||||
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
|
||||
const columnList = columns.map((col: any) => ({
|
||||
// 컬럼 정보를 간단한 형태로 변환 (회사별 제약조건 반영)
|
||||
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, // 라벨이 없으면 컬럼명 사용
|
||||
label: col.column_label || col.column_name,
|
||||
type: col.data_type,
|
||||
nullable: col.is_nullable === "YES",
|
||||
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}개 컬럼`);
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,9 @@ export class AuthController {
|
|||
|
||||
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
|
||||
|
||||
// 메뉴 조회를 위한 공통 파라미터
|
||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||
let firstMenuPath: string | null = null;
|
||||
try {
|
||||
const { AdminService } = await import("../services/adminService");
|
||||
const paramMap = {
|
||||
userId: loginResult.userInfo.userId,
|
||||
|
|
@ -59,15 +61,18 @@ export class AuthController {
|
|||
userLang: "ko",
|
||||
};
|
||||
|
||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||
let firstMenuPath: string | null = null;
|
||||
try {
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
|
||||
// 접근 가능한 첫 번째 메뉴 찾기
|
||||
// 조건:
|
||||
// 1. LEV (레벨)이 2 이상 (최상위 폴더 제외)
|
||||
// 2. MENU_URL이 있고 비어있지 않음
|
||||
// 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴
|
||||
const firstMenu = menuList.find((menu: any) => {
|
||||
const level = menu.lev || menu.level;
|
||||
const url = menu.menu_url || menu.url;
|
||||
|
||||
return level >= 2 && url && url.trim() !== "" && url !== "#";
|
||||
});
|
||||
|
||||
|
|
@ -81,30 +86,13 @@ export class AuthController {
|
|||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||
}
|
||||
|
||||
// POP 랜딩 경로 조회
|
||||
let popLandingPath: string | null = null;
|
||||
try {
|
||||
const popResult = await AdminService.getPopMenuList(paramMap);
|
||||
if (popResult.landingMenu?.menu_url) {
|
||||
popLandingPath = popResult.landingMenu.menu_url;
|
||||
} else if (popResult.childMenus.length === 1) {
|
||||
popLandingPath = popResult.childMenus[0].menu_url;
|
||||
} else if (popResult.childMenus.length > 1) {
|
||||
popLandingPath = "/pop";
|
||||
}
|
||||
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);
|
||||
} catch (popError) {
|
||||
logger.warn("POP 메뉴 조회 중 오류 (무시):", popError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "로그인 성공",
|
||||
data: {
|
||||
userInfo,
|
||||
token: loginResult.token,
|
||||
firstMenuPath,
|
||||
popLandingPath,
|
||||
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Router } from "express";
|
|||
import {
|
||||
getAdminMenus,
|
||||
getUserMenus,
|
||||
getPopMenus,
|
||||
getMenuInfo,
|
||||
saveMenu, // 메뉴 추가
|
||||
updateMenu, // 메뉴 수정
|
||||
|
|
@ -41,7 +40,6 @@ router.use(authenticateToken);
|
|||
// 메뉴 관련 API
|
||||
router.get("/menus", getAdminMenus);
|
||||
router.get("/user-menus", getUserMenus);
|
||||
router.get("/pop-menus", getPopMenus);
|
||||
router.get("/menus/:menuId", getMenuInfo);
|
||||
router.post("/menus", saveMenu); // 메뉴 추가
|
||||
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -17,7 +17,6 @@ interface AutoGenMappingInfo {
|
|||
numberingRuleId: string;
|
||||
targetColumn: string;
|
||||
showResultModal?: boolean;
|
||||
shareAcrossItems?: boolean;
|
||||
}
|
||||
|
||||
interface HiddenMappingInfo {
|
||||
|
|
@ -183,31 +182,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
||||
}
|
||||
|
||||
const allAutoGen = [
|
||||
...(fieldMapping?.autoGenMappings ?? []),
|
||||
...(cardMapping?.autoGenMappings ?? []),
|
||||
];
|
||||
|
||||
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
|
||||
const sharedCodes: Record<string, string> = {};
|
||||
for (const ag of allAutoGen) {
|
||||
if (!ag.shareAcrossItems) continue;
|
||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||
try {
|
||||
const code = await numberingRuleService.allocateCode(
|
||||
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
|
||||
);
|
||||
sharedCodes[ag.targetColumn] = code;
|
||||
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
|
||||
logger.info("[pop/execute-action] 일괄 채번 완료", {
|
||||
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const columns: string[] = ["company_code"];
|
||||
const values: unknown[] = [companyCode];
|
||||
|
|
@ -251,15 +225,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
values.push(value);
|
||||
}
|
||||
|
||||
const allAutoGen = [
|
||||
...(fieldMapping?.autoGenMappings ?? []),
|
||||
...(cardMapping?.autoGenMappings ?? []),
|
||||
];
|
||||
for (const ag of allAutoGen) {
|
||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||
|
||||
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
|
||||
columns.push(`"${ag.targetColumn}"`);
|
||||
values.push(sharedCodes[ag.targetColumn]);
|
||||
} else if (!ag.shareAcrossItems) {
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.allocateCode(
|
||||
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
||||
|
|
@ -271,7 +244,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
|
@ -476,31 +448,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
||||
}
|
||||
|
||||
const allAutoGen = [
|
||||
...(fieldMapping?.autoGenMappings ?? []),
|
||||
...(cardMapping?.autoGenMappings ?? []),
|
||||
];
|
||||
|
||||
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
|
||||
const sharedCodes: Record<string, string> = {};
|
||||
for (const ag of allAutoGen) {
|
||||
if (!ag.shareAcrossItems) continue;
|
||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||
try {
|
||||
const code = await numberingRuleService.allocateCode(
|
||||
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
|
||||
);
|
||||
sharedCodes[ag.targetColumn] = code;
|
||||
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
|
||||
logger.info("[pop/execute-action] 일괄 채번 완료", {
|
||||
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const columns: string[] = ["company_code"];
|
||||
const values: unknown[] = [companyCode];
|
||||
|
|
@ -520,6 +467,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
}
|
||||
}
|
||||
|
||||
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
|
||||
const allHidden = [
|
||||
...(fieldMapping?.hiddenMappings ?? []),
|
||||
...(cardMapping?.hiddenMappings ?? []),
|
||||
|
|
@ -546,28 +494,34 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
|||
values.push(value);
|
||||
}
|
||||
|
||||
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
|
||||
const allAutoGen = [
|
||||
...(fieldMapping?.autoGenMappings ?? []),
|
||||
...(cardMapping?.autoGenMappings ?? []),
|
||||
];
|
||||
for (const ag of allAutoGen) {
|
||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||
|
||||
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
|
||||
columns.push(`"${ag.targetColumn}"`);
|
||||
values.push(sharedCodes[ag.targetColumn]);
|
||||
} else if (!ag.shareAcrossItems) {
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.allocateCode(
|
||||
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
||||
ag.numberingRuleId,
|
||||
companyCode,
|
||||
{ ...fieldValues, ...item },
|
||||
);
|
||||
columns.push(`"${ag.targetColumn}"`);
|
||||
values.push(generatedCode);
|
||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||
logger.info("[pop/execute-action] 채번 완료", {
|
||||
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode,
|
||||
ruleId: ag.numberingRuleId,
|
||||
targetColumn: ag.targetColumn,
|
||||
generatedCode,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||
}
|
||||
logger.error("[pop/execute-action] 채번 실패", {
|
||||
ruleId: ag.numberingRuleId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -621,74 +621,6 @@ export class AdminService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 메뉴 목록 조회
|
||||
* menu_name_kor에 'POP'이 포함되거나 menu_desc에 [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환
|
||||
* [POP_LANDING] 태그가 있는 하위 메뉴를 landingMenu로 별도 반환
|
||||
*/
|
||||
static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> {
|
||||
try {
|
||||
const { userCompanyCode, userType } = paramMap;
|
||||
logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType });
|
||||
|
||||
let queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
let companyFilter = "";
|
||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||
companyFilter = `AND COMPANY_CODE = '*'`;
|
||||
} else {
|
||||
companyFilter = `AND COMPANY_CODE = $${paramIndex}`;
|
||||
queryParams.push(userCompanyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// POP L1 메뉴 조회
|
||||
const parentMenus = await query<any>(
|
||||
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
|
||||
FROM MENU_INFO
|
||||
WHERE PARENT_OBJ_ID = 0
|
||||
AND MENU_TYPE = 1
|
||||
AND (
|
||||
MENU_DESC LIKE '%[POP]%'
|
||||
OR UPPER(MENU_NAME_KOR) LIKE '%POP%'
|
||||
)
|
||||
${companyFilter}
|
||||
ORDER BY SEQ
|
||||
LIMIT 1`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
if (parentMenus.length === 0) {
|
||||
logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)");
|
||||
return { parentMenu: null, childMenus: [], landingMenu: null };
|
||||
}
|
||||
|
||||
const parentMenu = parentMenus[0];
|
||||
|
||||
// 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링)
|
||||
const childMenus = await query<any>(
|
||||
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
|
||||
FROM MENU_INFO
|
||||
WHERE PARENT_OBJ_ID = $1
|
||||
AND STATUS = 'active'
|
||||
AND COMPANY_CODE = $2
|
||||
ORDER BY SEQ`,
|
||||
[parentMenu.objid, parentMenu.company_code]
|
||||
);
|
||||
|
||||
// [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정
|
||||
const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null;
|
||||
|
||||
logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`);
|
||||
|
||||
return { parentMenu, childMenus, landingMenu };
|
||||
} catch (error) {
|
||||
logger.error("AdminService.getPopMenuList 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,34 +502,60 @@ 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") {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
|
|
@ -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
|
||||
SELECT DISTINCT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders2})
|
||||
AND is_active = true
|
||||
) combined
|
||||
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
|
||||
SELECT DISTINCT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders2})
|
||||
AND is_active = true
|
||||
AND (company_code = $${companyIdx2} OR company_code = '*')
|
||||
) combined
|
||||
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
|
||||
? 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
|
||||
? 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
|
||||
? 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;
|
||||
|
|
@ -4468,30 +4500,26 @@ export class TableManagementService {
|
|||
|
||||
const rawColumns = await query<any>(
|
||||
`SELECT
|
||||
c.column_name as "columnName",
|
||||
c.column_name as "displayName",
|
||||
c.data_type as "dataType",
|
||||
c.udt_name as "dbType",
|
||||
c.is_nullable as "isNullable",
|
||||
c.column_default as "defaultValue",
|
||||
c.character_maximum_length as "maxLength",
|
||||
c.numeric_precision as "numericPrecision",
|
||||
c.numeric_scale as "numericScale",
|
||||
column_name as "columnName",
|
||||
column_name as "displayName",
|
||||
data_type as "dataType",
|
||||
udt_name as "dbType",
|
||||
is_nullable as "isNullable",
|
||||
column_default as "defaultValue",
|
||||
character_maximum_length as "maxLength",
|
||||
numeric_precision as "numericPrecision",
|
||||
numeric_scale as "numericScale",
|
||||
CASE
|
||||
WHEN c.column_name IN (
|
||||
SELECT kcu.column_name FROM information_schema.key_column_usage kcu
|
||||
WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey'
|
||||
WHEN column_name IN (
|
||||
SELECT column_name FROM information_schema.key_column_usage
|
||||
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
|
||||
) THEN true
|
||||
ELSE false
|
||||
END as "isPrimaryKey",
|
||||
col_description(
|
||||
(SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')),
|
||||
c.ordinal_position
|
||||
) as "columnComment"
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_name = $1
|
||||
AND c.table_schema = 'public'
|
||||
ORDER BY c.ordinal_position`,
|
||||
END as "isPrimaryKey"
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
|
|
@ -4501,10 +4529,10 @@ export class TableManagementService {
|
|||
displayName: col.displayName,
|
||||
dataType: col.dataType,
|
||||
dbType: col.dbType,
|
||||
webType: "text",
|
||||
webType: "text", // 기본값
|
||||
inputType: "direct",
|
||||
detailSettings: "{}",
|
||||
description: col.columnComment || "",
|
||||
description: "", // 필수 필드 추가
|
||||
isNullable: col.isNullable,
|
||||
isPrimaryKey: col.isPrimaryKey,
|
||||
defaultValue: col.defaultValue,
|
||||
|
|
@ -4515,7 +4543,6 @@ export class TableManagementService {
|
|||
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
|
||||
displayOrder: 0,
|
||||
isVisible: true,
|
||||
columnComment: col.columnComment || "",
|
||||
}));
|
||||
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -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,340 @@
|
|||
# [계획서] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
|
||||
|
||||
> 관련 문서: [맥락노트](./BIC[맥락]-버튼-아이콘화.md) | [체크리스트](./BIC[체크]-버튼-아이콘화.md)
|
||||
|
||||
## 개요
|
||||
|
||||
화면 디자이너에서 버튼을 텍스트 모드(현행), 아이콘 모드, 아이콘+텍스트 모드 중 선택할 수 있도록 확장한다.
|
||||
아이콘 모드 선택 시 버튼 액션에 맞는 아이콘 후보군이 제시되고, 관리자가 원하는 아이콘을 선택한다.
|
||||
아이콘 크기 비율(버튼 높이 대비 4단계 프리셋), 아이콘 색상, 텍스트 위치(4방향), 아이콘-텍스트 간격 설정을 제공한다.
|
||||
관리자가 lucide 검색 또는 외부 SVG 붙여넣기로 커스텀 아이콘을 추가/삭제할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
- 버튼은 항상 **텍스트 모드**로만 표시됨
|
||||
- `ButtonConfigPanel.tsx`에서 "버튼 텍스트" 입력 → 실제 화면에서 해당 텍스트가 버튼에 표시
|
||||
- 아이콘 표시 기능 없음
|
||||
|
||||
### 현재 코드 위치
|
||||
|
||||
| 구분 | 파일 | 설명 |
|
||||
|------|------|------|
|
||||
| 설정 패널 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 버튼 텍스트, 액션 설정 (784~854행) |
|
||||
| 뷰어 렌더링 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | 실제 버튼 렌더링 (961~983행) |
|
||||
| 뷰어 렌더링 | `frontend/components/screen/InteractiveScreenViewer.tsx` | 실제 버튼 렌더링 (2041~2059행) |
|
||||
| 위젯 렌더링 | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
|
||||
| 최적화 컴포넌트 | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 컴포넌트 (643~674행) |
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. 표시 모드 선택 (라디오 그룹)
|
||||
|
||||
ButtonConfigPanel에 "버튼 텍스트" 입력 위에 표시 모드 선택 UI 추가:
|
||||
|
||||
- **텍스트 모드** (기본값, 현행 유지): 버튼에 텍스트만 표시
|
||||
- **아이콘 모드**: 버튼에 아이콘만 표시
|
||||
- **아이콘+텍스트 모드**: 버튼에 아이콘과 텍스트를 함께 표시
|
||||
|
||||
```
|
||||
[ 텍스트 | 아이콘 | 아이콘+텍스트 ] ← 라디오 그룹 (토글 형태)
|
||||
```
|
||||
|
||||
### 2. 텍스트 모드 선택 시
|
||||
|
||||
- 현재와 동일하게 "버튼 텍스트" 입력 필드 표시
|
||||
- 변경 사항 없음
|
||||
|
||||
### 2-1. 아이콘+텍스트 모드 선택 시
|
||||
|
||||
- 아이콘 선택 UI (3장과 동일) + 버튼 텍스트 입력 필드 **둘 다 표시**
|
||||
- 렌더링: 텍스트 위치에 따라 아이콘과 텍스트 배치 방향이 달라짐
|
||||
- 텍스트 위치 4방향: 오른쪽(기본), 왼쪽, 위쪽, 아래쪽
|
||||
- 예시: `[ ✓ 저장 ]` (오른쪽), `[ 저장 ✓ ]` (왼쪽), 세로 배치 (위쪽/아래쪽)
|
||||
- 아이콘과 텍스트 사이 간격: 기본 6px, 관리자가 0~무제한 조절 가능 (슬라이더 0~32px + 직접 입력)
|
||||
|
||||
### 3. 아이콘 모드 선택 시
|
||||
|
||||
#### 3-1. 버튼 액션별 추천 아이콘 목록
|
||||
|
||||
버튼 액션(`action.type`)에 따라 해당 액션에 어울리는 아이콘 후보군을 그리드로 표시:
|
||||
|
||||
| 버튼 액션 | 값 | 추천 아이콘 (lucide-react) |
|
||||
|-----------|-----|---------------------------|
|
||||
| 저장 | `save` | Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck |
|
||||
| 삭제 | `delete` | Trash2, Trash, XCircle, X, Eraser, CircleX |
|
||||
| 편집 | `edit` | Pencil, PenLine, Edit, SquarePen, FilePen, PenTool |
|
||||
| 페이지 이동 | `navigate` | ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link |
|
||||
| 모달 열기 | `modal` | Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen |
|
||||
| 데이터 전달 | `transferData` | SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2 |
|
||||
| 엑셀 다운로드 | `excel_download` | Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput |
|
||||
| 엑셀 업로드 | `excel_upload` | Upload, FileUp, FileSpreadsheet, Sheet, ImportIcon, FileInput |
|
||||
| 즉시 저장 | `quickInsert` | Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus |
|
||||
| 제어 흐름 | `control` | Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Cog |
|
||||
| 바코드 스캔 | `barcode_scan` | ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus |
|
||||
| 운행알림 및 종료 | `operation_control` | Truck, Car, MapPin, Navigation2, Route, Bell |
|
||||
| 이벤트 발송 | `event` | Send, Bell, Radio, Megaphone, Podcast, BellRing |
|
||||
| 복사 | `copy` | Copy, ClipboardCopy, Files, CopyPlus, Duplicate, ClipboardList |
|
||||
|
||||
**적절한 아이콘이 없는 액션 (숨김 처리된 deprecated 액션들):**
|
||||
|
||||
| 버튼 액션 | 값 | 안내 문구 |
|
||||
|-----------|-----|----------|
|
||||
| 연관 데이터 버튼 모달 열기 | `openRelatedModal` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| (deprecated) 데이터 전달 + 모달 | `openModalWithData` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| 테이블 이력 보기 | `view_table_history` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| 코드 병합 | `code_merge` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| 공차등록 | `empty_vehicle` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
|
||||
> 안내 문구: "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요."
|
||||
> 안내 문구 아래에 커스텀 아이콘 목록 + lucide 검색/SVG 붙여넣기 버튼이 표시됨
|
||||
|
||||
#### 3-2. 아이콘 선택 UI
|
||||
|
||||
- 액션별 추천 아이콘을 4~6열 그리드로 표시
|
||||
- 각 아이콘은 32x32 크기, 호버 시 하이라이트, 선택 시 ring 표시
|
||||
- 아이콘 아래에 이름 표시 (`text-[10px]`)
|
||||
- 관리자가 추가한 커스텀 아이콘이 있으면 "커스텀 아이콘" 구분선 아래 함께 표시
|
||||
|
||||
#### 3-3. 아이콘 크기 비율 설정
|
||||
|
||||
버튼 높이 대비 비율로 아이콘 크기를 설정 (정사각형 유지):
|
||||
|
||||
**프리셋 (ToggleGroup, 4단계):**
|
||||
|
||||
| 이름 | 버튼 높이 대비 | 설명 |
|
||||
|------|--------------|------|
|
||||
| 작게 | 40% | 컴팩트한 아이콘 |
|
||||
| 보통 | 55% | 기본값, 대부분의 버튼에 적합 |
|
||||
| 크게 | 70% | 존재감 있는 크기 |
|
||||
| 매우 크게 | 85% | 아이콘 강조, 버튼에 꽉 차는 느낌 |
|
||||
|
||||
- px 직접 입력은 제거 (비율 기반이므로 버튼 크기 변경 시 아이콘도 자동 비례)
|
||||
- 저장: `icon.size`에 프리셋 문자열(`"보통"`) 저장
|
||||
- 렌더링: `height: N%` + `aspect-ratio: 1/1`로 정사각형 유지
|
||||
|
||||
#### 3-4. 아이콘 색상 설정
|
||||
|
||||
아이콘 크기 아래에 아이콘 전용 색상 설정:
|
||||
|
||||
- **컬러 피커**: 기존 버튼 색상 설정과 동일한 UI 사용
|
||||
- **기본값**: 미설정 (= `textColor` 상속, 기존 동작과 동일)
|
||||
- **설정 시**: lucide 아이콘은 지정한 색상으로 덮어쓰기
|
||||
- **외부 SVG**: 고유 색상이 하드코딩된 SVG는 이 설정의 영향을 받지 않음 (원본 유지)
|
||||
- **초기화 버튼**: "텍스트 색상과 동일" 버튼으로 별도 색상 해제 가능
|
||||
|
||||
| 상황 | iconColor 설정 | 결과 |
|
||||
|------|---------------|------|
|
||||
| lucide 아이콘, iconColor 미설정 | 없음 | textColor 상속 (기존 동작) |
|
||||
| lucide 아이콘, iconColor 설정 | `#22c55e` | 초록색 아이콘 |
|
||||
| 외부 SVG (고유 색상), iconColor 설정 | `#22c55e` | SVG 원본 색상 유지 (무시) |
|
||||
| 외부 SVG (currentColor), iconColor 설정 | `#22c55e` | 초록색 아이콘 |
|
||||
|
||||
#### 3-5. 텍스트 위치 설정 (아이콘+텍스트 모드 전용)
|
||||
|
||||
아이콘 대비 텍스트의 배치 방향을 4방향으로 설정:
|
||||
|
||||
| 위치 | 값 | 레이아웃 | 설명 |
|
||||
|------|-----|---------|------|
|
||||
| 왼쪽 | `left` | `텍스트 ← 아이콘` | 텍스트가 아이콘 왼쪽 (가로) |
|
||||
| 오른쪽 | `right` | `아이콘 → 텍스트` | 기본값, 아이콘 뒤에 텍스트 (가로) |
|
||||
| 위쪽 | `top` | 텍스트 위, 아이콘 아래 | 세로 배치 |
|
||||
| 아래쪽 | `bottom` | 아이콘 위, 텍스트 아래 | 세로 배치 |
|
||||
|
||||
- 기본값: `"right"` (아이콘 오른쪽에 텍스트)
|
||||
- 저장: `componentConfig.iconTextPosition`
|
||||
- 아이콘 모드에서는 이 옵션이 숨겨짐 (텍스트가 없으므로 불필요)
|
||||
|
||||
#### 3-6. 아이콘-텍스트 간격 설정 (아이콘+텍스트 모드 전용)
|
||||
|
||||
아이콘+텍스트 모드에서 아이콘과 텍스트 사이 간격을 조절:
|
||||
|
||||
- **슬라이더**: 0~32px 범위 시각적 조절
|
||||
- **직접 입력**: px 수치 직접 입력 (최솟값 0, 최댓값 제한 없음)
|
||||
- **기본값**: 6px
|
||||
- 아이콘 모드에서는 이 옵션이 숨겨짐 (텍스트가 없으므로 불필요)
|
||||
|
||||
#### 3-7. 아이콘 모드 레이아웃 안내
|
||||
|
||||
아이콘만 표시하면 텍스트보다 좁은 공간으로 충분하므로 안내 문구 표시:
|
||||
|
||||
```
|
||||
ℹ 아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다.
|
||||
```
|
||||
|
||||
- `bg-blue-50 dark:bg-blue-950/20` 배경의 안내 박스
|
||||
- 아이콘 모드(`"icon"`)에서만 표시, 아이콘+텍스트 모드에서는 숨김
|
||||
|
||||
#### 3-8. 디폴트 아이콘 자동 부여
|
||||
|
||||
아이콘/아이콘+텍스트 모드 전환 시 아이콘이 미선택 상태이면 **디폴트 아이콘을 자동으로 부여**한다.
|
||||
|
||||
| 상황 | 디폴트 아이콘 |
|
||||
|------|-------------|
|
||||
| 추천 아이콘이 있는 액션 (save, delete 등) | 해당 액션의 **첫 번째 추천 아이콘** (예: save → Check) |
|
||||
| 추천 아이콘이 없는 액션 (deprecated 등) | 범용 폴백 아이콘: `SquareMousePointer` |
|
||||
|
||||
**커스텀 아이콘 삭제 시:**
|
||||
- 현재 선택된 커스텀 아이콘을 삭제하면 **디폴트 아이콘으로 자동 복귀** (텍스트 모드로 빠지지 않음)
|
||||
- 아이콘 모드를 유지한 채 디폴트 아이콘이 캔버스에 즉시 반영됨
|
||||
|
||||
#### 3-9. 커스텀 아이콘 추가/삭제
|
||||
|
||||
**방법 1: lucide 아이콘 검색으로 추가**
|
||||
- "아이콘 추가" 버튼 클릭 시 lucide 아이콘 전체 검색 가능한 모달/팝오버 표시
|
||||
- 검색 입력 → 아이콘 이름으로 필터링 → 선택하면 커스텀 목록에 추가
|
||||
|
||||
**방법 2: 외부 SVG 붙여넣기로 추가**
|
||||
- "SVG 붙여넣기" 버튼 클릭 시 텍스트 입력 영역(textarea) 표시
|
||||
- 외부에서 복사한 SVG 코드를 붙여넣기 → 미리보기로 확인 → "추가" 버튼으로 등록
|
||||
- SVG 유효성 검사: `<svg` 태그가 포함된 올바른 SVG인지 확인, 아니면 에러 메시지
|
||||
- 추가 시 관리자가 아이콘 이름을 직접 입력 (목록에서 구분용)
|
||||
- 저장 형태: SVG 문자열을 `customSvgIcons` 배열에 `{ name, svg }` 객체로 저장
|
||||
|
||||
**공통 규칙:**
|
||||
- 추가된 커스텀 아이콘(lucide/SVG 모두)은 **모든 버튼 액션의 아이콘 후보에 공통으로 노출**
|
||||
- 커스텀 아이콘에 X 버튼으로 삭제 가능
|
||||
|
||||
---
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
### componentConfig 확장
|
||||
|
||||
```typescript
|
||||
interface ButtonComponentConfig {
|
||||
text: string; // 기존: 버튼 텍스트
|
||||
displayMode: "text" | "icon" | "icon-text"; // 신규: 표시 모드 (기본값: "text")
|
||||
icon?: {
|
||||
name: string; // lucide 아이콘 이름 또는 커스텀 SVG 아이콘 이름
|
||||
type: "lucide" | "svg"; // 아이콘 출처 구분 (기본값: "lucide")
|
||||
size: "작게" | "보통" | "크게" | "매우 크게"; // 버튼 높이 대비 비율 프리셋 (기본값: "보통")
|
||||
color?: string; // 아이콘 색상 (미설정 시 textColor 상속)
|
||||
};
|
||||
iconGap?: number; // 아이콘-텍스트 간격 px (기본값: 6, 아이콘+텍스트 모드 전용)
|
||||
iconTextPosition?: "right" | "left" | "top" | "bottom"; // 텍스트 위치 (기본값: "right", 아이콘+텍스트 모드 전용)
|
||||
customIcons?: string[]; // 관리자가 추가한 lucide 커스텀 아이콘 이름 목록
|
||||
customSvgIcons?: Array<{ // 관리자가 붙여넣기한 외부 SVG 아이콘 목록
|
||||
name: string; // 관리자가 지정한 아이콘 이름
|
||||
svg: string; // SVG 문자열 원본
|
||||
}>;
|
||||
action: {
|
||||
type: string; // 기존: 버튼 액션 타입
|
||||
// ...기존 action 속성들 유지
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 저장 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "저장",
|
||||
"displayMode": "icon",
|
||||
"icon": {
|
||||
"name": "Check",
|
||||
"type": "lucide",
|
||||
"size": "보통",
|
||||
"color": "#22c55e"
|
||||
},
|
||||
"customIcons": ["Rocket", "Star"],
|
||||
"customSvgIcons": [
|
||||
{
|
||||
"name": "회사로고",
|
||||
"svg": "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>...</svg>"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"type": "save"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 시각적 동작 예시
|
||||
|
||||
### ButtonConfigPanel (디자이너 편집 모드)
|
||||
|
||||
```
|
||||
표시 모드: [ 텍스트 | (아이콘) | 아이콘+텍스트 ] ← 아이콘 선택됨
|
||||
|
||||
아이콘 선택:
|
||||
┌──────────────────────────────────┐
|
||||
│ 추천 아이콘 (저장) │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
|
||||
│ │ ✓ │ │ 💾 │ │ ✓○ │ │ ○✓ │ │
|
||||
│ │Check│ │Save│ │Chk○│ │○Chk│ │
|
||||
│ └────┘ └────┘ └────┘ └────┘ │
|
||||
│ ┌────┐ ┌────┐ │
|
||||
│ │📄✓│ │🛡✓│ │
|
||||
│ │FChk│ │ShCk│ │
|
||||
│ └────┘ └────┘ │
|
||||
│ │
|
||||
│ ── 커스텀 아이콘 ── │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ │
|
||||
│ │ 🚀 │ │ ⭐ │ │[로고]│ │
|
||||
│ │Rckt │ │Star│ │회사 │ │
|
||||
│ │ ✕ │ │ ✕│ │ ✕ │ │
|
||||
│ └────┘ └────┘ └────┘ │
|
||||
│ [+ lucide 검색] [+ SVG 붙여넣기]│
|
||||
└──────────────────────────────────┘
|
||||
|
||||
아이콘 크기 비율: [ 작게 | (보통) | 크게 | 매우 크게 ]
|
||||
텍스트 위치: [ 왼쪽 | (오른쪽) | 위쪽 | 아래쪽 ] ← 아이콘+텍스트 모드에서만 표시
|
||||
아이콘-텍스트 간격: [━━━━━○━━] [6] px ← 아이콘+텍스트 모드에서만 표시
|
||||
아이콘 색상: [■ #22c55e] [텍스트 색상과 동일]
|
||||
|
||||
ℹ 아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다.
|
||||
```
|
||||
|
||||
### 실제 화면 렌더링
|
||||
|
||||
| 모드 | 표시 |
|
||||
|------|------|
|
||||
| 텍스트 모드 | `[ 저장 ]` |
|
||||
| 아이콘 모드 (보통, 55%) | `[ ✓ ]` |
|
||||
| 아이콘 모드 (매우 크게, 85%) | `[ ✓ ]` |
|
||||
| 아이콘+텍스트 (텍스트 오른쪽) | `[ ✓ 저장 ]` (간격 6px) |
|
||||
| 아이콘+텍스트 (텍스트 왼쪽) | `[ 저장 ✓ ]` |
|
||||
| 아이콘+텍스트 (텍스트 아래쪽) | 아이콘 위, 텍스트 아래 (세로) |
|
||||
| 아이콘+텍스트 (색상 분리) | `[ 초록✓ 검정저장 ]` |
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상
|
||||
|
||||
### 수정 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `ButtonConfigPanel.tsx` | 표시 모드 3종 라디오, 아이콘 그리드, 크기, 색상, 간격 설정, 레이아웃 안내, 커스텀 아이콘 UI |
|
||||
| `InteractiveScreenViewerDynamic.tsx` | `displayMode` 3종 분기 → 아이콘/아이콘+텍스트/텍스트 렌더링 |
|
||||
| `InteractiveScreenViewer.tsx` | 동일 분기 추가 |
|
||||
| `ButtonWidget.tsx` | 동일 분기 추가 |
|
||||
| `OptimizedButtonComponent.tsx` | 동일 분기 추가 |
|
||||
| `ScreenDesigner.tsx` | 입력 필드 포커스 시 키보드 단축키 기본 동작 허용 (Ctrl+A/C/V/Z) |
|
||||
| `RealtimePreviewDynamic.tsx` | 버튼 컴포넌트 position wrapper에서 border 속성 분리 (이중 테두리 방지) |
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- 기본값은 `"text"` 모드 → 기존 모든 버튼은 변경 없이 동작
|
||||
- `displayMode`가 없거나 `"text"`이면 현행 텍스트 렌더링 유지
|
||||
- 아이콘/아이콘+텍스트 모드 전환 시 아이콘 미선택이면 **디폴트 아이콘 자동 부여** (빈 상태 방지)
|
||||
- 커스텀 아이콘 삭제 시 텍스트 모드로 빠지지 않고 **디폴트 아이콘으로 자동 복귀**
|
||||
- 아이콘 모드에서도 `text` 값은 유지 (접근성 aria-label로 활용)
|
||||
- 기본 아이콘은 lucide-react 사용 (프로젝트 일관성)
|
||||
- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능
|
||||
- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장
|
||||
- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
# [맥락노트] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
|
||||
|
||||
> 관련 문서: [계획서](./BIC[계획]-버튼-아이콘화.md) | [체크리스트](./BIC[체크]-버튼-아이콘화.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 현재 모든 버튼은 텍스트로만 표시 → 버튼 영역이 넓어야 하고, 모바일/태블릿에서 공간 효율이 낮음
|
||||
- "저장", "삭제", "추가" 같은 자주 쓰는 버튼은 아이콘만으로 충분히 인식 가능
|
||||
- 관리자가 화면 레이아웃을 더 컴팩트하게 구성할 수 있도록 선택권 제공
|
||||
- 단, "출하 계획" 같이 아이콘화가 어려운 특수 버튼이 존재하므로 텍스트 모드도 반드시 유지
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 표시 모드는 3종 라디오 그룹(토글 형태)으로 구현
|
||||
|
||||
- **결정**: `ToggleGroup` 형태의 세 개 옵션 (텍스트 / 아이콘 / 아이콘+텍스트)
|
||||
- **근거**: 세 모드는 상호 배타적. 아이콘+텍스트 병합 모드가 있어야 `[ + 추가 ]`, `[ 💾 저장 ]` 같은 실무 패턴을 지원. 아이콘만으로 의미 전달이 부족한 경우 텍스트를 병기하면 사용자 인식 속도가 빨라짐
|
||||
- **대안 검토**: Switch(토글) → 기각 ("무엇이 켜지는지" 직관적이지 않음, 3종 불가)
|
||||
|
||||
### 2. 기본값은 텍스트 모드
|
||||
|
||||
- **결정**: `displayMode` 기본값 = `"text"`
|
||||
- **근거**: 기존 모든 버튼은 텍스트로 동작 중. 아이콘 모드는 명시적으로 선택해야만 적용되어야 하위 호환성이 보장됨
|
||||
- **중요**: `displayMode`가 `undefined`이거나 `"text"`이면 현행 동작 그대로 유지
|
||||
|
||||
### 3. 아이콘은 버튼 액션(action.type)에 연동
|
||||
|
||||
- **결정**: 버튼 액션을 변경하면 해당 액션에 맞는 추천 아이콘 목록이 자동으로 갱신됨
|
||||
- **근거**: 관리자가 "저장" 아이콘을 고른 뒤 액션을 "삭제"로 바꾸면 혼란 발생. 액션별로 적절한 아이콘 후보를 보여주는 것이 자연스러움
|
||||
- **주의**: 액션 변경 시 이전에 선택한 아이콘이 새 액션의 추천 목록에 없으면 선택 초기화
|
||||
|
||||
### 4. 액션별 아이콘은 6개씩 제공, 적절한 아이콘이 없으면 안내 문구
|
||||
|
||||
- **결정**: 활성 액션 14개 각각에 6개의 lucide-react 아이콘 후보 제공
|
||||
- **근거**: 너무 적으면 선택지 부족, 너무 많으면 선택 피로. 6개가 2행 그리드로 깔끔하게 표시됨
|
||||
- **deprecated/숨김 액션**: UI에서 숨김 처리된 액션은 추천 아이콘 없이 안내 문구만 표시
|
||||
|
||||
### 5. 커스텀 아이콘 추가는 2가지 방법 제공
|
||||
|
||||
- **결정**: (1) lucide 아이콘 검색 + (2) 외부 SVG 붙여넣기 두 가지 경로 제공
|
||||
- **근거**: lucide 내장 아이콘만으로는 부족한 경우 존재 (회사 로고, 업종별 특수 아이콘 등). 외부에서 가져온 SVG를 직접 붙여넣기로 등록할 수 있어야 실무 유연성 확보
|
||||
- **lucide 추가**: "lucide 검색" 버튼 → 팝오버에서 검색 → 선택 → `customIcons` 배열에 이름 추가
|
||||
- **SVG 추가**: "SVG 붙여넣기" 버튼 → textarea에 SVG 코드 붙여넣기 → 미리보기 확인 → 이름 입력 → `customSvgIcons` 배열에 `{ name, svg }` 저장
|
||||
- **SVG 유효성**: `<svg` 태그 포함 여부로 기본 검증, XSS 방지를 위해 DOMPurify로 정화 후 저장
|
||||
- **범위**: 모든 커스텀 아이콘은 **해당 버튼 컴포넌트에 저장** (lucide: `customIcons`, SVG: `customSvgIcons`)
|
||||
- **노출**: 커스텀 아이콘(lucide/SVG 모두)은 어떤 버튼 액션에서도 추천 아이콘 아래에 함께 노출됨
|
||||
- **삭제**: 커스텀 아이콘 위에 X 버튼으로 개별 삭제 가능
|
||||
|
||||
### 5-1. 외부 SVG 붙여넣기의 보안 고려
|
||||
|
||||
- **결정**: SVG 문자열을 DOMPurify로 정화(sanitize)한 뒤 저장
|
||||
- **근거**: SVG에 `<script>`, `onload` 같은 악성 코드가 포함될 수 있으므로 XSS 방지 필수
|
||||
- **렌더링**: 정화된 SVG를 `dangerouslySetInnerHTML`로 렌더링 (정화 후이므로 안전)
|
||||
- **대안 검토**: SVG를 이미지 파일로 업로드 → 기각 (관리자 입장에서 복사-붙여넣기가 훨씬 간편)
|
||||
|
||||
### 6. 아이콘 색상은 별도 설정, 기본값은 textColor 상속
|
||||
|
||||
- **결정**: `icon.color` 옵션 추가. 미설정 시 `textColor`를 상속, 설정하면 아이콘만 해당 색상 적용
|
||||
- **근거**: 아이콘+텍스트 모드에서 `[ 초록✓ 검정저장 ]` 같이 아이콘과 텍스트 색을 분리하고 싶은 경우 존재. 삭제 버튼에 빨간 아이콘 + 흰 텍스트 같은 세밀한 디자인도 가능
|
||||
- **기본값**: 미설정 (= `textColor` 상속) → 설정하지 않으면 기존 동작과 100% 동일
|
||||
- **외부 SVG**: `fill`이 하드코딩된 SVG는 이 설정 무시 (SVG 원본 색상 유지가 의도). `currentColor`를 사용하는 SVG만 영향받음
|
||||
- **구현**: 아이콘을 `<span style={{ color: icon.color }}>`으로 감싸서 아이콘만 색상 분리
|
||||
- **초기화**: "텍스트 색상과 동일" 버튼으로 별도 색상 해제 → `icon.color` 삭제
|
||||
|
||||
### 7. 아이콘 크기는 버튼 높이 대비 비율(%) 프리셋 4단계
|
||||
|
||||
- **결정**: 작게(40%) / 보통(55%) / 크게(70%) / 매우 크게(85%) — 버튼 높이 대비 비율
|
||||
- **근거**: 절대 px 값은 버튼 크기가 바뀌면 비율이 깨짐. 비율 기반이면 버튼 크기를 조정해도 아이콘이 자동으로 비례하여 일관된 시각적 균형 유지
|
||||
- **기본값**: `"보통"` (55%) — 대부분의 버튼 크기에 적합
|
||||
- **px 직접 입력 제거**: 관리자에게 과도한 선택지를 주면 오히려 일관성이 깨짐. 4단계 프리셋만으로 충분
|
||||
- **구현**: CSS `height: N%` + `aspect-ratio: 1/1`로 정사각형 유지, lucide 아이콘은 래핑 span으로 크기 제어
|
||||
- **레거시 호환**: 기존 `"sm"`, `"md"` 등 레거시 값은 55%(보통)로 자동 폴백
|
||||
|
||||
### 8. 아이콘 동적 렌더링은 매핑 객체 방식
|
||||
|
||||
- **결정**: lucide-react 아이콘 이름(string) → 실제 컴포넌트 매핑 객체를 별도 파일로 관리
|
||||
- **근거**: `import * from 'lucide-react'`는 번들 크기에 영향. 사용하는 아이콘만 명시적으로 매핑
|
||||
- **파일**: `frontend/lib/button-icon-map.ts`
|
||||
- **구현**: `Record<string, React.ComponentType>` 형태의 매핑 + `renderIcon(name, size)` 유틸 함수
|
||||
|
||||
### 9. 아이콘 모드에서도 text 값은 유지
|
||||
|
||||
- **결정**: `displayMode === "icon"`이어도 `text` 필드는 삭제하지 않음
|
||||
- **근거**: 접근성(`aria-label`), 검색/필터링 등에 텍스트가 필요할 수 있음
|
||||
- **렌더링**: 아이콘 모드에서는 `text`를 `aria-label` 용도로만 보존
|
||||
- **아이콘+텍스트 모드**: `text`가 아이콘 오른쪽에 함께 렌더링됨
|
||||
|
||||
### 10. 아이콘-텍스트 간격 설정 추가
|
||||
|
||||
- **결정**: 아이콘+텍스트 모드에서 아이콘과 텍스트 사이 간격을 관리자가 조절 가능 (`iconGap`)
|
||||
- **근거**: 고정 `gap-1.5`(6px)로는 다양한 버튼 크기/디자인에 대응 불가. 간격이 좁으면 답답하고, 넓으면 분리되어 보이는 경우가 있어 관리자에게 조절 권한 제공
|
||||
- **기본값**: 6px (기존 `gap-1.5`와 동일)
|
||||
- **UI**: 슬라이더(0~32px) + 숫자 직접 입력(최댓값 제한 없음)
|
||||
- **저장**: `componentConfig.iconGap` (숫자)
|
||||
|
||||
### 11. 키보드 단축키 입력 필드 충돌 해결
|
||||
|
||||
- **결정**: `ScreenDesigner`의 글로벌 키보드 핸들러에서 입력 필드 포커스 시 앱 단축키를 무시하도록 수정
|
||||
- **근거**: SVG 붙여넣기 textarea에서 Ctrl+V/A/C/Z가 작동하지 않는 치명적 UX 문제 발견. 글로벌 `keydown` 핸들러가 `{ capture: true }`로 모든 키보드 이벤트를 가로채고 있었음
|
||||
- **수정**: `browserShortcuts` 일괄 차단과 앱 전용 단축키 처리 앞에 `e.target`/`document.activeElement` 기반 입력 필드 감지 가드 추가
|
||||
- **영향**: input, textarea, select, contentEditable 요소에서 텍스트 편집 단축키가 정상 동작
|
||||
|
||||
### 12. noIconAction에서 커스텀 아이콘 추가 허용
|
||||
|
||||
- **결정**: 추천 아이콘이 없는 deprecated 액션에서도 커스텀 아이콘(lucide 검색, SVG 붙여넣기) 추가 가능
|
||||
- **근거**: "적절한 아이콘이 없습니다" 문구만 표시하고 아이콘 추가를 완전 차단하면 관리자가 필요한 아이콘을 직접 등록할 방법이 없음. 추천은 없지만 직접 추가는 허용해야 유연성 확보
|
||||
- **안내 문구**: "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요."
|
||||
|
||||
### 13. 아이콘 모드 레이아웃 안내 문구
|
||||
|
||||
- **결정**: 아이콘 모드(`"icon"`) 선택 시 "버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다" 안내 표시
|
||||
- **근거**: 아이콘 자체는 항상 정사각형(24x24 viewBox)이지만, 디자이너에서 버튼 컨테이너는 가로로 넓은 직사각형이 기본. 아이콘만 넣으면 좌우 여백이 과다해 보이므로 버튼 영역을 줄이라는 안내가 필요. 자동 크기 조정은 기존 레이아웃을 깨뜨릴 위험이 있어 도입하지 않되, 관리자에게 팁을 제공하면 스스로 최적화할 수 있음
|
||||
- **표시 조건**: `displayMode === "icon"`일 때만 (아이콘+텍스트 모드는 가로 공간이 필요하므로 해당 안내 불필요)
|
||||
- **대안 검토**: 자동 정사각형 조정 → 기각 (관리자 수동 레이아웃 파괴 위험)
|
||||
|
||||
### 14. 디폴트 아이콘 자동 부여
|
||||
|
||||
- **결정**: 아이콘/아이콘+텍스트 모드 전환 시 아이콘이 미선택이면 디폴트 아이콘을 자동으로 부여. 커스텀 아이콘 삭제 시에도 텍스트 모드로 빠지지 않고 디폴트 아이콘으로 복귀
|
||||
- **근거**: 아이콘 모드로 전환했는데 아무것도 안 보이면 "기능이 작동하지 않는다"는 착각을 유발. 또한 커스텀 아이콘을 삭제했을 때 갑자기 텍스트로 빠지면 관리자가 의도치 않은 모드 변경을 경험하게 됨
|
||||
- **디폴트 선택 기준**: 해당 액션의 첫 번째 추천 아이콘 (예: save → Check). 추천 아이콘이 없는 액션은 범용 폴백 `SquareMousePointer` 사용
|
||||
- **구현**: `getDefaultIconForAction(actionType)` 유틸 함수로 중앙화 (`button-icon-map.tsx`)
|
||||
- **폴백 아이콘**: `SquareMousePointer` — 마우스 포인터 + 사각형 형태로 "버튼 클릭 동작"을 범용적으로 표현
|
||||
|
||||
### 15. 아이콘+텍스트 모드에서 텍스트 위치 4방향 지원
|
||||
|
||||
- **결정**: 아이콘 대비 텍스트 위치를 왼쪽/오른쪽/위쪽/아래쪽 4방향으로 설정 가능
|
||||
- **근거**: 기존에는 아이콘 오른쪽에 텍스트 고정이었으나, 세로 배치(위/아래)가 필요한 경우도 존재 (좁고 높은 버튼, 툴바 스타일). 4방향을 제공하면 관리자가 버튼 모양에 맞게 레이아웃 선택 가능
|
||||
- **기본값**: `"right"` (아이콘 오른쪽에 텍스트) — 가장 자연스러운 좌→우 읽기 방향
|
||||
- **구현**: `flexDirection` (row/column) + 요소 순서 (textFirst) 조합으로 4방향 구현
|
||||
- **저장**: `componentConfig.iconTextPosition`
|
||||
- **표시 조건**: 아이콘+텍스트 모드에서만 표시 (아이콘 모드, 텍스트 모드에서는 숨김)
|
||||
|
||||
### 16. 버튼 컴포넌트 테두리 이중 적용 문제 해결
|
||||
|
||||
- **결정**: `RealtimePreviewDynamic`의 position wrapper에서 버튼 컴포넌트의 border 속성을 분리(strip)
|
||||
- **근거**: StyleEditor에서 설정한 border가 (1) position wrapper와 (2) 내부 버튼 요소 두 곳에 모두 적용되어 이중 테두리 발생. border는 내부 버튼(`buttonElementStyle`)에서만 렌더링해야 함
|
||||
- **수정 파일**: `RealtimePreviewDynamic.tsx` — `isButtonComponent` 조건에 `v2-button-primary` 추가하여 border strip 대상에 포함
|
||||
- **수정 파일**: `ButtonPrimaryComponent.tsx` — 외부 wrapper(`componentStyle`)에서 border 속성 destructure로 제거, `border: "none"` shorthand 대신 개별 longhand 속성으로 변경 (borderStyle 미설정 시 기본 `"solid"` 적용)
|
||||
|
||||
### 17. 커스텀 아이콘 검색은 lucide 전체 목록 기반
|
||||
|
||||
- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능
|
||||
- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수
|
||||
- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링
|
||||
- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 설정 패널 (수정) | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 버튼 텍스트/액션 설정 (784~854행에 모드 선택 추가) |
|
||||
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | 버튼 렌더링 분기 (961~983행) |
|
||||
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) |
|
||||
| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
|
||||
| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) |
|
||||
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 |
|
||||
| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### lucide-react 아이콘 동적 렌더링
|
||||
|
||||
```typescript
|
||||
// button-icon-map.ts
|
||||
import { Check, Save, Trash2, Pencil, ... } from "lucide-react";
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Check, Save, Trash2, Pencil, ...
|
||||
};
|
||||
|
||||
export function renderButtonIcon(name: string, size: string | number) {
|
||||
const IconComponent = iconMap[name];
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent style={getIconSizeStyle(size)} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 아이콘 크기 비율 매핑 (버튼 높이 대비 %)
|
||||
|
||||
```typescript
|
||||
const iconSizePresets: Record<string, number> = {
|
||||
"작게": 40,
|
||||
"보통": 55,
|
||||
"크게": 70,
|
||||
"매우 크게": 85,
|
||||
};
|
||||
|
||||
// 프리셋 문자열 → 비율(%) 반환. 레거시 값은 55(보통)로 폴백
|
||||
export function getIconPercent(size: string | number): number {
|
||||
if (typeof size === "number") return size;
|
||||
return iconSizePresets[size] ?? 55;
|
||||
}
|
||||
|
||||
// 버튼 높이 대비 비율 + 정사각형 유지
|
||||
export function getIconSizeStyle(size: string | number): React.CSSProperties {
|
||||
const pct = getIconPercent(size);
|
||||
return { height: `${pct}%`, width: "auto", aspectRatio: "1 / 1" };
|
||||
}
|
||||
```
|
||||
|
||||
### 외부 SVG 아이콘 렌더링
|
||||
|
||||
```typescript
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export function renderSvgIcon(svgString: string, size: string | number) {
|
||||
const clean = DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } });
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center"
|
||||
style={getIconSizeStyle(size)}
|
||||
dangerouslySetInnerHTML={{ __html: clean }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 버튼 액션별 추천 아이콘 구조
|
||||
|
||||
```typescript
|
||||
const actionIconMap: Record<string, string[]> = {
|
||||
save: ["Check", "Save", "CheckCircle", "CircleCheck", "FileCheck", "ShieldCheck"],
|
||||
delete: ["Trash2", "Trash", "XCircle", "X", "Eraser", "CircleX"],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 현재 버튼 액션 목록 (활성)
|
||||
|
||||
| 값 | 표시명 | 아이콘화 가능 |
|
||||
|-----|--------|-------------|
|
||||
| `save` | 저장 | O |
|
||||
| `delete` | 삭제 | O |
|
||||
| `edit` | 편집 | O |
|
||||
| `navigate` | 페이지 이동 | O |
|
||||
| `modal` | 모달 열기 | O |
|
||||
| `transferData` | 데이터 전달 | O |
|
||||
| `excel_download` | 엑셀 다운로드 | O |
|
||||
| `excel_upload` | 엑셀 업로드 | O |
|
||||
| `quickInsert` | 즉시 저장 | O |
|
||||
| `control` | 제어 흐름 | O |
|
||||
| `barcode_scan` | 바코드 스캔 | O |
|
||||
| `operation_control` | 운행알림 및 종료 | O |
|
||||
| `event` | 이벤트 발송 | O |
|
||||
| `copy` | 복사 (품목코드 초기화) | O |
|
||||
|
||||
### 현재 버튼 액션 목록 (숨김/deprecated)
|
||||
|
||||
| 값 | 표시명 | 아이콘화 가능 |
|
||||
|-----|--------|-------------|
|
||||
| `openRelatedModal` | 연관 데이터 버튼 모달 열기 | X (적절한 아이콘 없음) |
|
||||
| `openModalWithData` | (deprecated) 데이터 전달 + 모달 | X |
|
||||
| `view_table_history` | 테이블 이력 보기 | X |
|
||||
| `code_merge` | 코드 병합 | X |
|
||||
| `empty_vehicle` | 공차등록 | X |
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
# [체크리스트] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
|
||||
|
||||
> 관련 문서: [계획서](./BIC[계획]-버튼-아이콘화.md) | [맥락노트](./BIC[맥락]-버튼-아이콘화.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (전 단계 구현 및 검증 완료)
|
||||
- 현재 단계: 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 아이콘 매핑 파일 생성
|
||||
|
||||
- [x] `frontend/lib/button-icon-map.tsx` 생성
|
||||
- [x] 버튼 액션별 추천 아이콘 매핑 (`actionIconMap`) 정의 (14개 액션 x 6개 아이콘)
|
||||
- [x] 아이콘 크기 비율 매핑 (`iconSizePresets`) 정의 (작게/보통/크게/매우 크게, 버튼 높이 대비 %) + `getIconSizeStyle()` 유틸
|
||||
- [x] lucide 아이콘 동적 렌더링 포함 `getButtonDisplayContent()` 구현
|
||||
- [x] SVG 아이콘 렌더링 (DOMPurify 정화 via `isomorphic-dompurify`)
|
||||
- [x] 아이콘 이름 → 컴포넌트 매핑 객체 (`iconMap`) + `addToIconMap()` 동적 추가
|
||||
- [x] deprecated 액션용 안내 문구 상수 (`NO_ICON_MESSAGE`) 정의
|
||||
- [x] `isomorphic-dompurify` 기존 설치 확인 (추가 설치 불필요)
|
||||
- [x] `ButtonIconRenderer` 공용 컴포넌트 추가 (모든 렌더러에서 재사용)
|
||||
- [x] `getDefaultIconForAction()` 디폴트 아이콘 유틸 함수 추가 (액션별 첫 번째 추천 / 범용 폴백)
|
||||
- [x] `FALLBACK_ICON_NAME` 상수 + `SquareMousePointer` import/매핑 추가
|
||||
|
||||
### 2단계: ButtonConfigPanel 수정
|
||||
|
||||
- [x] 표시 모드 버튼 그룹 UI 추가 (텍스트 / 아이콘 / 아이콘+텍스트)
|
||||
- [x] `displayMode` 상태 관리 및 `onUpdateProperty` 연동
|
||||
- [x] 아이콘 모드 선택 시 조건부 UI 분기 (텍스트 입력 숨김 → 아이콘 선택 표시)
|
||||
- [x] 아이콘+텍스트 모드 선택 시 아이콘 선택 + 텍스트 입력 **동시** 표시
|
||||
- [x] 버튼 액션별 추천 아이콘 그리드 렌더링 (4열 그리드)
|
||||
- [x] 선택된 아이콘 하이라이트 (`ring-2 ring-primary/30 border-primary`)
|
||||
- [x] 아이콘 크기 비율 프리셋 버튼 그룹 (작게/보통/크게/매우 크게, 한글 라벨)
|
||||
- [x] px 직접 입력 필드 제거 (비율 프리셋만 제공)
|
||||
- [x] `icon.name`, `icon.size` 를 `onUpdateProperty`로 저장
|
||||
- [x] 아이콘 색상 컬러 피커 구현 (`ColorPickerWithTransparent` 재사용)
|
||||
- [x] "텍스트 색상과 동일" 초기화 버튼 구현
|
||||
- [x] 텍스트 위치 4방향 설정 추가 (`iconTextPosition`, 왼쪽/오른쪽/위쪽/아래쪽)
|
||||
- [x] 아이콘-텍스트 간격 설정 추가 (`iconGap`, 슬라이더 + 직접 입력, 아이콘+텍스트 모드 전용)
|
||||
- [x] 아이콘 모드 레이아웃 안내 문구 표시 (Info 아이콘 + bg-blue-50 박스)
|
||||
- [x] 액션 변경 시 선택 아이콘 자동 초기화 로직 (추천 목록에 없으면 해제)
|
||||
- [x] deprecated 액션에서 안내 문구 + 커스텀 아이콘 추가 버튼 표시
|
||||
- [x] 아이콘/아이콘+텍스트 모드 전환 시 아이콘 미선택이면 디폴트 아이콘 자동 부여
|
||||
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 자동 복귀 (텍스트 모드 전환 방지)
|
||||
|
||||
### 3단계: 커스텀 아이콘 추가/삭제 (lucide 검색)
|
||||
|
||||
- [x] "lucide 검색" 버튼 UI
|
||||
- [x] lucide 아이콘 검색 팝오버 (Popover + Command + CommandInput)
|
||||
- [x] `import { icons } from "lucide-react"` 기반 전체 아이콘 검색/필터링
|
||||
- [x] 선택 시 `componentConfig.customIcons` 배열 추가 + `addToIconMap` 동적 등록
|
||||
- [x] lucide 커스텀 아이콘 그리드 렌더링 (추천 아이콘 아래, 구분선 포함)
|
||||
- [x] lucide 커스텀 아이콘 X 버튼으로 개별 삭제
|
||||
|
||||
### 3-1단계: 커스텀 아이콘 추가/삭제 (SVG 붙여넣기)
|
||||
|
||||
- [x] "SVG 붙여넣기" 버튼 UI (Popover)
|
||||
- [x] SVG 입력 textarea + DOMPurify 실시간 미리보기
|
||||
- [x] SVG 유효성 검사 (`<svg` 태그 포함 여부)
|
||||
- [x] 아이콘 이름 입력 필드 (관리자가 구분용 이름 지정)
|
||||
- [x] DOMPurify로 SVG 정화(sanitize) 후 저장
|
||||
- [x] `componentConfig.customSvgIcons` 배열에 `{ name, svg }` 추가
|
||||
- [x] SVG 커스텀 아이콘 그리드 렌더링 (lucide 커스텀 아이콘과 함께 표시)
|
||||
- [x] SVG 커스텀 아이콘 X 버튼으로 개별 삭제
|
||||
- [x] 커스텀 아이콘(lucide + SVG 모두)이 모든 버튼 액션에서 공통 노출
|
||||
|
||||
### 4단계: 버튼 렌더링 수정 (뷰어/위젯)
|
||||
|
||||
- [x] `InteractiveScreenViewerDynamic.tsx` - `ButtonIconRenderer` 적용
|
||||
- [x] `InteractiveScreenViewer.tsx` - `ButtonIconRenderer` 적용
|
||||
- [x] `ButtonWidget.tsx` - `ButtonIconRenderer` 적용 (디자인/실행 모드 모두)
|
||||
- [x] `OptimizedButtonComponent.tsx` - `ButtonIconRenderer` 적용 (실행 중 "처리 중..." 유지)
|
||||
- [x] `ButtonPrimaryComponent.tsx` - `ButtonIconRenderer` 적용 (v2-button-primary 캔버스 렌더링)
|
||||
- [x] lucide 아이콘 렌더링 (`icon.type === "lucide"`, `getLucideIcon` 조회)
|
||||
- [x] SVG 아이콘 렌더링 (`icon.type === "svg"`, DOMPurify 정화 후 innerHTML)
|
||||
- [x] 아이콘+텍스트 모드: `inline-flex items-center` + 동적 `gap` (iconGap px)
|
||||
- [x] `icon.color` 설정 시 아이콘만 별도 색상 적용 (inline style)
|
||||
- [x] `icon.color` 미설정 시 textColor 상속 (currentColor 기본)
|
||||
- [x] 아이콘 크기 비율 프리셋 `getIconSizeStyle()` 처리 (버튼 높이 대비 %)
|
||||
- [x] 텍스트 위치 4방향 렌더링 (`flexDirection` + 요소 순서 조합)
|
||||
|
||||
### 4-2단계: 버튼 테두리 이중 적용 수정
|
||||
|
||||
- [x] `RealtimePreviewDynamic.tsx` — position wrapper에서 버튼 컴포넌트 border strip 추가
|
||||
- [x] `ButtonPrimaryComponent.tsx` — 외부 wrapper에서 border 속성 destructure 제거
|
||||
- [x] `ButtonPrimaryComponent.tsx` — `border: "none"` shorthand 제거, 개별 longhand 속성으로 변경
|
||||
- [x] `isButtonComponent` 조건에 `"v2-button-primary"` 추가
|
||||
|
||||
### 4-1단계: 키보드 단축키 충돌 수정
|
||||
|
||||
- [x] `ScreenDesigner.tsx` 글로벌 keydown 핸들러에 입력 필드 감지 가드 추가
|
||||
- [x] `browserShortcuts` 배열에서 `Ctrl+V` 제거
|
||||
- [x] 입력 필드(input/textarea/select/contentEditable) 포커스 시 Ctrl+A/C/V/Z 기본 동작 허용
|
||||
- [x] SVG 붙여넣기 textarea에 `onPaste`/`onKeyDown` stopPropagation 핸들러 추가
|
||||
|
||||
### 5단계: 검증
|
||||
|
||||
- [x] 텍스트 모드: 기존 동작 변화 없음 확인 (하위 호환성)
|
||||
- [x] `displayMode` 없는 기존 버튼: 텍스트 모드로 정상 동작
|
||||
- [x] 아이콘 모드 선택 → 추천 아이콘 6개 그리드 표시
|
||||
- [x] 아이콘 선택 → 캔버스(오른쪽 프리뷰) 및 실제 화면에서 아이콘 렌더링 확인
|
||||
- [x] 아이콘 크기 비율 프리셋 변경 → 버튼 높이 대비 비율 반영 확인
|
||||
- [x] 텍스트 위치 4방향(왼/오른/위/아래) 변경 → 레이아웃 방향 반영 확인
|
||||
- [x] 버튼 테두리 설정 → 내부 버튼에만 적용, 외부 wrapper에 이중 적용 없음 확인
|
||||
- [x] 버튼 액션 변경 → 추천 아이콘 목록 갱신 확인
|
||||
- [x] lucide 커스텀 아이콘 추가 → 모든 액션에서 노출 확인
|
||||
- [x] SVG 커스텀 아이콘 붙여넣기 → 미리보기 → 추가 → 모든 액션에서 노출 확인
|
||||
- [x] SVG에 악성 코드 삽입 시도 → DOMPurify 정화 후 안전 렌더링 확인
|
||||
- [x] 커스텀 아이콘 삭제 → 목록에서 제거 확인
|
||||
- [x] deprecated 액션에서 안내 문구 + 커스텀 아이콘 추가 가능 확인
|
||||
- [x] 아이콘+텍스트 모드: 아이콘 + 텍스트 나란히 렌더링 확인
|
||||
- [x] 아이콘+텍스트 간격 조절: 슬라이더/직접 입력으로 간격 변경 → 실시간 반영 확인
|
||||
- [x] 아이콘 색상 미설정 → textColor와 동일한 색상 확인
|
||||
- [x] 아이콘 색상 설정 → 아이콘만 해당 색상, 텍스트는 textColor 유지 확인
|
||||
- [x] 외부 SVG (고유 색상) → icon.color 설정해도 SVG 원본 색상 유지 확인
|
||||
- [x] "텍스트 색상과 동일" 버튼 → icon.color 해제되고 textColor 상속 복원 확인
|
||||
- [x] 레이아웃 안내 문구: 아이콘 모드에서만 표시, 다른 모드에서 숨김 확인
|
||||
- [x] 입력 필드에서 Ctrl+A/C/V/Z 단축키 정상 동작 확인
|
||||
- [x] 아이콘 모드 전환 시 디폴트 아이콘 자동 선택 → 캔버스에 즉시 반영 확인
|
||||
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인
|
||||
- [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인
|
||||
|
||||
### 6단계: 정리
|
||||
|
||||
- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러)
|
||||
- [x] 불필요한 import 없음 확인
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-04 | 계획서, 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-04 | 외부 SVG 붙여넣기 기능 추가 (3개 문서 모두 반영) |
|
||||
| 2026-03-04 | 아이콘+텍스트 모드, 레이아웃 안내 추가 |
|
||||
| 2026-03-04 | 설정 패널 내 미리보기 제거 (오른쪽 캔버스 프리뷰로 대체) |
|
||||
| 2026-03-04 | 아이콘 색상 설정 추가 (icon.color, 기본값 textColor 상속) |
|
||||
| 2026-03-04 | 3개 문서 교차 검토 — 개요 누락 보완, 시각 예시 문구 통일, 렌더 함수 px 대응, 용어 명확화 |
|
||||
| 2026-03-04 | 구현 완료 — 1~4단계 코드 작성, 6단계 린트/타입 검증 통과 |
|
||||
| 2026-03-04 | 아이콘-텍스트 간격 설정 추가 (iconGap, 슬라이더+직접 입력) |
|
||||
| 2026-03-04 | noIconAction에서 커스텀 아이콘 추가 허용 + 안내 문구 변경 |
|
||||
| 2026-03-04 | ScreenDesigner 키보드 단축키 수정 — 입력 필드에서 텍스트 편집 단축키 허용 |
|
||||
| 2026-03-04 | SVG 붙여넣기 textarea에 onPaste/onKeyDown 핸들러 추가 |
|
||||
| 2026-03-04 | SVG 커스텀 아이콘 이름 중복 방지 (자동 넘버링) |
|
||||
| 2026-03-04 | 디폴트 아이콘 자동 부여 — 모드 전환 시 자동 선택, 커스텀 삭제 시 디폴트 복귀 |
|
||||
| 2026-03-04 | `getDefaultIconForAction()` 유틸 + `SquareMousePointer` 폴백 아이콘 추가 |
|
||||
| 2026-03-04 | 3개 문서 변경사항 동기화 및 코드 정리 |
|
||||
| 2026-03-04 | 아이콘 크기: 절대 px → 버튼 높이 대비 비율(%) 4단계 프리셋으로 변경, px 직접 입력 제거 |
|
||||
| 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) |
|
||||
| 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 |
|
||||
| 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# [계획서] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
|
||||
|
||||
> 관련 문서: [맥락노트](./MST[맥락노트]-v2select-multiselect-tooltip.md) | [체크리스트](./MST[체크리스트]-v2select-multiselect-tooltip.md)
|
||||
|
||||
## 개요
|
||||
|
||||
모든 화면에서 다중 선택 가능한 드롭다운(`V2Select` - `DropdownSelect`)의 선택 항목 표시 방식을 개선합니다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
- 다중 선택 시 `"3개 선택됨"` 같은 텍스트만 표시
|
||||
- 어떤 항목이 선택되었는지 드롭다운을 열어야만 확인 가능
|
||||
|
||||
### 현재 코드 (V2Select.tsx - DropdownSelect, 174~178행)
|
||||
|
||||
```tsx
|
||||
{selectedLabels.length > 0
|
||||
? multiple
|
||||
? `${selectedLabels.length}개 선택됨`
|
||||
: selectedLabels[0]
|
||||
: placeholder}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. 선택된 항목 라벨을 쉼표로 연결하여 한 줄로 표시
|
||||
|
||||
- 예: `"구매품, 판매품, 재고품"`
|
||||
- `truncate` (text-overflow: ellipsis)로 필드 너비를 넘으면 말줄임(`...`) 처리
|
||||
- 무조건 한 줄 표시, 넘치면 `...`으로 숨김
|
||||
|
||||
### 2. 텍스트가 말줄임(`...`) 처리될 때만 호버 툴팁 표시
|
||||
|
||||
- 필드 너비를 넘어서 `...`으로 잘릴 때만 툴팁 활성화
|
||||
- 필드 내에 전부 보이면 툴팁 불필요
|
||||
- 툴팁 내용은 세로 나열로 각 항목을 한눈에 확인 가능
|
||||
- 툴팁은 딜레이 없이 즉시 표시
|
||||
|
||||
---
|
||||
|
||||
## 시각적 동작 예시
|
||||
|
||||
| 상태 | 필드 내 표시 | 호버 시 툴팁 |
|
||||
|------|-------------|-------------|
|
||||
| 미선택 | `선택` (placeholder) | 없음 |
|
||||
| 1개 선택 | `구매품` | 없음 |
|
||||
| 3개 선택 (필드 내 수용) | `구매품, 판매품, 재고품` | 없음 (잘리지 않으므로) |
|
||||
| 5개 선택 (필드 넘침) | `구매품, 판매품, 재고품, 외...` | 구매품 / 판매품 / 재고품 / 외주품 / 반제품 (세로 나열) |
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상
|
||||
|
||||
- **파일**: `frontend/components/v2/V2Select.tsx`
|
||||
- **컴포넌트**: `DropdownSelect` 내부 표시 텍스트 부분 (170~178행)
|
||||
- **적용 범위**: `DropdownSelect`를 사용하는 모든 화면 (품목정보, 기타 모든 모달 포함)
|
||||
- **변경 규모**: 약 30줄 내외 소규모 변경
|
||||
|
||||
---
|
||||
|
||||
## 코드 설계
|
||||
|
||||
### 추가 import
|
||||
|
||||
```tsx
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
```
|
||||
|
||||
### 말줄임 감지 로직
|
||||
|
||||
```tsx
|
||||
// 텍스트가 잘리는지(truncated) 감지
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = textRef.current;
|
||||
if (el) {
|
||||
setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
}
|
||||
}, [selectedLabels]);
|
||||
```
|
||||
|
||||
### 수정 코드 (DropdownSelect 내부, 170~178행 대체)
|
||||
|
||||
```tsx
|
||||
const displayText = selectedLabels.length > 0
|
||||
? (multiple ? selectedLabels.join(", ") : selectedLabels[0])
|
||||
: placeholder;
|
||||
|
||||
const isPlaceholder = selectedLabels.length === 0;
|
||||
|
||||
// 렌더링 부분
|
||||
{isTruncated && multiple ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn("truncate flex-1 text-left", isPlaceholder && "text-muted-foreground")}
|
||||
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px]">
|
||||
<div className="space-y-0.5 text-xs">
|
||||
{selectedLabels.map((label, i) => (
|
||||
<div key={i}>{label}</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn("truncate flex-1 text-left", isPlaceholder && "text-muted-foreground")}
|
||||
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- 기존 단일 선택 동작은 변경하지 않음
|
||||
- `DropdownSelect` 공통 컴포넌트 수정이므로 모든 화면에 자동 적용
|
||||
- 무조건 한 줄 표시, 넘치면 `...`으로 말줄임
|
||||
- 툴팁은 텍스트가 실제로 잘릴 때(`scrollWidth > clientWidth`)만 표시
|
||||
- 툴팁 내용은 세로 나열로 각 항목 확인 용이
|
||||
- 툴팁 딜레이 없음 (`delayDuration={0}`)
|
||||
- shadcn 표준 Tooltip 컴포넌트 사용으로 프로젝트 일관성 유지
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# [맥락노트] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
|
||||
|
||||
> 관련 문서: [계획서](./MST[계획서]-v2select-multiselect-tooltip.md) | [체크리스트](./MST[체크리스트]-v2select-multiselect-tooltip.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 사용자가 수정 모달에서 다중 선택 드롭다운을 사용할 때 `"3개 선택됨"` 만 보임
|
||||
- 드롭다운을 다시 열어봐야만 무엇이 선택됐는지 확인 가능 → UX 불편
|
||||
- 선택 항목을 직접 보여주고, 넘치면 툴팁으로 확인할 수 있게 개선
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. "n개 선택됨" → 라벨 쉼표 나열
|
||||
|
||||
- **결정**: `"구매품, 판매품, 재고품"` 형태로 표시
|
||||
- **근거**: 사용자가 드롭다운을 열지 않아도 선택 내용을 바로 확인 가능
|
||||
|
||||
### 2. 무조건 한 줄, 넘치면 말줄임(`...`)
|
||||
|
||||
- **결정**: 여러 줄 줄바꿈 없이 한 줄 고정, `truncate`로 오버플로우 처리
|
||||
- **근거**: 드롭다운 필드 높이가 고정되어 있어 여러 줄 표시 시 레이아웃이 깨짐
|
||||
|
||||
### 3. 텍스트가 잘릴 때만 툴팁 표시
|
||||
|
||||
- **결정**: `scrollWidth > clientWidth` 비교로 실제 잘림 여부 감지 후 툴팁 활성화
|
||||
- **근거**: 전부 보이는데 툴팁이 뜨면 오히려 방해. 필요할 때만 보여야 함
|
||||
- **대안 검토**: "2개 이상이면 항상 툴팁" → 기각 (불필요한 툴팁 발생)
|
||||
|
||||
### 4. 툴팁 내용은 세로 나열
|
||||
|
||||
- **결정**: 툴팁 안에서 항목을 줄바꿈으로 세로 나열
|
||||
- **근거**: 가로 나열 시 툴팁도 길어져서 읽기 어려움. 세로가 한눈에 파악하기 좋음
|
||||
|
||||
### 5. 툴팁 딜레이 0ms
|
||||
|
||||
- **결정**: `delayDuration={0}` 즉시 표시
|
||||
- **근거**: 사용자가 "무엇을 선택했는지" 확인하려는 의도적 행동이므로 즉시 응답해야 함
|
||||
|
||||
### 6. Radix Tooltip 대신 커스텀 호버 툴팁 사용
|
||||
|
||||
- **결정**: Radix Tooltip을 사용하지 않고 `onMouseEnter`/`onMouseLeave`로 직접 제어
|
||||
- **근거**: Radix Tooltip + Popover 조합은 이벤트 충돌 발생. 내부 배치든 외부 래핑이든 Popover가 호버를 가로챔
|
||||
- **시도 1**: Tooltip을 Button 안에 배치 → Popover가 이벤트 가로챔 (실패)
|
||||
- **시도 2**: Radix 공식 패턴 (TooltipTrigger > PopoverTrigger > Button 체이닝) → 여전히 동작 안 함 (실패)
|
||||
- **최종**: wrapper div에 마우스 이벤트 + 절대 위치 div로 툴팁 렌더링 (성공)
|
||||
- **추가**: Popover 열릴 때 `setHoverTooltip(false)`로 툴팁 자동 숨김
|
||||
|
||||
### 8. 선택 항목 표시 순서는 드롭다운 옵션 순서 기준
|
||||
|
||||
- **결정**: 사용자가 클릭한 순서가 아닌 드롭다운 옵션 목록 순서대로 표시
|
||||
- **근거**: 선택 순서대로 보여주면 매번 순서가 달라져서 혼란. 옵션 순서 기준이 일관적이고 예측 가능
|
||||
- **구현**: `selectedValues.map(...)` → `safeOptions.filter(...).map(...)` 으로 변경
|
||||
|
||||
### 9. DropdownSelect 공통 컴포넌트 수정
|
||||
|
||||
- **결정**: 특정 화면이 아닌 `DropdownSelect` 자체를 수정
|
||||
- **근거**: 품목정보뿐 아니라 모든 화면에서 동일한 문제가 있으므로 공통 해결
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 수정 대상 | `frontend/components/v2/V2Select.tsx` | DropdownSelect 컴포넌트 (170~178행) |
|
||||
| 타입 정의 | `frontend/types/v2-components.ts` | V2SelectProps, SelectOption 타입 |
|
||||
| UI 컴포넌트 | `frontend/components/ui/tooltip.tsx` | shadcn Tooltip 컴포넌트 |
|
||||
| 렌더러 | `frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx` | V2Select를 레지스트리에 연결 |
|
||||
| 수정 모달 | `frontend/components/screen/EditModal.tsx` | 공통 편집 모달 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### truncate 감지 방식
|
||||
|
||||
```
|
||||
scrollWidth: 텍스트의 실제 전체 너비 (보이지 않는 부분 포함)
|
||||
clientWidth: 요소의 보이는 너비
|
||||
|
||||
scrollWidth > clientWidth → 텍스트가 잘리고 있음 (... 표시 중)
|
||||
```
|
||||
|
||||
### selectedLabels 계산 흐름
|
||||
|
||||
```
|
||||
value (string[]) → selectedValues → safeOptions에서 label 매칭 → selectedLabels (string[])
|
||||
```
|
||||
|
||||
- `selectedLabels`는 이미 `DropdownSelect` 내부에서 `useMemo`로 계산됨 (126~130행)
|
||||
- 추가 데이터 fetching 불필요, 기존 값을 `.join(", ")`로 결합하면 됨
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# [체크리스트] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
|
||||
|
||||
> 관련 문서: [계획서](./MST[계획서]-v2select-multiselect-tooltip.md) | [맥락노트](./MST[맥락노트]-v2select-multiselect-tooltip.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 전체 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 코드 수정
|
||||
|
||||
- [x] `V2Select.tsx`에 Tooltip 관련 import 추가
|
||||
- [x] `DropdownSelect` 내부에 `textRef`, `isTruncated` 상태 추가
|
||||
- [x] `useEffect`로 `scrollWidth > clientWidth` 감지 로직 추가
|
||||
- [x] 표시 텍스트를 `selectedLabels.join(", ")`로 변경
|
||||
- [x] `isTruncated && multiple` 조건으로 Tooltip 래핑
|
||||
- [x] 툴팁 내용을 세로 나열 (`space-y-0.5`)로 구성
|
||||
- [x] `delayDuration={0}` 설정
|
||||
- [x] Radix Tooltip → 커스텀 호버 툴팁으로 변경 (onMouseEnter/onMouseLeave + 절대 위치 div)
|
||||
- [x] 선택 항목 표시 순서를 드롭다운 옵션 순서 기준으로 변경
|
||||
|
||||
### 2단계: 검증
|
||||
|
||||
- [x] 단일 선택 모드: 기존 동작 변화 없음 확인
|
||||
- [x] 다중 선택 1개: 라벨 정상 표시, 툴팁 없음
|
||||
- [x] 다중 선택 3개 (필드 내 수용): 쉼표 나열 표시, 툴팁 없음
|
||||
- [x] 다중 선택 5개+ (필드 넘침): 말줄임 표시, 호버 시 툴팁 세로 나열
|
||||
- [x] 품목정보 수정 모달에서 동작 확인
|
||||
- [x] 다른 화면의 다중 선택 드롭다운에서도 동작 확인
|
||||
|
||||
### 3단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-04 | 설계 문서 작성 완료 |
|
||||
| 2026-03-04 | 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-04 | 파일명 MST 접두사 적용 |
|
||||
| 2026-03-04 | 1단계 코드 수정 완료 (V2Select.tsx) |
|
||||
| 2026-03-04 | Radix Tooltip이 Popover와 충돌 → 커스텀 호버 툴팁으로 변경 |
|
||||
| 2026-03-04 | 사용자 검증 완료, 전체 작업 완료 |
|
||||
| 2026-03-04 | 선택 항목 표시 순서를 옵션 순서 기준으로 변경 |
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
# 탭 시스템 아키텍처 및 구현 계획
|
||||
|
||||
## 1. 개요
|
||||
|
||||
사이드바 메뉴 클릭 시 `router.push()` 페이지 이동 방식에서 **탭 기반 멀티 화면 시스템**으로 전환한다.
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ Tab Data Layer (중앙) │
|
||||
API 응답 ────────→│ │
|
||||
│ 탭별 상태 저장소 │
|
||||
│ ├─ formData │
|
||||
│ ├─ selectedRows │
|
||||
│ ├─ scrollPosition │
|
||||
│ ├─ modalState │
|
||||
│ ├─ sortState │
|
||||
│ └─ cacheState │
|
||||
│ │
|
||||
│ 공통 규칙 엔진 │
|
||||
│ ├─ 날짜 포맷 규칙 │
|
||||
│ ├─ 숫자/통화 포맷 규칙 │
|
||||
│ ├─ 로케일 처리 규칙 │
|
||||
│ ├─ 유효성 검증 규칙 │
|
||||
│ └─ 데이터 타입 변환 규칙 │
|
||||
│ │
|
||||
│ F5 복원 / 캐시 관리 │
|
||||
│ (sessionStorage 중앙관리) │
|
||||
└────────────┬─────────────┘
|
||||
│
|
||||
가공 완료된 데이터
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
화면 A (경량) 화면 B (경량) 화면 C (경량)
|
||||
렌더링만 담당 렌더링만 담당 렌더링만 담당
|
||||
```
|
||||
|
||||
## 2. 레이어 구조
|
||||
|
||||
| 레이어 | 책임 |
|
||||
|---|---|
|
||||
| **Tab Data Layer** | 탭별 상태 보관, 캐시, 복원, 데이터 가공 |
|
||||
| **공통 규칙 엔진** | 날짜/숫자/로케일 포맷, 유효성 검증 |
|
||||
| **화면 컴포넌트** | 가공된 데이터를 받아서 렌더링만 담당 |
|
||||
|
||||
## 3. 파일 구성
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `stores/tabStore.ts` | Zustand 기반 탭 상태 관리 |
|
||||
| `components/layout/TabBar.tsx` | 탭 바 UI (드래그, 우클릭, 오버플로우) |
|
||||
| `components/layout/TabContent.tsx` | 탭별 콘텐츠 렌더링 (컨테이너) |
|
||||
| `components/layout/EmptyDashboard.tsx` | 탭 없을 때 안내 화면 |
|
||||
| `components/layout/AppLayout.tsx` | 전체 레이아웃 (사이드바 + 탭 + 콘텐츠) |
|
||||
| `lib/tabStateCache.ts` | 탭별 상태 캐싱 엔진 |
|
||||
| `lib/formatting/rules.ts` | 포맷 규칙 정의 |
|
||||
| `lib/formatting/index.ts` | formatDate, formatNumber, formatCurrency |
|
||||
| `app/(main)/screens/[screenId]/page.tsx` | 화면별 렌더링 |
|
||||
|
||||
## 4. 기술 스택
|
||||
|
||||
- Next.js 15, React 19, Zustand
|
||||
- Tailwind CSS, shadcn/ui
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 1: 탭 껍데기
|
||||
|
||||
### 5-1. Zustand 탭 Store (`stores/tabStore.ts`)
|
||||
- [ ] zustand 직접 의존성 추가
|
||||
- [ ] Tab 인터페이스: id, type, title, screenId, menuObjid, adminUrl
|
||||
- [ ] 탭 목록, 활성 탭 ID
|
||||
- [ ] openTab, closeTab, switchTab, refreshTab
|
||||
- [ ] closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs
|
||||
- [ ] updateTabOrder (드래그 순서 변경)
|
||||
- [ ] 중복 방지: 같은 탭이면 해당 탭으로 이동
|
||||
- [ ] 닫기 후 왼쪽 탭으로 이동, 왼쪽 없으면 오른쪽
|
||||
- [ ] sessionStorage 영속화 (persist middleware)
|
||||
- [ ] 탭 ID 생성 규칙: V2 화면 `tab-{screenId}-{menuObjid}`, URL 탭 `tab-url-{menuObjid}`
|
||||
|
||||
### 5-2. TabBar 컴포넌트 (`components/layout/TabBar.tsx`)
|
||||
- [ ] 고정 너비 탭, 화면 너비에 맞게 동적 개수
|
||||
- [ ] 활성 탭: 새로고침 버튼 + X 버튼
|
||||
- [ ] 비활성 탭: X 버튼만
|
||||
- [ ] 오버플로우 시 +N 드롭다운 (ResizeObserver 감시)
|
||||
- [ ] 드래그 순서 변경 (mousedown/move/up, DOM transform 직접 조작)
|
||||
- [ ] 사이드바 메뉴 드래그 드롭 수신 (`application/tab-menu` 커스텀 데이터, 마우스 위치에 삽입)
|
||||
- [ ] 우클릭 컨텍스트 메뉴 (새로고침/왼쪽닫기/오른쪽닫기/다른탭닫기/모든탭닫기)
|
||||
- [ ] 휠 클릭: 탭 즉시 닫기
|
||||
|
||||
### 5-3. TabContent 컴포넌트 (`components/layout/TabContent.tsx`)
|
||||
- [ ] display:none 방식 (비활성 탭 DOM 유지, 상태 보존)
|
||||
- [ ] 지연 마운트 (한 번 활성화된 탭만 마운트)
|
||||
- [ ] 안정적 순서 유지 (탭 순서 변경 시 리마운트 방지)
|
||||
- [ ] 탭별 모달 격리 (DialogPortalContainerContext)
|
||||
- [ ] tab.type === "screen" -> ScreenViewPageWrapper 임베딩
|
||||
- [ ] tab.type === "admin" -> 동적 import로 관리자 페이지 렌더링
|
||||
|
||||
### 5-4. EmptyDashboard 컴포넌트 (`components/layout/EmptyDashboard.tsx`)
|
||||
- [ ] 탭이 없을 때 "사이드바에서 메뉴를 선택하여 탭을 추가하세요" 표시
|
||||
|
||||
### 5-5. AppLayout 수정 (`components/layout/AppLayout.tsx`)
|
||||
- [ ] handleMenuClick: router.push -> tabStore.openTab 호출
|
||||
- [ ] 레이아웃: main 영역을 TabBar + TabContent로 교체
|
||||
- [ ] children prop 제거 (탭이 콘텐츠 관리)
|
||||
- [ ] 사이드바 메뉴 드래그 가능하게 (draggable)
|
||||
|
||||
### 5-6. 라우팅 연동
|
||||
- [ ] `app/(main)/layout.tsx` 수정 - children 대신 탭 시스템
|
||||
- [ ] URL 직접 접근 시 탭으로 열기 (북마크/공유 링크 대응)
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2: F5 최대 복원
|
||||
|
||||
### 6-1. 탭 상태 캐싱 엔진 (`lib/tabStateCache.ts`)
|
||||
- [ ] 탭별 상태 저장/복원 (sessionStorage)
|
||||
- [ ] 저장 대상: formData, selectedRows, sortState, scrollPosition, modalState, checkboxState
|
||||
- [ ] debounce 적용 (상태 변경마다 저장하지 않음)
|
||||
|
||||
### 6-2. 복원 로직
|
||||
- [ ] 활성 탭: fresh API 호출 (캐시 데이터 무시)
|
||||
- [ ] 비활성 탭: 캐시에서 복원
|
||||
- [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제
|
||||
|
||||
### 6-3. 캐시 키 관리 (clearTabStateCache)
|
||||
|
||||
탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거:
|
||||
- `tab-cache-{screenId}-{menuObjid}`
|
||||
- `page-scroll-{screenId}-{menuObjid}`
|
||||
- `tsp-{screenId}-*`, `table-state-{screenId}-*`
|
||||
- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*`
|
||||
- `bom-tree-{screenId}-*`
|
||||
- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}`
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 3: 포맷팅 중앙화
|
||||
|
||||
### 7-1. 포맷팅 규칙 엔진
|
||||
|
||||
```typescript
|
||||
// lib/formatting/rules.ts
|
||||
|
||||
interface FormatRules {
|
||||
date: {
|
||||
display: string; // "YYYY-MM-DD"
|
||||
datetime: string; // "YYYY-MM-DD HH:mm:ss"
|
||||
input: string; // "YYYY-MM-DD"
|
||||
};
|
||||
number: {
|
||||
locale: string; // 사용자 로케일 기반
|
||||
decimals: number; // 기본 소수점 자릿수
|
||||
};
|
||||
currency: {
|
||||
code: string; // 회사 설정 기반
|
||||
locale: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function formatValue(value: any, dataType: string, rules: FormatRules): string;
|
||||
export function formatDate(value: any, format?: string): string;
|
||||
export function formatNumber(value: any, locale?: string): string;
|
||||
export function formatCurrency(value: any, currencyCode?: string): string;
|
||||
```
|
||||
|
||||
### 7-2. 하드코딩 교체 대상
|
||||
- [ ] V2DateRenderer.tsx
|
||||
- [ ] EditModal.tsx
|
||||
- [ ] InteractiveDataTable.tsx
|
||||
- [ ] FlowWidget.tsx
|
||||
- [ ] AggregationWidgetComponent.tsx
|
||||
- [ ] aggregation.ts (피벗)
|
||||
- [ ] 기타 하드코딩 파일들
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 4: ScreenViewPage 경량화
|
||||
- [ ] 탭 데이터 레이어에서 받은 데이터로 렌더링만 담당
|
||||
- [ ] API 호출, 캐시, 복원 로직 제거 (탭 레이어가 담당)
|
||||
- [ ] 관리자 페이지도 동일한 데이터 레이어 패턴 적용
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 구현 완료: 다중 스크롤 영역 F5 복원
|
||||
|
||||
### 개요
|
||||
|
||||
split panel 등 한 탭 안에 **스크롤 영역이 여러 개**인 화면에서, F5 새로고침 후에도 각 영역의 스크롤 위치가 복원된다.
|
||||
|
||||
탭 전환 시에는 `display: none` 방식으로 DOM이 유지되므로 브라우저가 스크롤을 자연 보존한다. 이 기능은 **F5 새로고침** 전용이다.
|
||||
|
||||
### 동작 방식
|
||||
|
||||
탭 내 모든 스크롤 가능한 요소를 DOM 경로(`"0/1/0/2"` 형태)와 함께 저장한다.
|
||||
|
||||
```
|
||||
scrollPositions: [
|
||||
{ path: "0/1/0/2", top: 150, left: 0 }, // 예: 좌측 패널
|
||||
{ path: "0/1/1/3/1", top: 420, left: 0 }, // 예: 우측 패널
|
||||
]
|
||||
```
|
||||
|
||||
- **실시간 추적**: 스크롤 이벤트 발생 시 해당 요소의 경로와 위치를 Map에 기록
|
||||
- **저장 시점**: 탭 전환 시 + `beforeunload`(F5/닫기) 시 sessionStorage에 저장
|
||||
- **복원 시점**: 탭 활성화 시 경로를 기반으로 각 요소를 찾아 개별 복원
|
||||
|
||||
### 관련 파일 및 주요 함수
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `lib/tabStateCache.ts` | 스크롤 캡처/복원 핵심 로직 |
|
||||
| `components/layout/TabContent.tsx` | 스크롤 이벤트 감지, 저장/복원 호출 |
|
||||
|
||||
**`tabStateCache.ts` 핵심 함수**:
|
||||
|
||||
| 함수 | 설명 |
|
||||
|---|---|
|
||||
| `getElementPath(element, container)` | 요소의 DOM 경로를 자식 인덱스 문자열로 생성 |
|
||||
| `captureAllScrollPositions(container)` | TreeWalker로 컨테이너 하위 모든 스크롤 요소의 위치를 일괄 캡처 |
|
||||
| `restoreAllScrollPositions(container, positions)` | 경로 기반으로 각 요소를 찾아 스크롤 위치 복원 (콘텐츠 렌더링 대기 폴링 포함) |
|
||||
|
||||
**`TabContent.tsx` 핵심 Ref**:
|
||||
|
||||
| Ref | 설명 |
|
||||
|---|---|
|
||||
| `lastScrollMapRef` | `Map<tabId, Map<path, {top, left}>>` - 탭 내 요소별 최신 스크롤 위치 |
|
||||
| `pathCacheRef` | `WeakMap<HTMLElement, string>` - 동일 요소의 경로 재계산 방지용 캐시 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 파일
|
||||
|
||||
| 파일 | 비고 |
|
||||
|---|---|
|
||||
| `frontend/components/layout/AppLayout.tsx` | 사이드바 + 콘텐츠 레이아웃 |
|
||||
| `frontend/app/(main)/screens/[screenId]/page.tsx` | 화면 렌더링 (건드리지 않음) |
|
||||
| `frontend/stores/modalDataStore.ts` | Zustand store 참고 패턴 |
|
||||
| `frontend/lib/adminPageRegistry.tsx` | 관리자 페이지 레지스트리 |
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
# 모달 필수 입력 검증 설계
|
||||
|
||||
## 1. 목표
|
||||
|
||||
모든 모달에서 필수 입력값이 빈 상태로 저장 버튼을 클릭하면:
|
||||
- 첫 번째 빈 필수 필드로 포커스 이동 + 하이라이트
|
||||
- 우측 상단에 토스트 알림 ("○○ 항목을 입력해주세요")
|
||||
- 버튼은 항상 활성 상태 (비활성화하지 않음)
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ DialogContent (모든 모달의 공통 래퍼) │
|
||||
│ │
|
||||
│ useDialogAutoValidation(contentRef) │
|
||||
│ │ │
|
||||
│ ├─ 0단계: 모드 확인 │
|
||||
│ │ └─ useTabStore.mode === "user" 일 때만 실행 │
|
||||
│ │ │
|
||||
│ ├─ 1단계: 필수 필드 탐지 │
|
||||
│ │ └─ Label 내부 <span> 안에 * 문자 존재 여부 │
|
||||
│ │ (라벨 텍스트 직접 매칭 X → span 태그 안의 * 만 감지) │
|
||||
│ │ │
|
||||
│ └─ 2단계: 저장 버튼 클릭 인터셉트 │
|
||||
│ │ │
|
||||
│ ├─ 저장/수정/확인 버튼 클릭 감지 │
|
||||
│ │ (data-action-type="save"/"submit" │
|
||||
│ │ 또는 data-variant="default") │
|
||||
│ │ │
|
||||
│ ├─ 빈 필수 필드 있음: │
|
||||
│ │ ├─ 클릭 이벤트 차단 (stopPropagation + preventDefault) │
|
||||
│ │ ├─ 첫 번째 빈 필드로 포커스 이동 │
|
||||
│ │ ├─ 해당 필드 빨간 테두리 + 하이라이트 애니메이션 │
|
||||
│ │ └─ 토스트 알림: "{필드명} 항목을 입력해주세요" │
|
||||
│ │ │
|
||||
│ └─ 모든 필수 필드 입력됨: │
|
||||
│ └─ 클릭 이벤트 통과 (정상 저장 진행) │
|
||||
│ │
|
||||
│ 제외 조건: │
|
||||
│ └─ 필수 필드가 0개인 모달 → 인터셉트 없음 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 필수 필드 감지: span 기반 * 감지
|
||||
|
||||
### 원리
|
||||
|
||||
화면 관리에서 필드를 "필수"로 체크하면 `component.required = true`가 저장된다.
|
||||
V2 컴포넌트가 렌더링할 때 `required = true`이면 Label 안에 `<span>*</span>`을 추가한다.
|
||||
훅은 이 span 안의 `*`를 감지하여 필수 필드를 식별한다.
|
||||
|
||||
### 오탐 방지
|
||||
|
||||
관리자가 라벨 텍스트에 직접 `*`를 입력해도 span 안에 들어가지 않으므로 오탐이 발생하지 않는다.
|
||||
|
||||
```
|
||||
required = true → <label>품목코드<span class="text-orange-500">*</span></label>
|
||||
→ span 안에 * 있음 → 감지 O
|
||||
|
||||
required = false → <label>품목코드</label>
|
||||
→ span 없음 → 감지 X
|
||||
|
||||
라벨에 * 직접 입력 → <label>품목코드*</label>
|
||||
→ span 없이 텍스트에 * → 감지 X (오탐 방지)
|
||||
```
|
||||
|
||||
### 지원 필드 타입
|
||||
|
||||
| V2 컴포넌트 | 렌더링 요소 | 빈값 판정 |
|
||||
|---|---|---|
|
||||
| V2Input | `<input>`, `<textarea>` | `value.trim() === ""` |
|
||||
| V2Select | `<button role="combobox">` | `querySelector("[data-placeholder]")` 존재 |
|
||||
| V2Date | `<input>` (날짜/시간) | `value.trim() === ""` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 저장 버튼 클릭 인터셉트
|
||||
|
||||
### 원리
|
||||
|
||||
버튼을 비활성화하지 않고, 클릭 이벤트를 캡처링 단계에서 가로챈다.
|
||||
빈 필수 필드가 있으면 이벤트를 차단하고, 없으면 통과시킨다.
|
||||
|
||||
### 인터셉트 대상 버튼
|
||||
|
||||
| 조건 | 예시 |
|
||||
|------|------|
|
||||
| `data-action-type="save"` | ButtonPrimary 저장 버튼 |
|
||||
| `data-action-type="submit"` | ButtonPrimary 제출 버튼 |
|
||||
| `data-variant="default"` | shadcn Button 기본 (저장/확인/등록) |
|
||||
|
||||
### 인터셉트하지 않는 버튼
|
||||
|
||||
| 조건 | 예시 |
|
||||
|------|------|
|
||||
| `data-variant` = outline/ghost/destructive/secondary | 취소, 닫기, 삭제 |
|
||||
| `role` = combobox/tab/switch 등 | 폼 컨트롤 |
|
||||
| `data-action-type` != save/submit | 기타 액션 버튼 |
|
||||
| `data-dialog-close` | 모달 닫기 X 버튼 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 시각적 피드백
|
||||
|
||||
### 포커스 이동
|
||||
|
||||
첫 번째 빈 필수 필드로 커서를 이동한다.
|
||||
- `<input>`, `<textarea>`: `input.focus()`
|
||||
- `<button role="combobox">` (V2Select): `button.click()` → 드롭다운 열기
|
||||
|
||||
### 하이라이트 애니메이션
|
||||
|
||||
빈 필수 필드에 빨간 테두리 + 흔들림 효과를 준다.
|
||||
|
||||
```css
|
||||
@keyframes validationShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20%, 60% { transform: translateX(-4px); }
|
||||
40%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
[data-validation-highlight] {
|
||||
border-color: hsl(var(--destructive)) !important;
|
||||
animation: validationShake 400ms ease-in-out;
|
||||
}
|
||||
```
|
||||
|
||||
애니메이션 종료 후 `data-validation-highlight` 속성 제거 (일회성).
|
||||
|
||||
### 토스트 알림
|
||||
|
||||
우측 상단에 토스트 메시지를 표시한다.
|
||||
|
||||
```typescript
|
||||
toast.error(`${fieldLabel} 항목을 입력해주세요`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 동작 흐름
|
||||
|
||||
```
|
||||
모달 열림
|
||||
│
|
||||
▼
|
||||
DialogContent 마운트
|
||||
│
|
||||
▼
|
||||
useDialogAutoValidation 실행
|
||||
│
|
||||
▼
|
||||
모드 확인 (useTabStore.mode)
|
||||
│
|
||||
├─ mode !== "user"? → return
|
||||
│
|
||||
▼
|
||||
필수 필드 탐지 (Label 내 span에서 * 감지)
|
||||
│
|
||||
├─ 필수 필드 0개? → return
|
||||
│
|
||||
▼
|
||||
클릭 이벤트 리스너 등록 (캡처링 단계)
|
||||
│
|
||||
▼
|
||||
사용자가 저장 버튼 클릭
|
||||
│
|
||||
▼
|
||||
인터셉트 대상 버튼인가?
|
||||
│
|
||||
├─ 아니오 → 클릭 통과
|
||||
│
|
||||
▼
|
||||
빈 필수 필드 검사
|
||||
│
|
||||
├─ 모두 입력됨 → 클릭 통과 (정상 저장)
|
||||
│
|
||||
├─ 빈 필드 있음:
|
||||
│ ├─ e.stopPropagation() + e.preventDefault()
|
||||
│ ├─ 첫 번째 빈 필드에 포커스 이동
|
||||
│ ├─ 해당 필드에 data-validation-highlight 속성 추가
|
||||
│ ├─ 애니메이션 종료 후 속성 제거
|
||||
│ └─ toast.error("{필드명} 항목을 입력해주세요")
|
||||
│
|
||||
▼
|
||||
모달 닫힘
|
||||
│
|
||||
▼
|
||||
클린업
|
||||
├─ 이벤트 리스너 제거
|
||||
└─ 하이라이트 속성 제거
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 관련 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `frontend/lib/hooks/useDialogAutoValidation.ts` | 검증 훅 본체 |
|
||||
| `frontend/components/ui/dialog.tsx` | DialogContent에서 훅 호출 |
|
||||
| `frontend/components/ui/button.tsx` | data-variant 속성 노출 |
|
||||
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | data-action-type 속성 노출 |
|
||||
| `frontend/app/globals.css` | 하이라이트 애니메이션 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 적용 범위
|
||||
|
||||
### 현재 (1단계): 사용자 모드만
|
||||
|
||||
| 모달 유형 | 동작 여부 | 이유 |
|
||||
|---------------------------------------|:---:|-------------------------------|
|
||||
| 사용자 모드 모달 (SaveModal 등) | O | mode === "user" + span * 있음 |
|
||||
| 관리자 모드 모달 (CodeFormModal 등) | X | mode !== "user" → return |
|
||||
| 확인/삭제 다이얼로그 (필수 필드 없음) | X | 필수 필드 0개 → 자동 제외 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 이전 방식과 비교
|
||||
|
||||
| 항목 | 이전 (버튼 비활성화) | 현재 (클릭 인터셉트) |
|
||||
|------|---|---|
|
||||
| 버튼 상태 | 빈 필드 있으면 비활성화 | 항상 활성 |
|
||||
| 피드백 시점 | 모달 열릴 때부터 | 저장 버튼 클릭 시 |
|
||||
| 피드백 방식 | 빨간 테두리 + 에러 문구 | 포커스 이동 + 하이라이트 + 토스트 |
|
||||
| 복잡도 | 높음 (MutationObserver, 폴링, CSS 지연) | 낮음 (클릭 이벤트 하나) |
|
||||
|
|
@ -10,17 +10,8 @@ import { LoginFooter } from "@/components/auth/LoginFooter";
|
|||
* 비즈니스 로직은 useLogin 훅에서 처리하고, UI 컴포넌트들을 조합하여 구성
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
const {
|
||||
formData,
|
||||
isLoading,
|
||||
error,
|
||||
showPassword,
|
||||
isPopMode,
|
||||
handleInputChange,
|
||||
handleLogin,
|
||||
togglePasswordVisibility,
|
||||
togglePopMode,
|
||||
} = useLogin();
|
||||
const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } =
|
||||
useLogin();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
||||
|
|
@ -32,11 +23,9 @@ export default function LoginPage() {
|
|||
isLoading={isLoading}
|
||||
error={error}
|
||||
showPassword={showPassword}
|
||||
isPopMode={isPopMode}
|
||||
onInputChange={handleInputChange}
|
||||
onSubmit={handleLogin}
|
||||
onTogglePassword={togglePasswordVisibility}
|
||||
onTogglePop={togglePopMode}
|
||||
/>
|
||||
|
||||
<LoginFooter />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -29,17 +29,24 @@ import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조
|
|||
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
|
||||
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
function ScreenViewPage() {
|
||||
export interface ScreenViewPageProps {
|
||||
screenIdProp?: number;
|
||||
menuObjidProp?: number;
|
||||
}
|
||||
|
||||
function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
|
||||
// 스케줄 자동 생성 서비스 활성화
|
||||
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const screenId = parseInt(params.screenId as string);
|
||||
const screenId = screenIdProp ?? parseInt(params.screenId as string);
|
||||
|
||||
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
||||
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
||||
// props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기
|
||||
const menuObjid = menuObjidProp ?? (searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined);
|
||||
|
||||
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
||||
const previewCompanyCode = searchParams.get("company_code");
|
||||
|
|
@ -125,10 +132,13 @@ function ScreenViewPage() {
|
|||
initComponents();
|
||||
}, []);
|
||||
|
||||
// 편집 모달 이벤트 리스너 등록
|
||||
// 편집 모달 이벤트 리스너 등록 (활성 탭에서만 처리)
|
||||
const tabId = useTabId();
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||||
const state = useTabStore.getState();
|
||||
const currentActiveTabId = state[state.mode].activeTabId;
|
||||
if (tabId && tabId !== currentActiveTabId) return;
|
||||
|
||||
setEditModalConfig({
|
||||
screenId: event.detail.screenId,
|
||||
|
|
@ -148,7 +158,7 @@ function ScreenViewPage() {
|
|||
// @ts-expect-error - CustomEvent type
|
||||
window.removeEventListener("openEditModal", handleOpenEditModal);
|
||||
};
|
||||
}, []);
|
||||
}, [tabId]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadScreen = async () => {
|
||||
|
|
@ -1344,16 +1354,17 @@ function ScreenViewPage() {
|
|||
}
|
||||
|
||||
// 실제 컴포넌트를 Provider로 감싸기
|
||||
function ScreenViewPageWrapper() {
|
||||
function ScreenViewPageWrapper({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
|
||||
return (
|
||||
<TableSearchWidgetHeightProvider>
|
||||
<ScreenContextProvider>
|
||||
<SplitPanelProvider>
|
||||
<ScreenViewPage />
|
||||
<ScreenViewPage screenIdProp={screenIdProp} menuObjidProp={menuObjidProp} />
|
||||
</SplitPanelProvider>
|
||||
</ScreenContextProvider>
|
||||
</TableSearchWidgetHeightProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScreenViewPageWrapper };
|
||||
export default ScreenViewPageWrapper;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react";
|
||||
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
@ -285,23 +285,14 @@ function PopScreenViewPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 일반 모드 네비게이션 바 */}
|
||||
{!isPreviewMode && (
|
||||
<div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/pop")} className="gap-1 text-xs">
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
POP 대시보드
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">{screen.screenName}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/")} className="gap-1 text-xs">
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
PC 모드
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POP 화면 컨텐츠 */}
|
||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
|
||||
{/* 현재 모드 표시 (일반 모드) */}
|
||||
{!isPreviewMode && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||
{currentModeKey.replace("_", " ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`}
|
||||
|
|
|
|||
|
|
@ -424,4 +424,38 @@ select {
|
|||
}
|
||||
}
|
||||
|
||||
/* ===== 모달 필수 입력 검증 ===== */
|
||||
@keyframes validationShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20%, 60% { transform: translateX(-4px); }
|
||||
40%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
/* 흔들림 애니메이션 (일회성) */
|
||||
[data-validation-highlight] {
|
||||
animation: validationShake 400ms ease-in-out;
|
||||
}
|
||||
|
||||
/* 빨간 테두리 (값 입력 전까지 유지) */
|
||||
[data-validation-error] {
|
||||
border-color: hsl(var(--destructive)) !important;
|
||||
}
|
||||
|
||||
/* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */
|
||||
.validation-error-msg-wrapper {
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.validation-error-msg-wrapper > p {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 0;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--destructive));
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== End of Global Styles ===== */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import "./globals.css";
|
|||
import { QueryProvider } from "@/providers/QueryProvider";
|
||||
import { RegistryProvider } from "./registry-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import ScreenModal from "@/components/common/ScreenModal";
|
||||
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -45,7 +45,6 @@ export default function RootLayout({
|
|||
<QueryProvider>
|
||||
<RegistryProvider>{children}</RegistryProvider>
|
||||
<Toaster position="top-right" />
|
||||
<ScreenModal />
|
||||
</QueryProvider>
|
||||
{/* Portal 컨테이너 */}
|
||||
<div id="portal-root" data-radix-portal="true" />
|
||||
|
|
|
|||
|
|
@ -82,19 +82,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
});
|
||||
|
||||
// 화면 할당 관련 상태
|
||||
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen");
|
||||
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당)
|
||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [screenSearchText, setScreenSearchText] = useState("");
|
||||
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
|
||||
|
||||
// POP 화면 할당 관련 상태
|
||||
const [selectedPopScreen, setSelectedPopScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [popScreenSearchText, setPopScreenSearchText] = useState("");
|
||||
const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false);
|
||||
const [isPopLanding, setIsPopLanding] = useState(false);
|
||||
const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false);
|
||||
|
||||
// 대시보드 할당 관련 상태
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
|
||||
const [dashboards, setDashboards] = useState<any[]>([]);
|
||||
|
|
@ -203,27 +196,8 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`);
|
||||
};
|
||||
|
||||
// POP 화면 선택 시 URL 자동 설정
|
||||
const handlePopScreenSelect = (screen: ScreenDefinition) => {
|
||||
const actualScreenId = screen.screenId || screen.id;
|
||||
if (!actualScreenId) {
|
||||
toast.error("화면 ID를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPopScreen(screen);
|
||||
setIsPopScreenDropdownOpen(false);
|
||||
|
||||
const popUrl = `/pop/screens/${actualScreenId}`;
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: popUrl,
|
||||
}));
|
||||
};
|
||||
|
||||
// URL 타입 변경 시 처리
|
||||
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => {
|
||||
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => {
|
||||
// console.log("🔄 URL 타입 변경:", {
|
||||
// from: urlType,
|
||||
// to: type,
|
||||
|
|
@ -234,53 +208,36 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
setUrlType(type);
|
||||
|
||||
if (type === "direct") {
|
||||
// 직접 입력 모드로 변경 시 선택된 화면 초기화
|
||||
setSelectedScreen(null);
|
||||
setSelectedPopScreen(null);
|
||||
// URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: "",
|
||||
screenCode: undefined,
|
||||
}));
|
||||
} else if (type === "pop") {
|
||||
setSelectedScreen(null);
|
||||
if (selectedPopScreen) {
|
||||
const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: `/pop/screens/${actualScreenId}`,
|
||||
screenCode: undefined, // 화면 코드도 함께 초기화
|
||||
}));
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: "",
|
||||
}));
|
||||
}
|
||||
} else if (type === "screen") {
|
||||
setSelectedPopScreen(null);
|
||||
// 화면 할당 모드로 변경 시
|
||||
// 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지
|
||||
if (selectedScreen) {
|
||||
console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName);
|
||||
// 현재 선택된 화면으로 URL 재생성
|
||||
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
|
||||
let screenUrl = `/screens/${actualScreenId}`;
|
||||
|
||||
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
|
||||
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
|
||||
if (isAdminMenu) {
|
||||
screenUrl += "?mode=admin";
|
||||
}
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: screenUrl,
|
||||
screenCode: selectedScreen.screenCode,
|
||||
screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지
|
||||
}));
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: "",
|
||||
screenCode: undefined,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// dashboard
|
||||
setSelectedScreen(null);
|
||||
setSelectedPopScreen(null);
|
||||
if (!selectedDashboard) {
|
||||
// 선택된 화면이 없으면 URL과 screenCode 초기화
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: "",
|
||||
|
|
@ -340,8 +297,8 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
|
||||
const menuUrl = menu.menu_url || menu.MENU_URL || "";
|
||||
|
||||
const isPopScreenUrl = menuUrl.startsWith("/pop/screens/");
|
||||
const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/");
|
||||
// URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정)
|
||||
const isScreenUrl = menuUrl.startsWith("/screens/");
|
||||
|
||||
setFormData({
|
||||
objid: menu.objid || menu.OBJID,
|
||||
|
|
@ -403,31 +360,10 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
}, 500);
|
||||
}
|
||||
}
|
||||
} else if (isPopScreenUrl) {
|
||||
setUrlType("pop");
|
||||
setSelectedScreen(null);
|
||||
|
||||
// [POP_LANDING] 태그 감지
|
||||
const menuDesc = menu.menu_desc || menu.MENU_DESC || "";
|
||||
setIsPopLanding(menuDesc.includes("[POP_LANDING]"));
|
||||
|
||||
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
|
||||
if (popScreenId) {
|
||||
const setPopScreenFromId = () => {
|
||||
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
|
||||
if (screen) {
|
||||
setSelectedPopScreen(screen);
|
||||
}
|
||||
};
|
||||
if (screens.length > 0) {
|
||||
setPopScreenFromId();
|
||||
} else {
|
||||
setTimeout(setPopScreenFromId, 500);
|
||||
}
|
||||
}
|
||||
} else if (menuUrl.startsWith("/dashboard/")) {
|
||||
setUrlType("dashboard");
|
||||
setSelectedScreen(null);
|
||||
// 대시보드 ID 추출 및 선택은 useEffect에서 처리됨
|
||||
} else {
|
||||
setUrlType("direct");
|
||||
setSelectedScreen(null);
|
||||
|
|
@ -472,7 +408,6 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
} else {
|
||||
console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType);
|
||||
setIsEdit(false);
|
||||
setIsPopLanding(false);
|
||||
|
||||
// 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1)
|
||||
let defaultMenuType = "1"; // 기본값은 사용자
|
||||
|
|
@ -535,31 +470,6 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
}
|
||||
}, [isOpen, formData.companyCode]);
|
||||
|
||||
// POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const checkOtherPopLanding = async () => {
|
||||
try {
|
||||
const res = await menuApi.getPopMenus();
|
||||
if (res.success && res.data?.landingMenu) {
|
||||
const landingObjId = res.data.landingMenu.objid?.toString();
|
||||
const currentObjId = formData.objid?.toString();
|
||||
// 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복
|
||||
setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId);
|
||||
} else {
|
||||
setHasOtherPopLanding(false);
|
||||
}
|
||||
} catch {
|
||||
setHasOtherPopLanding(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (urlType === "pop") {
|
||||
checkOtherPopLanding();
|
||||
}
|
||||
}, [isOpen, urlType, formData.objid]);
|
||||
|
||||
// 화면 목록 및 대시보드 목록 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
|
|
@ -607,22 +517,6 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
}
|
||||
}, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]);
|
||||
|
||||
// POP 화면 목록 로드 완료 후 기존 할당 설정
|
||||
useEffect(() => {
|
||||
if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") {
|
||||
const menuUrl = formData.menuUrl;
|
||||
if (menuUrl.startsWith("/pop/screens/")) {
|
||||
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
|
||||
if (popScreenId && !selectedPopScreen) {
|
||||
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
|
||||
if (screen) {
|
||||
setSelectedPopScreen(screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]);
|
||||
|
||||
// 드롭다운 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
|
@ -639,20 +533,16 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
setIsDashboardDropdownOpen(false);
|
||||
setDashboardSearchText("");
|
||||
}
|
||||
if (!target.closest(".pop-screen-dropdown")) {
|
||||
setIsPopScreenDropdownOpen(false);
|
||||
setPopScreenSearchText("");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) {
|
||||
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]);
|
||||
}, [isLangKeyDropdownOpen, isScreenDropdownOpen]);
|
||||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
|
|
@ -700,17 +590,10 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
try {
|
||||
setLoading(true);
|
||||
|
||||
// POP 기본 화면 태그 처리
|
||||
let finalMenuDesc = formData.menuDesc;
|
||||
if (urlType === "pop") {
|
||||
const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim();
|
||||
finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag;
|
||||
}
|
||||
|
||||
// 백엔드에 전송할 데이터 변환
|
||||
const submitData = {
|
||||
...formData,
|
||||
menuDesc: finalMenuDesc,
|
||||
// 상태를 소문자로 변환 (백엔드에서 소문자 기대)
|
||||
status: formData.status.toLowerCase(),
|
||||
};
|
||||
|
||||
|
|
@ -970,7 +853,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
||||
|
||||
{/* URL 타입 선택 */}
|
||||
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex flex-wrap gap-x-6 gap-y-2">
|
||||
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex space-x-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="screen" id="screen" />
|
||||
<Label htmlFor="screen" className="cursor-pointer">
|
||||
|
|
@ -983,12 +866,6 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
대시보드 할당
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="pop" id="pop" />
|
||||
<Label htmlFor="pop" className="cursor-pointer">
|
||||
POP 화면 할당
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="direct" id="direct" />
|
||||
<Label htmlFor="direct" className="cursor-pointer">
|
||||
|
|
@ -1154,106 +1031,6 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* POP 화면 할당 */}
|
||||
{urlType === "pop" && (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsPopScreenDropdownOpen(!isPopScreenDropdownOpen)}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="text-left">
|
||||
{selectedPopScreen ? selectedPopScreen.screenName : "POP 화면을 선택하세요"}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{isPopScreenDropdownOpen && (
|
||||
<div className="pop-screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
|
||||
<div className="sticky top-0 border-b bg-white p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="POP 화면 검색..."
|
||||
value={popScreenSearchText}
|
||||
onChange={(e) => setPopScreenSearchText(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{screens
|
||||
.filter(
|
||||
(screen) =>
|
||||
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
|
||||
)
|
||||
.map((screen, index) => (
|
||||
<div
|
||||
key={`pop-screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
|
||||
onClick={() => handlePopScreenSelect(screen)}
|
||||
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{screen.screenName}</div>
|
||||
<div className="text-xs text-gray-500">{screen.screenCode}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{screens.filter(
|
||||
(screen) =>
|
||||
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
|
||||
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500">검색 결과가 없습니다.</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPopScreen && (
|
||||
<div className="bg-accent rounded-md border p-3">
|
||||
<div className="text-sm font-medium text-blue-900">{selectedPopScreen.screenName}</div>
|
||||
<div className="text-primary text-xs">코드: {selectedPopScreen.screenCode}</div>
|
||||
<div className="text-primary text-xs">생성된 URL: {formData.menuUrl}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POP 기본 화면 설정 */}
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="popLanding"
|
||||
checked={isPopLanding}
|
||||
disabled={!isPopLanding && hasOtherPopLanding}
|
||||
onChange={(e) => setIsPopLanding(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<label
|
||||
htmlFor="popLanding"
|
||||
className={`text-sm font-medium ${!isPopLanding && hasOtherPopLanding ? "text-muted-foreground" : ""}`}
|
||||
>
|
||||
POP 기본 화면으로 설정
|
||||
</label>
|
||||
{!isPopLanding && hasOtherPopLanding && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(이미 다른 메뉴가 기본 화면으로 설정되어 있습니다)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isPopLanding && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
프로필에서 POP 모드 전환 시 이 화면으로 바로 이동합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL 직접 입력 */}
|
||||
{urlType === "direct" && (
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ 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 { Switch } from "@/components/ui/switch";
|
||||
import { Eye, EyeOff, Loader2, Monitor } from "lucide-react";
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { LoginFormData } from "@/types/auth";
|
||||
import { ErrorMessage } from "./ErrorMessage";
|
||||
|
||||
|
|
@ -12,11 +11,9 @@ interface LoginFormProps {
|
|||
isLoading: boolean;
|
||||
error: string;
|
||||
showPassword: boolean;
|
||||
isPopMode: boolean;
|
||||
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onTogglePassword: () => void;
|
||||
onTogglePop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -27,11 +24,9 @@ export function LoginForm({
|
|||
isLoading,
|
||||
error,
|
||||
showPassword,
|
||||
isPopMode,
|
||||
onInputChange,
|
||||
onSubmit,
|
||||
onTogglePassword,
|
||||
onTogglePop,
|
||||
}: LoginFormProps) {
|
||||
return (
|
||||
<Card className="border-0 shadow-xl">
|
||||
|
|
@ -87,19 +82,6 @@ export function LoginForm({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* POP 모드 토글 */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-600">POP 모드</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPopMode}
|
||||
onCheckedChange={onTogglePop}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 로그인 버튼 */}
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -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,14 +82,32 @@ 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
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{ width: scaledW, height: scaledH }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
key={`canvas-${widthMm}-${heightMm}`}
|
||||
ref={(r) => {
|
||||
(canvasRef as any).current = r;
|
||||
(canvasRef as { current: HTMLDivElement | null }).current = r;
|
||||
drop(r);
|
||||
}}
|
||||
className="relative bg-white shadow-lg"
|
||||
|
|
@ -80,5 +132,7 @@ export function BarcodeDesignerCanvas() {
|
|||
))}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -42,12 +42,9 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
||||
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
|
||||
// 바코드 리더 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setScannedCode("");
|
||||
setError("");
|
||||
setIsScanning(false);
|
||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||
}
|
||||
|
||||
|
|
@ -186,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>
|
||||
|
|
@ -279,7 +276,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
{/* 스캔 가이드 오버레이 */}
|
||||
{isScanning && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-3/5 w-4/5 rounded-lg border-4 border-primary/70 animate-pulse" />
|
||||
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
|
||||
<div className="absolute bottom-4 left-0 right-0 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
|
||||
<Scan className="h-4 w-4 animate-pulse text-primary" />
|
||||
|
|
@ -358,20 +355,6 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{scannedCode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setScannedCode("");
|
||||
startScanning();
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
다시 스캔
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{scannedCode && !autoSubmit && (
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -27,6 +27,8 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
|||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -43,6 +45,8 @@ interface ScreenModalProps {
|
|||
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
const { userId, userName, user } = useAuth();
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
const tabId = useTabId();
|
||||
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
|
||||
|
||||
const [modalState, setModalState] = useState<ScreenModalState>({
|
||||
isOpen: false,
|
||||
|
|
@ -170,6 +174,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenModal = (event: CustomEvent) => {
|
||||
// 활성 탭에서만 이벤트 처리 (다른 탭의 ScreenModal 인스턴스는 무시)
|
||||
const storeState = useTabStore.getState();
|
||||
const currentActiveTabId = storeState[storeState.mode].activeTabId;
|
||||
if (tabId && tabId !== currentActiveTabId) return;
|
||||
|
||||
const {
|
||||
screenId,
|
||||
title,
|
||||
|
|
@ -191,7 +200,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
isCreateMode,
|
||||
});
|
||||
|
||||
// 🆕 모달 열린 시간 기록
|
||||
// 모달 열린 시간 기록
|
||||
modalOpenedAtRef.current = Date.now();
|
||||
|
||||
// 폼 변경 추적 초기화
|
||||
|
|
@ -443,7 +452,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
window.removeEventListener("closeSaveModal", handleCloseModal);
|
||||
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
};
|
||||
}, [continuousMode]); // continuousMode 의존성 추가
|
||||
}, [tabId, continuousMode]);
|
||||
|
||||
// 화면 데이터 로딩
|
||||
useEffect(() => {
|
||||
|
|
@ -852,7 +861,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
} else {
|
||||
handleCloseInternal();
|
||||
}
|
||||
}, []);
|
||||
}, [tabId]);
|
||||
|
||||
// 확인 후 실제로 모달을 닫는 함수
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
|
|
@ -990,7 +999,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<Dialog
|
||||
open={modalState.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
// X 버튼 클릭 시에도 확인 다이얼로그 표시
|
||||
if (!open) {
|
||||
handleCloseAttempt();
|
||||
}
|
||||
|
|
@ -1217,6 +1225,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
isInModal={true}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -1260,6 +1269,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
isInModal={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue