Compare commits

..

24 Commits

Author SHA1 Message Date
syc0123 7853aeede9 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-05 09:03:24 +09:00
syc0123 8f3231d5a1 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-04 16:30:42 +09:00
syc0123 6ceed2acd0 feat: Implement button iconization feature for screen designer
- Added a comprehensive plan for expanding button display modes in the screen designer, allowing for text, icon, and icon+text modes.
- Introduced a new `ButtonIconRenderer` component to handle dynamic rendering of buttons based on selected display modes and actions.
- Enhanced the `ButtonConfigPanel` to include UI for selecting display modes and managing icon settings, including size, color, and position.
- Implemented functionality for custom icon addition via lucide search and SVG paste, ensuring flexibility for users.
- Updated relevant components to utilize the new button rendering logic, improving the overall user experience and visual consistency.

Made-with: Cursor
2026-03-04 16:30:05 +09:00
syc0123 a0cf9db6e8 feat: Update DropdownSelect component to display selected items based on dropdown option order
- Modified the selection display logic in the DropdownSelect component to show selected items in the order of the dropdown options, rather than the order of user selection.
- This change aims to provide a consistent and predictable user experience, reducing confusion caused by varying display orders.
- Updated the relevant documentation to reflect this new behavior and its rationale.

Made-with: Cursor
2026-03-04 11:08:42 +09:00
syc0123 ec5a980c41 feat: Add documentation for V2Select multi-select dropdown improvements
- Created a comprehensive plan document detailing the enhancements made to the V2Select multi-select dropdown, including the new display format for selected items and tooltip functionality.
- Added context notes and a checklist to track implementation progress and ensure all aspects of the feature are covered.
- Documented the rationale behind design decisions, including the need for improved user experience and visibility of selected items.

Made-with: Cursor
2026-03-04 10:12:52 +09:00
syc0123 2b324d083b feat: Improve V2Select multi-select dropdown item display
- Enhanced the display of selected items in the V2Select component to show labels in a comma-separated format, improving user visibility without needing to open the dropdown.
- Implemented tooltip functionality that activates only when the text is truncated, allowing users to see all selected items at a glance.
- Updated the DropdownSelect component to ensure consistent behavior across all screens using this component.
- Added necessary imports and state management for tooltip visibility and text truncation detection.

Made-with: Cursor
2026-03-04 10:11:48 +09:00
syc0123 cfd49020a0 feat: Implement validation error message display for required fields
- Added a new CSS class for displaying validation error messages below required input fields without affecting layout.
- Enhanced the `useDialogAutoValidation` hook to insert error messages dynamically when required fields are empty.
- Implemented logic to remove error messages when the input is cleared, improving user feedback during form validation.

Made-with: Cursor
2026-03-04 09:23:09 +09:00
syc0123 35dfe5bd79 feat: Update modal validation design and behavior
- Changed the modal validation mechanism to focus on the first empty required field and display a toast notification prompting the user to fill it.
- Removed the disabling of the save button, ensuring it remains active regardless of validation state.
- Enhanced visual feedback with a shake animation for empty fields and a red border to indicate errors.
- Updated the documentation to reflect the new validation flow and requirements.

Made-with: Cursor
2026-03-03 18:30:56 +09:00
syc0123 dfc495d32b Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-03 17:12:27 +09:00
syc0123 52c6af472d fix: Improve TabBar pointer handling and state management
- Added logic to clear the settle timer when pointer events are triggered, enhancing responsiveness during drag-and-drop interactions.
- Implemented checks to ensure pointer events are only processed if they match the current drag state, preventing unintended actions.
- Introduced a new callback for handling lost pointer capture, ensuring proper state reset and cleanup when pointer capture is lost.

Made-with: Cursor
2026-03-03 17:07:04 +09:00
syc0123 2647031ef7 feat: Enhance TabBar component with drag-and-drop functionality and drop ghost animation
- Added support for drag-and-drop functionality in the TabBar component, allowing users to reorder tabs seamlessly.
- Introduced a drop ghost feature that visually represents the target position of a dragged tab, enhancing user experience during tab reordering.
- Updated the timing for settling animations to improve responsiveness and visual feedback.
- Refactored state management to accommodate new drag-and-drop logic, ensuring smooth interactions and animations.

Made-with: Cursor
2026-03-03 16:43:56 +09:00
syc0123 7989305963 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-03 14:55:11 +09:00
syc0123 eb2bd8f10f feat: Enhance modal button behavior and validation feedback
- Updated modal button handling to disable all buttons by default, with exceptions for specific button types (e.g., cancel, close, delete).
- Introduced a new validation mechanism that visually indicates empty required fields with red borders and error messages after a delay.
- Improved the `useDialogAutoValidation` hook to manage button states based on field validation, ensuring a smoother user experience.
- Added CSS animations to prevent flickering during validation state changes.

Made-with: Cursor
2026-03-03 14:54:41 +09:00
syc0123 dca89a698f Merge remote-tracking branch 'origin/ycshin-node' into ycshin-node
Resolve conflict in InteractiveScreenViewerDynamic.tsx:
- Keep horizontal label code (fd5c61b side)
- Remove old inline required field validation (replaced by useDialogAutoValidation hook)
- Clean up checkAllRequiredFieldsFilled usage from SaveModal, ButtonPrimaryComponent
- Remove isFieldEmpty, isInputComponent, checkAllRequiredFieldsFilled from formValidation.ts

Made-with: Cursor
2026-03-03 13:12:48 +09:00
syc0123 aa020bfdd8 feat: Implement automatic validation for modal forms
- Introduced a new hook `useDialogAutoValidation` to handle automatic validation of required fields in modals.
- Added visual feedback for empty required fields, including red borders and error messages.
- Disabled action buttons when required fields are not filled, enhancing user experience.
- Updated `DialogContent` to integrate the new validation logic, ensuring that only user mode modals are validated.

Made-with: Cursor
2026-03-03 12:07:12 +09:00
syc0123 eb471d087f refactor: Update TabBar component for improved styling and functionality
- Replaced `RefreshCw` icon with `RotateCw` for better visual representation of refresh action.
- Adjusted tab height and padding for a more compact design.
- Updated text sizes for tab titles and buttons to enhance readability.
- Improved button sizes and hover effects for a more consistent user experience.
- Enhanced layout structure to ensure proper alignment and spacing of elements.

Made-with: Cursor
2026-03-03 10:23:07 +09:00
syc0123 83437e76dd feat: Enhance form validation and modal handling in various components
- Added `isInModal` prop to `ScreenModal` and `InteractiveScreenViewerDynamic` for improved modal context awareness.
- Implemented `isFieldEmpty` and `checkAllRequiredFieldsFilled` utility functions to validate required fields in forms.
- Updated `SaveModal` and `ButtonPrimaryComponent` to disable save actions when required fields are missing, enhancing user feedback.
- Introduced error messages for required fields in modals to guide users in completing necessary inputs.

Made-with: Cursor
2026-02-27 18:11:59 +09:00
syc0123 dc04bd162a refactor: Enhance modal and tab handling in ScreenModal and TabContent components
- Removed unnecessary variable `isTabActive` in ScreenModal for cleaner state management.
- Updated `useEffect` dependencies to include `tabId` for accurate modal behavior.
- Improved tab content caching logic to ensure scroll positions and form states are correctly saved and restored.
- Enhanced dialog handling to prevent unintended closures when tabs are inactive, ensuring a smoother user experience.

Made-with: Cursor
2026-02-27 16:01:23 +09:00
syc0123 a0e3147b47 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-02-27 14:26:16 +09:00
syc0123 d04dc4c050 feat: Add Zustand for state management and enhance modal handling
- Integrated Zustand for improved state management across components.
- Updated modal components to handle visibility based on active tabs, ensuring better user experience.
- Refactored various components to utilize the new tab store for managing active tab states.
- Enhanced number formatting utility to streamline number and currency display across the application.

Made-with: Cursor
2026-02-27 14:25:53 +09:00
syc0123 7acdd852a5 feat: F5 새로고침 시 다중 스크롤 영역 위치 저장/복원 지원
split panel 등 여러 스크롤 영역이 있는 화면에서 F5 새로고침 시
우측 패널 스크롤 위치가 복원되지 않던 문제 해결.

- DOM 경로 기반 다중 스크롤 위치 캡처/복원 (ScrollSnapshot)
- 실시간 스크롤 추적을 요소별 Map으로 전환
- 미사용 레거시 단일 스크롤 함수 제거 (약 130줄 정리)

Made-with: Cursor
2026-02-27 14:21:15 +09:00
syc0123 3db8a8a276 Merge remote-tracking branch 'origin/jskim-node' into ycshin-node
Made-with: Cursor

# Conflicts:
#	frontend/lib/registry/components/index.ts
2026-02-26 18:19:48 +09:00
syc0123 6d40c3ea1c Merge ycshin-node with gbpark-node: resolve component index conflict
- 양쪽 브랜치의 V2 컴포넌트 import를 모두 포함하여 충돌 해결
- gbpark-node: v2-split-line, v2-bom-tree, v2-bom-item-editor
- ycshin-node: v2-process-work-standard

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 12:45:34 +09:00
syc0123 5976e96ed8 Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-02-24 12:43:09 +09:00
199 changed files with 7452 additions and 25126 deletions

6
.gitignore vendored
View File

@ -163,12 +163,6 @@ uploads/
# ===== 기타 =====
claude.md
# Agent Pipeline 로컬 파일
_local/
.agent-pipeline/
.codeguard-baseline.json
scripts/browser-test-*.js
# AI 에이전트 테스트 산출물
*-test-screenshots/
*-screenshots/

View File

@ -1,25 +0,0 @@
# AI Assistant API (VEXPLOR 내장) - 환경 변수
# 이 파일을 .env 로 복사한 뒤 값 설정
NODE_ENV=development
PORT=3100
# PostgreSQL (AI 어시스턴트 전용 DB)
DB_HOST=localhost
DB_PORT=5432
DB_USER=ai_assistant
DB_PASSWORD=ai_assistant_password
DB_NAME=ai_assistant_db
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
JWT_REFRESH_EXPIRES_IN=30d
# LLM (구글 키 등)
GEMINI_API_KEY=your-gemini-api-key
GEMINI_MODEL=gemini-2.0-flash
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100

View File

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

View File

@ -1,43 +0,0 @@
# AI 어시스턴트 API (VEXPLOR 내장)
VEXPLOR와 **같은 서비스**로 동작하도록 이 API는 포트 3100에서 구동되고, backend-node가 `/api/ai/v1` 요청을 여기로 프록시합니다.
## 동작 방식
- **프론트(9771)**`/api/ai/v1/*` 호출
- **Next.js**`8080/api/ai/v1/*` 로 rewrite
- **backend-node(8080)**`3100/api/v1/*` 로 프록시 → **이 서비스**
따라서 사용자는 **다른 포트를 쓰지 않고** VEXPLOR만 켜도 AI 기능을 사용할 수 있습니다.
## 서비스 올리는 순서 (한 번에 동작하게)
1. **AI 어시스턴트 API (이 폴더, 포트 3100)**
```bash
cd ai-assistant
npm install
cp .env.example .env # 필요 시 DB, JWT, GEMINI_API_KEY 등 수정
npm start
```
2. **backend-node (포트 8080)**
```bash
cd backend-node
npm run dev
```
3. **프론트 (포트 9771)**
```bash
cd frontend
npm run dev
```
브라우저에서는 `http://localhost:9771` 만 사용하면 되고, AI API는 같은 오리진의 `/api/ai/v1` 로 호출됩니다.
## 환경 변수
- `.env.example``.env` 로 복사 후 수정
- `PORT=3100` (기본값)
- PostgreSQL: `DB_*`
- JWT: `JWT_SECRET`, `JWT_REFRESH_SECRET`
- LLM: `GEMINI_API_KEY`

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,186 +0,0 @@
// src/app.js
// AI Assistant API 서버 메인 엔트리포인트
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./config/swagger.config');
const logger = require('./config/logger.config');
const { sequelize } = require('./models');
const routes = require('./routes');
const errorHandler = require('./middlewares/error-handler.middleware');
const app = express();
// VEXPLOR 내장 시 backend-node가 이 포트로 프록시하므로 기본 3100 사용
const PORT = process.env.PORT || 3100;
// ===========================================
// 미들웨어 설정
// ===========================================
// Trust proxy (Docker/Nginx 환경)
app.set('trust proxy', 1);
// CORS 설정 (helmet보다 먼저 설정)
app.use(cors({
origin: true, // 모든 origin 허용
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
}));
// Preflight 요청 처리
app.options('*', cors());
// 보안 헤더 (CORS 이후에 설정)
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
crossOriginOpenerPolicy: { policy: 'unsafe-none' },
}));
// 요청 본문 파싱
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// 압축
app.use(compression());
// Rate Limiting (전역)
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// 요청 로깅
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
next();
});
// ===========================================
// 헬스 체크
// ===========================================
app.get('/health', (req, res) => {
res.json({
success: true,
data: {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
},
});
});
// ===========================================
// Swagger API 문서
// ===========================================
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
explorer: true,
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'AI Assistant API 문서',
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
},
}));
// Swagger JSON
app.get('/api-docs.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
// ===========================================
// API 라우트
// ===========================================
app.use('/api/v1', routes);
// ===========================================
// 404 처리
// ===========================================
app.use((req, res) => {
res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: `요청한 리소스를 찾을 수 없습니다: ${req.method} ${req.originalUrl}`,
},
});
});
// ===========================================
// 에러 핸들러
// ===========================================
app.use(errorHandler);
// ===========================================
// 서버 시작
// ===========================================
async function startServer() {
try {
// 데이터베이스 연결
await sequelize.authenticate();
logger.info('✅ 데이터베이스 연결 성공');
// 테이블 동기화 (테이블이 없으면 생성)
await sequelize.sync();
logger.info('✅ 데이터베이스 스키마 동기화 완료');
// 초기 데이터 설정 (관리자 계정, LLM 프로바이더)
const initService = require('./services/init.service');
await initService.initialize();
// 서버 시작
app.listen(PORT, () => {
logger.info(`🚀 AI Assistant API 서버가 포트 ${PORT}에서 실행 중입니다`);
logger.info(`📚 API 문서 (Swagger): http://localhost:${PORT}/api-docs`);
logger.info(`📚 API 엔드포인트: http://localhost:${PORT}/api/v1`);
});
} catch (error) {
logger.error('❌ 서버 시작 실패:', error);
process.exit(1);
}
}
// 프로세스 종료 처리
process.on('SIGTERM', async () => {
logger.info('SIGTERM 신호 수신, 서버 종료 중...');
await sequelize.close();
process.exit(0);
});
process.on('SIGINT', async () => {
logger.info('SIGINT 신호 수신, 서버 종료 중...');
await sequelize.close();
process.exit(0);
});
startServer();
module.exports = app;

View File

@ -1,474 +0,0 @@
// src/controllers/admin.controller.js
// 관리자 컨트롤러
const { LLMProvider, User, UsageLog, ApiKey } = require('../models');
const { Op } = require('sequelize');
const logger = require('../config/logger.config');
// ===== LLM 프로바이더 관리 =====
/**
* LLM 프로바이더 목록 조회
*/
exports.getProviders = async (req, res, next) => {
try {
const providers = await LLMProvider.findAll({
order: [['priority', 'ASC']],
attributes: [
'id',
'name',
'displayName',
'endpoint',
'modelName',
'priority',
'maxTokens',
'temperature',
'timeoutMs',
'costPer1kInputTokens',
'costPer1kOutputTokens',
'isActive',
'isHealthy',
'lastHealthCheck',
'createdAt',
'updatedAt',
// API 키는 마스킹해서 반환
'apiKey',
],
});
// API 키 마스킹
const maskedProviders = providers.map((p) => {
const data = p.toJSON();
if (data.apiKey) {
// 앞 8자만 보여주고 나머지는 마스킹
data.apiKey = data.apiKey.substring(0, 8) + '****' + data.apiKey.slice(-4);
data.hasApiKey = true;
} else {
data.hasApiKey = false;
}
return data;
});
return res.json({
success: true,
data: maskedProviders,
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 추가
*/
exports.createProvider = async (req, res, next) => {
try {
const {
name,
displayName,
endpoint,
apiKey,
modelName,
priority = 50,
maxTokens = 4096,
temperature = 0.7,
timeoutMs = 60000,
costPer1kInputTokens = 0,
costPer1kOutputTokens = 0,
} = req.body;
// 중복 이름 확인
const existing = await LLMProvider.findOne({ where: { name } });
if (existing) {
return res.status(409).json({
success: false,
error: {
code: 'PROVIDER_EXISTS',
message: '이미 존재하는 프로바이더 이름입니다.',
},
});
}
const provider = await LLMProvider.create({
name,
displayName,
endpoint,
apiKey,
modelName,
priority,
maxTokens,
temperature,
timeoutMs,
costPer1kInputTokens,
costPer1kOutputTokens,
isActive: true,
isHealthy: true,
});
logger.info(`LLM 프로바이더 추가: ${name} (${modelName})`);
return res.status(201).json({
success: true,
data: {
id: provider.id,
name: provider.name,
displayName: provider.displayName,
modelName: provider.modelName,
priority: provider.priority,
isActive: provider.isActive,
message: 'LLM 프로바이더가 추가되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 수정
*/
exports.updateProvider = async (req, res, next) => {
try {
const { id } = req.params;
const updates = req.body;
const provider = await LLMProvider.findByPk(id);
if (!provider) {
return res.status(404).json({
success: false,
error: {
code: 'PROVIDER_NOT_FOUND',
message: 'LLM 프로바이더를 찾을 수 없습니다.',
},
});
}
// 허용된 필드만 업데이트
const allowedFields = [
'displayName',
'endpoint',
'apiKey',
'modelName',
'priority',
'maxTokens',
'temperature',
'timeoutMs',
'costPer1kInputTokens',
'costPer1kOutputTokens',
'isActive',
'isHealthy',
];
allowedFields.forEach((field) => {
if (updates[field] !== undefined) {
provider[field] = updates[field];
}
});
await provider.save();
logger.info(`LLM 프로바이더 수정: ${provider.name}`);
return res.json({
success: true,
data: {
id: provider.id,
name: provider.name,
displayName: provider.displayName,
modelName: provider.modelName,
isActive: provider.isActive,
message: 'LLM 프로바이더가 수정되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
/**
* LLM 프로바이더 삭제
*/
exports.deleteProvider = async (req, res, next) => {
try {
const { id } = req.params;
const provider = await LLMProvider.findByPk(id);
if (!provider) {
return res.status(404).json({
success: false,
error: {
code: 'PROVIDER_NOT_FOUND',
message: 'LLM 프로바이더를 찾을 수 없습니다.',
},
});
}
const providerName = provider.name;
await provider.destroy();
logger.info(`LLM 프로바이더 삭제: ${providerName}`);
return res.json({
success: true,
data: {
message: 'LLM 프로바이더가 삭제되었습니다.',
},
});
} catch (error) {
return next(error);
}
};
// ===== 사용자 관리 =====
/**
* 사용자 목록 조회
*/
exports.getUsers = async (req, res, next) => {
try {
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 100;
const offset = (page - 1) * limit;
const { count, rows: users } = await User.findAndCountAll({
attributes: [
'id',
'email',
'name',
'role',
'status',
'plan',
'monthlyTokenLimit',
'lastLoginAt',
'createdAt',
],
order: [['createdAt', 'DESC']],
limit,
offset,
});
// 페이지네이션 없이 간단한 배열로 반환 (프론트엔드 호환)
return res.json({
success: true,
data: users,
});
} catch (error) {
return next(error);
}
};
/**
* 사용자 정보 수정
*/
exports.updateUser = async (req, res, next) => {
try {
const { id } = req.params;
const { role, status, plan, monthlyTokenLimit } = req.body;
const user = await User.findByPk(id);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
if (role) user.role = role;
if (status) user.status = status;
if (plan) user.plan = plan;
if (monthlyTokenLimit !== undefined) user.monthlyTokenLimit = monthlyTokenLimit;
await user.save();
logger.info(`사용자 정보 수정: ${user.email} (role: ${user.role}, status: ${user.status})`);
return res.json({
success: true,
data: user.toSafeJSON(),
});
} catch (error) {
return next(error);
}
};
// ===== 시스템 통계 =====
/**
* 사용자별 사용량 통계
*/
exports.getUsageByUser = async (req, res, next) => {
try {
const days = parseInt(req.query.days, 10) || 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
// 사용자별 집계 (raw SQL 사용)
const userStats = await UsageLog.sequelize.query(`
SELECT
u.id as "userId",
u.email,
u.name,
COALESCE(SUM(ul.total_tokens), 0) as "totalTokens",
COALESCE(SUM(ul.cost_usd), 0) as "totalCost",
COUNT(ul.id) as "requestCount"
FROM users u
LEFT JOIN usage_logs ul ON u.id = ul.user_id AND ul.created_at >= :startDate
GROUP BY u.id, u.email, u.name
HAVING COUNT(ul.id) > 0
ORDER BY SUM(ul.total_tokens) DESC NULLS LAST
`, {
replacements: { startDate },
type: UsageLog.sequelize.QueryTypes.SELECT,
});
// 데이터 정리
const result = userStats.map((stat) => ({
userId: stat.userId,
email: stat.email || 'Unknown',
name: stat.name || '',
totalTokens: parseInt(stat.totalTokens, 10) || 0,
totalCost: parseFloat(stat.totalCost) || 0,
requestCount: parseInt(stat.requestCount, 10) || 0,
}));
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
};
/**
* 프로바이더별 사용량 통계
*/
exports.getUsageByProvider = async (req, res, next) => {
try {
const days = parseInt(req.query.days, 10) || 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
// 프로바이더별 집계 (컬럼명 수정: providerName, modelName)
const providerStats = await UsageLog.findAll({
where: {
createdAt: { [Op.gte]: startDate },
},
attributes: [
'providerName',
'modelName',
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('prompt_tokens')), 'promptTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('completion_tokens')), 'completionTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
[UsageLog.sequelize.fn('AVG', UsageLog.sequelize.col('response_time_ms')), 'avgResponseTime'],
],
group: ['providerName', 'modelName'],
order: [[UsageLog.sequelize.literal('"totalTokens"'), 'DESC']],
raw: true,
});
// 데이터 정리
const result = providerStats.map((stat) => ({
provider: stat.providerName || 'Unknown',
model: stat.modelName || 'Unknown',
totalTokens: parseInt(stat.totalTokens, 10) || 0,
promptTokens: parseInt(stat.promptTokens, 10) || 0,
completionTokens: parseInt(stat.completionTokens, 10) || 0,
totalCost: parseFloat(stat.totalCost) || 0,
requestCount: parseInt(stat.requestCount, 10) || 0,
avgResponseTime: Math.round(parseFloat(stat.avgResponseTime) || 0),
}));
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
};
/**
* 시스템 통계 조회
*/
exports.getStats = async (req, res, next) => {
try {
// 전체 사용자 수
const totalUsers = await User.count();
const activeUsers = await User.count({ where: { status: 'active' } });
// 전체 API 키 수
const totalApiKeys = await ApiKey.count();
const activeApiKeys = await ApiKey.count({ where: { status: 'active' } });
// 오늘 사용량
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayUsage = await UsageLog.findOne({
where: {
createdAt: { [Op.gte]: today },
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
// 이번 달 사용량
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const monthlyUsage = await UsageLog.findOne({
where: {
createdAt: { [Op.gte]: monthStart },
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
// 활성 프로바이더 수
const activeProviders = await LLMProvider.count({ where: { isActive: true, isHealthy: true } });
return res.json({
success: true,
data: {
users: {
total: totalUsers,
active: activeUsers,
},
apiKeys: {
total: totalApiKeys,
active: activeApiKeys,
},
providers: {
active: activeProviders,
},
usage: {
today: {
tokens: parseInt(todayUsage?.totalTokens, 10) || 0,
cost: parseFloat(todayUsage?.totalCost) || 0,
requests: parseInt(todayUsage?.requestCount, 10) || 0,
},
monthly: {
tokens: parseInt(monthlyUsage?.totalTokens, 10) || 0,
cost: parseFloat(monthlyUsage?.totalCost) || 0,
requests: parseInt(monthlyUsage?.requestCount, 10) || 0,
},
},
},
});
} catch (error) {
return next(error);
}
};

View File

@ -1,215 +0,0 @@
// src/controllers/api-key.controller.js
// API 키 컨트롤러
const { ApiKey } = require('../models');
const logger = require('../config/logger.config');
/**
* API 발급
*/
exports.create = async (req, res, next) => {
try {
const { name, expiresInDays, permissions } = req.body;
const userId = req.user.userId;
// API 키 생성
const rawKey = ApiKey.generateKey();
const keyHash = ApiKey.hashKey(rawKey);
const keyPrefix = rawKey.substring(0, 12);
// 만료 일시 계산
let expiresAt = null;
if (expiresInDays) {
expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
}
const apiKey = await ApiKey.create({
userId,
name,
keyPrefix,
keyHash,
permissions: permissions || ['chat:read', 'chat:write'],
expiresAt,
});
logger.info(`API 키 발급: ${name} (user: ${userId})`);
// 주의: 원본 키는 이 응답에서만 반환됨 (다시 조회 불가)
return res.status(201).json({
success: true,
data: {
id: apiKey.id,
name: apiKey.name,
key: rawKey, // 원본 키 (한 번만 표시)
keyPrefix: apiKey.keyPrefix,
permissions: apiKey.permissions,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt,
message: '⚠️ API 키는 이 응답에서만 확인할 수 있습니다. 안전한 곳에 저장하세요.',
},
});
} catch (error) {
return next(error);
}
};
/**
* API 목록 조회
*/
exports.list = async (req, res, next) => {
try {
const userId = req.user.userId;
const apiKeys = await ApiKey.findAll({
where: { userId },
attributes: [
'id',
'name',
'keyPrefix',
'permissions',
'rateLimit',
'status',
'expiresAt',
'lastUsedAt',
'totalRequests',
'createdAt',
],
order: [['createdAt', 'DESC']],
});
return res.json({
success: true,
data: apiKeys,
});
} catch (error) {
return next(error);
}
};
/**
* API 상세 조회
*/
exports.get = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
attributes: [
'id',
'name',
'keyPrefix',
'permissions',
'rateLimit',
'status',
'expiresAt',
'lastUsedAt',
'totalRequests',
'createdAt',
'updatedAt',
],
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
return res.json({
success: true,
data: apiKey,
});
} catch (error) {
return next(error);
}
};
/**
* API 수정
*/
exports.update = async (req, res, next) => {
try {
const { id } = req.params;
const { name, status } = req.body;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
if (name) apiKey.name = name;
if (status) apiKey.status = status;
await apiKey.save();
logger.info(`API 키 수정: ${apiKey.name} (id: ${id})`);
return res.json({
success: true,
data: {
id: apiKey.id,
name: apiKey.name,
keyPrefix: apiKey.keyPrefix,
status: apiKey.status,
updatedAt: apiKey.updatedAt,
},
});
} catch (error) {
return next(error);
}
};
/**
* API 폐기
*/
exports.revoke = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.userId;
const apiKey = await ApiKey.findOne({
where: { id, userId },
});
if (!apiKey) {
return res.status(404).json({
success: false,
error: {
code: 'API_KEY_NOT_FOUND',
message: 'API 키를 찾을 수 없습니다.',
},
});
}
apiKey.status = 'revoked';
await apiKey.save();
logger.info(`API 키 폐기: ${apiKey.name} (id: ${id})`);
return res.json({
success: true,
data: {
message: 'API 키가 폐기되었습니다.',
},
});
} catch (error) {
return next(error);
}
};

View File

@ -1,195 +0,0 @@
// src/controllers/auth.controller.js
// 인증 컨트롤러
const jwt = require('jsonwebtoken');
const { User } = require('../models');
const logger = require('../config/logger.config');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret';
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '30d';
/**
* JWT 토큰 생성
*/
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ userId: user.id },
JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
);
return { accessToken, refreshToken };
}
/**
* 회원가입
*/
exports.register = async (req, res, next) => {
try {
const { email, password, name } = req.body;
// 이메일 중복 확인
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
return res.status(409).json({
success: false,
error: {
code: 'EMAIL_ALREADY_EXISTS',
message: '이미 등록된 이메일입니다.',
},
});
}
// 사용자 생성
const user = await User.create({
email,
password,
name,
});
// 토큰 생성
const tokens = generateTokens(user);
logger.info(`새 사용자 가입: ${email}`);
return res.status(201).json({
success: true,
data: {
user: user.toSafeJSON(),
...tokens,
},
});
} catch (error) {
return next(error);
}
};
/**
* 로그인
*/
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
// 사용자 조회
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_CREDENTIALS',
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
},
});
}
// 비밀번호 검증
const isValidPassword = await user.validatePassword(password);
if (!isValidPassword) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_CREDENTIALS',
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
},
});
}
// 계정 상태 확인
if (user.status !== 'active') {
return res.status(403).json({
success: false,
error: {
code: 'ACCOUNT_INACTIVE',
message: '계정이 비활성화되었습니다. 관리자에게 문의하세요.',
},
});
}
// 마지막 로그인 시간 업데이트
user.lastLoginAt = new Date();
await user.save();
// 토큰 생성
const tokens = generateTokens(user);
logger.info(`사용자 로그인: ${email}`);
return res.json({
success: true,
data: {
user: user.toSafeJSON(),
...tokens,
},
});
} catch (error) {
return next(error);
}
};
/**
* 토큰 갱신
*/
exports.refresh = async (req, res, next) => {
try {
const { refreshToken } = req.body;
// 리프레시 토큰 검증
let decoded;
try {
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
} catch (error) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_REFRESH_TOKEN',
message: '유효하지 않은 리프레시 토큰입니다.',
},
});
}
// 사용자 조회
const user = await User.findByPk(decoded.userId);
if (!user || user.status !== 'active') {
return res.status(401).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 새 토큰 생성
const tokens = generateTokens(user);
return res.json({
success: true,
data: tokens,
});
} catch (error) {
return next(error);
}
};
/**
* 로그아웃
*/
exports.logout = async (req, res) => {
// 클라이언트에서 토큰 삭제 처리
// 서버에서는 특별한 처리 없음 (필요시 블랙리스트 구현)
return res.json({
success: true,
data: {
message: '로그아웃되었습니다.',
},
});
};

View File

@ -1,152 +0,0 @@
// src/controllers/chat.controller.js
// 채팅 컨트롤러 (OpenAI 호환 API)
const llmService = require('../services/llm.service');
const logger = require('../config/logger.config');
/**
* 채팅 완성 API (OpenAI 호환)
* POST /api/v1/chat/completions
*/
exports.completions = async (req, res, next) => {
try {
const {
model = 'gemini-2.0-flash',
messages,
temperature = 0.7,
max_tokens = 4096,
stream = false,
} = req.body;
const startTime = Date.now();
// 스트리밍 응답 처리
if (stream) {
return handleStreamingResponse(req, res, {
model,
messages,
temperature,
maxTokens: max_tokens,
});
}
// 일반 응답 처리
const result = await llmService.chat({
model,
messages,
temperature,
maxTokens: max_tokens,
userId: req.user.id,
apiKeyId: req.apiKey?.id,
});
const responseTime = Date.now() - startTime;
// 사용량 정보 저장 (미들웨어에서 처리)
req.usageData = {
providerId: result.providerId,
providerName: result.provider,
modelName: result.model,
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
costUsd: result.cost,
responseTimeMs: responseTime,
success: true,
};
// OpenAI 호환 응답 형식
return res.json({
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: result.model,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: result.text,
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: result.usage.promptTokens,
completion_tokens: result.usage.completionTokens,
total_tokens: result.usage.totalTokens,
},
});
} catch (error) {
logger.error('채팅 완성 오류:', error);
// 사용량 정보 저장 (실패)
req.usageData = {
success: false,
errorMessage: error.message,
};
return next(error);
}
};
/**
* 스트리밍 응답 처리
*/
async function handleStreamingResponse(req, res, params) {
const { model, messages, temperature, maxTokens } = params;
// SSE 헤더 설정
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// 스트리밍 응답 생성
const stream = await llmService.chatStream({
model,
messages,
temperature,
maxTokens,
userId: req.user.id,
apiKeyId: req.apiKey?.id,
});
// 스트림 이벤트 처리
for await (const chunk of stream) {
const data = {
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
delta: {
content: chunk.text,
},
finish_reason: chunk.done ? 'stop' : null,
},
],
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
// 스트림 종료
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
logger.error('스트리밍 오류:', error);
const errorData = {
error: {
message: error.message,
type: 'server_error',
},
};
res.write(`data: ${JSON.stringify(errorData)}\n\n`);
res.end();
}
}

View File

@ -1,67 +0,0 @@
// src/controllers/model.controller.js
// 모델 컨트롤러
const { LLMProvider } = require('../models');
/**
* 사용 가능한 모델 목록 조회
*/
exports.list = async (req, res, next) => {
try {
const providers = await LLMProvider.getActiveProviders();
// OpenAI 호환 형식으로 변환
const models = providers.map((provider) => ({
id: provider.modelName,
object: 'model',
created: Math.floor(new Date(provider.createdAt).getTime() / 1000),
owned_by: provider.name,
permission: [],
root: provider.modelName,
parent: null,
}));
return res.json({
object: 'list',
data: models,
});
} catch (error) {
return next(error);
}
};
/**
* 모델 상세 정보 조회
*/
exports.get = async (req, res, next) => {
try {
const { id } = req.params;
const provider = await LLMProvider.findOne({
where: { modelName: id, isActive: true },
});
if (!provider) {
return res.status(404).json({
error: {
message: `모델 '${id}'을(를) 찾을 수 없습니다.`,
type: 'invalid_request_error',
param: 'model',
code: 'model_not_found',
},
});
}
return res.json({
id: provider.modelName,
object: 'model',
created: Math.floor(new Date(provider.createdAt).getTime() / 1000),
owned_by: provider.name,
permission: [],
root: provider.modelName,
parent: null,
});
} catch (error) {
return next(error);
}
};

View File

@ -1,177 +0,0 @@
// src/controllers/usage.controller.js
// 사용량 컨트롤러
const { UsageLog, User } = require('../models');
const { Op } = require('sequelize');
/**
* 사용량 요약 조회
*/
exports.getSummary = async (req, res, next) => {
try {
const userId = req.user.userId;
// 사용자 정보 조회
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 이번 달 사용량
const now = new Date();
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(
userId,
now.getFullYear(),
now.getMonth() + 1
);
// 오늘 사용량
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart);
todayEnd.setDate(todayEnd.getDate() + 1);
const todayUsage = await UsageLog.findOne({
where: {
userId,
createdAt: {
[Op.between]: [todayStart, todayEnd],
},
},
attributes: [
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'],
[UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'],
[UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'],
],
raw: true,
});
return res.json({
success: true,
data: {
plan: user.plan,
limit: {
monthly: user.monthlyTokenLimit,
remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens),
},
usage: {
today: {
tokens: parseInt(todayUsage?.totalTokens, 10) || 0,
cost: parseFloat(todayUsage?.totalCost) || 0,
requests: parseInt(todayUsage?.requestCount, 10) || 0,
},
monthly: monthlyUsage,
},
},
});
} catch (error) {
return next(error);
}
};
/**
* 일별 사용량 조회
*/
exports.getDailyUsage = async (req, res, next) => {
try {
const userId = req.user.userId;
const { startDate, endDate } = req.query;
// 기본값: 최근 30일
const end = endDate ? new Date(endDate) : new Date();
const start = startDate ? new Date(startDate) : new Date(end);
if (!startDate) {
start.setDate(start.getDate() - 30);
}
const dailyUsage = await UsageLog.getDailyUsageByUser(userId, start, end);
return res.json({
success: true,
data: {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
usage: dailyUsage,
},
});
} catch (error) {
return next(error);
}
};
/**
* 월별 사용량 조회
*/
exports.getMonthlyUsage = async (req, res, next) => {
try {
const userId = req.user.userId;
const now = new Date();
const year = parseInt(req.query.year, 10) || now.getFullYear();
const month = parseInt(req.query.month, 10) || (now.getMonth() + 1);
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(userId, year, month);
return res.json({
success: true,
data: {
year,
month,
usage: monthlyUsage,
},
});
} catch (error) {
return next(error);
}
};
/**
* 사용량 로그 목록 조회
*/
exports.getLogs = async (req, res, next) => {
try {
const userId = req.user.userId;
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 20;
const offset = (page - 1) * limit;
const { count, rows: logs } = await UsageLog.findAndCountAll({
where: { userId },
attributes: [
'id',
'providerName',
'modelName',
'promptTokens',
'completionTokens',
'totalTokens',
'costUsd',
'responseTimeMs',
'success',
'errorMessage',
'createdAt',
],
order: [['createdAt', 'DESC']],
limit,
offset,
});
return res.json({
success: true,
data: {
logs,
pagination: {
total: count,
page,
limit,
totalPages: Math.ceil(count / limit),
},
},
});
} catch (error) {
return next(error);
}
};

View File

@ -1,113 +0,0 @@
// src/controllers/user.controller.js
// 사용자 컨트롤러
const { User, UsageLog } = require('../models');
const logger = require('../config/logger.config');
/**
* 정보 조회
*/
exports.getMe = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 이번 달 사용량 조회
const now = new Date();
const monthlyUsage = await UsageLog.getMonthlyTotalByUser(
user.id,
now.getFullYear(),
now.getMonth() + 1
);
return res.json({
success: true,
data: {
...user.toSafeJSON(),
usage: {
monthly: monthlyUsage,
limit: user.monthlyTokenLimit,
remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens),
},
},
});
} catch (error) {
return next(error);
}
};
/**
* 정보 수정
*/
exports.updateMe = async (req, res, next) => {
try {
const { name, password } = req.body;
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 업데이트할 필드만 설정
if (name) user.name = name;
if (password) user.password = password;
await user.save();
logger.info(`사용자 정보 수정: ${user.email}`);
return res.json({
success: true,
data: user.toSafeJSON(),
});
} catch (error) {
return next(error);
}
};
/**
* 계정 삭제
*/
exports.deleteMe = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: '사용자를 찾을 수 없습니다.',
},
});
}
// 소프트 삭제 (상태 변경)
user.status = 'inactive';
await user.save();
logger.info(`사용자 계정 삭제: ${user.email}`);
return res.json({
success: true,
data: {
message: '계정이 삭제되었습니다.',
},
});
} catch (error) {
return next(error);
}
};

View File

@ -1,257 +0,0 @@
// src/middlewares/auth.middleware.js
// 인증 미들웨어
const jwt = require('jsonwebtoken');
const { ApiKey, User } = require('../models');
const logger = require('../config/logger.config');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
/**
* JWT 토큰 인증 미들웨어
* Authorization: Bearer <JWT_TOKEN>
*/
exports.authenticateJWT = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: {
code: 'UNAUTHORIZED',
message: '인증 토큰이 필요합니다.',
},
});
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
return next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: {
code: 'TOKEN_EXPIRED',
message: '토큰이 만료되었습니다.',
},
});
}
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
} catch (error) {
return next(error);
}
};
/**
* API 인증 미들웨어
* Authorization: Bearer <API_KEY>
*/
exports.authenticateApiKey = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: {
message: 'API 키가 필요합니다.',
type: 'invalid_request_error',
code: 'missing_api_key',
},
});
}
const apiKeyValue = authHeader.substring(7);
// API 키 접두사 확인
const prefix = process.env.API_KEY_PREFIX || 'sk-';
if (!apiKeyValue.startsWith(prefix)) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키 형식입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
// API 키 조회
const apiKey = await ApiKey.findByKey(apiKeyValue);
if (!apiKey) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
// 만료 확인
if (apiKey.isExpired()) {
return res.status(401).json({
error: {
message: 'API 키가 만료되었습니다.',
type: 'invalid_request_error',
code: 'expired_api_key',
},
});
}
// 사용자 상태 확인
if (apiKey.user.status !== 'active') {
return res.status(403).json({
error: {
message: '계정이 비활성화되었습니다.',
type: 'invalid_request_error',
code: 'account_inactive',
},
});
}
// 사용 기록 업데이트
await apiKey.recordUsage();
// 요청 객체에 사용자 및 API 키 정보 추가
req.user = {
id: apiKey.user.id,
userId: apiKey.user.id,
email: apiKey.user.email,
role: apiKey.user.role,
plan: apiKey.user.plan,
};
req.apiKey = apiKey;
return next();
} catch (error) {
logger.error('API 키 인증 오류:', error);
return next(error);
}
};
/**
* 관리자 권한 확인 미들웨어
*/
exports.requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
error: {
code: 'FORBIDDEN',
message: '관리자 권한이 필요합니다.',
},
});
}
return next();
};
/**
* JWT 또는 API 인증 미들웨어
* JWT 토큰과 API 모두 허용
*/
exports.authenticateAny = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: {
code: 'UNAUTHORIZED',
message: '인증이 필요합니다.',
},
});
}
const token = authHeader.substring(7);
const prefix = process.env.API_KEY_PREFIX || 'sk-';
// API 키인 경우
if (token.startsWith(prefix)) {
const apiKey = await ApiKey.findByKey(token);
if (!apiKey) {
return res.status(401).json({
error: {
message: '유효하지 않은 API 키입니다.',
type: 'invalid_request_error',
code: 'invalid_api_key',
},
});
}
if (apiKey.isExpired()) {
return res.status(401).json({
error: {
message: 'API 키가 만료되었습니다.',
type: 'invalid_request_error',
code: 'expired_api_key',
},
});
}
if (apiKey.user.status !== 'active') {
return res.status(403).json({
error: {
message: '계정이 비활성화되었습니다.',
type: 'invalid_request_error',
code: 'account_inactive',
},
});
}
await apiKey.recordUsage();
req.user = {
id: apiKey.user.id,
userId: apiKey.user.id,
email: apiKey.user.email,
role: apiKey.user.role,
plan: apiKey.user.plan,
};
req.apiKey = apiKey;
return next();
}
// JWT 토큰인 경우
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
return next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: {
code: 'TOKEN_EXPIRED',
message: '토큰이 만료되었습니다.',
},
});
}
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
} catch (error) {
return next(error);
}
};

View File

@ -1,80 +0,0 @@
// src/middlewares/error-handler.middleware.js
// 에러 핸들러 미들웨어
const logger = require('../config/logger.config');
/**
* 전역 에러 핸들러
*/
module.exports = (err, req, res, _next) => {
// 에러 로깅
logger.error('에러 발생:', {
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
// Sequelize 유효성 검사 에러
if (err.name === 'SequelizeValidationError') {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '데이터 유효성 검사 실패',
details: err.errors.map((e) => ({
field: e.path,
message: e.message,
})),
},
});
}
// Sequelize 고유 제약 조건 에러
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json({
success: false,
error: {
code: 'DUPLICATE_ENTRY',
message: '중복된 데이터가 존재합니다.',
details: err.errors.map((e) => ({
field: e.path,
message: e.message,
})),
},
});
}
// JWT 에러
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.',
},
});
}
// 기본 에러 응답
const statusCode = err.statusCode || 500;
const message = err.message || '서버 오류가 발생했습니다.';
// 프로덕션 환경에서는 상세 에러 숨김
const response = {
success: false,
error: {
code: err.code || 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production' && statusCode === 500
? '서버 오류가 발생했습니다.'
: message,
},
};
// 개발 환경에서는 스택 트레이스 포함
if (process.env.NODE_ENV === 'development') {
response.error.stack = err.stack;
}
return res.status(statusCode).json(response);
};

View File

@ -1,50 +0,0 @@
// src/middlewares/usage-logger.middleware.js
// 사용량 로깅 미들웨어
const { UsageLog } = require('../models');
const logger = require('../config/logger.config');
/**
* 사용량 로깅 미들웨어
* 응답 완료 사용량 정보를 데이터베이스에 저장
*/
exports.usageLogger = (req, res, next) => {
// 응답 완료 후 처리
res.on('finish', async () => {
try {
// 사용량 데이터가 없으면 스킵
if (!req.usageData) {
return;
}
const usageData = {
userId: req.user?.id || req.user?.userId,
apiKeyId: req.apiKey?.id || null,
providerId: req.usageData.providerId || null,
providerName: req.usageData.providerName || null,
modelName: req.usageData.modelName || null,
promptTokens: req.usageData.promptTokens || 0,
completionTokens: req.usageData.completionTokens || 0,
totalTokens: req.usageData.totalTokens || 0,
costUsd: req.usageData.costUsd || 0,
responseTimeMs: req.usageData.responseTimeMs || null,
success: req.usageData.success !== false,
errorMessage: req.usageData.errorMessage || null,
requestIp: req.ip || req.connection?.remoteAddress,
userAgent: req.headers['user-agent'] || null,
};
await UsageLog.create(usageData);
logger.debug('사용량 로그 저장:', {
userId: usageData.userId,
tokens: usageData.totalTokens,
cost: usageData.costUsd,
});
} catch (error) {
logger.error('사용량 로그 저장 실패:', error);
}
});
next();
};

View File

@ -1,30 +0,0 @@
// src/middlewares/validation.middleware.js
// 유효성 검사 미들웨어
const { validationResult } = require('express-validator');
/**
* 요청 유효성 검사 결과 처리
*/
exports.validateRequest = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const formattedErrors = errors.array().map((error) => ({
field: error.path,
message: error.msg,
value: error.value,
}));
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '입력값이 올바르지 않습니다.',
details: formattedErrors,
},
});
}
return next();
};

View File

@ -1,130 +0,0 @@
// src/models/api-key.model.js
// API 키 모델
const { DataTypes } = require('sequelize');
const crypto = require('crypto');
module.exports = (sequelize) => {
const ApiKey = sequelize.define('ApiKey', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
comment: '소유자 사용자 ID',
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: 'API 키 이름 (사용자 지정)',
},
keyPrefix: {
type: DataTypes.STRING(12),
allowNull: false,
comment: 'API 키 접두사 (표시용)',
},
keyHash: {
type: DataTypes.STRING(64),
allowNull: false,
unique: true,
comment: 'API 키 해시 (SHA-256)',
},
permissions: {
type: DataTypes.JSONB,
defaultValue: ['chat:read', 'chat:write'],
comment: '권한 목록',
},
rateLimit: {
type: DataTypes.INTEGER,
defaultValue: 60, // 분당 60회
comment: '분당 요청 제한',
},
status: {
type: DataTypes.ENUM('active', 'revoked', 'expired'),
defaultValue: 'active',
comment: 'API 키 상태',
},
expiresAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '만료 일시 (null이면 무기한)',
},
lastUsedAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 사용 시간',
},
totalRequests: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '총 요청 수',
},
}, {
tableName: 'api_keys',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['key_hash'],
unique: true,
},
{
fields: ['user_id'],
},
{
fields: ['status'],
},
],
});
// 클래스 메서드: API 키 생성
ApiKey.generateKey = function() {
const prefix = process.env.API_KEY_PREFIX || 'sk-';
const length = parseInt(process.env.API_KEY_LENGTH, 10) || 48;
const randomPart = crypto.randomBytes(length).toString('base64url').slice(0, length);
return `${prefix}${randomPart}`;
};
// 클래스 메서드: API 키 해시 생성
ApiKey.hashKey = function(key) {
return crypto.createHash('sha256').update(key).digest('hex');
};
// 클래스 메서드: API 키로 조회
ApiKey.findByKey = async function(key) {
const keyHash = this.hashKey(key);
const apiKey = await this.findOne({
where: { keyHash, status: 'active' },
});
if (apiKey) {
// 사용자 정보 별도 조회
const { User } = require('./index');
apiKey.user = await User.findByPk(apiKey.userId);
}
return apiKey;
};
// 인스턴스 메서드: 사용 기록 업데이트
ApiKey.prototype.recordUsage = async function() {
this.lastUsedAt = new Date();
this.totalRequests += 1;
await this.save();
};
// 인스턴스 메서드: 만료 여부 확인
ApiKey.prototype.isExpired = function() {
if (!this.expiresAt) return false;
return new Date() > this.expiresAt;
};
return ApiKey;
};

View File

@ -1,55 +0,0 @@
// src/models/index.js
// Sequelize 모델 인덱스
const { Sequelize } = require('sequelize');
const config = require('../config/database.config');
const env = process.env.NODE_ENV || 'development';
const dbConfig = config[env];
// Sequelize 인스턴스 생성
const sequelize = new Sequelize(
dbConfig.database,
dbConfig.username,
dbConfig.password,
{
host: dbConfig.host,
port: dbConfig.port,
dialect: dbConfig.dialect,
logging: dbConfig.logging,
pool: dbConfig.pool,
dialectOptions: dbConfig.dialectOptions,
}
);
// 모델 임포트
const User = require('./user.model')(sequelize);
const ApiKey = require('./api-key.model')(sequelize);
const UsageLog = require('./usage-log.model')(sequelize);
const LLMProvider = require('./llm-provider.model')(sequelize);
// 관계 설정
// User - ApiKey (1:N)
User.hasMany(ApiKey, { foreignKey: 'userId', as: 'apiKeys' });
ApiKey.belongsTo(User, { foreignKey: 'userId', as: 'user' });
// User - UsageLog (1:N)
User.hasMany(UsageLog, { foreignKey: 'userId', as: 'usageLogs' });
UsageLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
// ApiKey - UsageLog (1:N)
ApiKey.hasMany(UsageLog, { foreignKey: 'apiKeyId', as: 'usageLogs' });
UsageLog.belongsTo(ApiKey, { foreignKey: 'apiKeyId', as: 'apiKey' });
// LLMProvider - UsageLog (1:N)
LLMProvider.hasMany(UsageLog, { foreignKey: 'providerId', as: 'usageLogs' });
UsageLog.belongsTo(LLMProvider, { foreignKey: 'providerId', as: 'provider' });
module.exports = {
sequelize,
Sequelize,
User,
ApiKey,
UsageLog,
LLMProvider,
};

View File

@ -1,143 +0,0 @@
// src/models/llm-provider.model.js
// LLM 프로바이더 모델
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const LLMProvider = sequelize.define('LLMProvider', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
comment: '프로바이더 이름 (gemini, openai, claude 등)',
},
displayName: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '표시 이름',
},
endpoint: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'API 엔드포인트 URL',
},
apiKey: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'API 키 (암호화 저장 권장)',
},
modelName: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '기본 모델 이름',
},
priority: {
type: DataTypes.INTEGER,
defaultValue: 100,
comment: '우선순위 (낮을수록 우선)',
},
maxTokens: {
type: DataTypes.INTEGER,
defaultValue: 4096,
comment: '최대 토큰 수',
},
temperature: {
type: DataTypes.FLOAT,
defaultValue: 0.7,
comment: '기본 온도',
},
timeoutMs: {
type: DataTypes.INTEGER,
defaultValue: 60000,
comment: '타임아웃 (밀리초)',
},
costPer1kInputTokens: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '입력 토큰 1K당 비용 (USD)',
},
costPer1kOutputTokens: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '출력 토큰 1K당 비용 (USD)',
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '활성화 여부',
},
isHealthy: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '건강 상태',
},
lastHealthCheck: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 헬스 체크 시간',
},
healthCheckUrl: {
type: DataTypes.STRING(500),
allowNull: true,
comment: '헬스 체크 URL',
},
config: {
type: DataTypes.JSONB,
defaultValue: {},
comment: '추가 설정',
},
}, {
tableName: 'llm_providers',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['name'],
unique: true,
},
{
fields: ['priority'],
},
{
fields: ['is_active', 'is_healthy'],
},
],
});
// 클래스 메서드: 활성 프로바이더 목록 조회 (우선순위 순)
LLMProvider.getActiveProviders = async function() {
return this.findAll({
where: { isActive: true },
order: [['priority', 'ASC']],
});
};
// 클래스 메서드: 건강한 프로바이더 목록 조회
LLMProvider.getHealthyProviders = async function() {
return this.findAll({
where: { isActive: true, isHealthy: true },
order: [['priority', 'ASC']],
});
};
// 인스턴스 메서드: 헬스 상태 업데이트
LLMProvider.prototype.updateHealth = async function(isHealthy) {
this.isHealthy = isHealthy;
this.lastHealthCheck = new Date();
await this.save();
};
// 인스턴스 메서드: 비용 계산
LLMProvider.prototype.calculateCost = function(promptTokens, completionTokens) {
const inputCost = (promptTokens / 1000) * parseFloat(this.costPer1kInputTokens || 0);
const outputCost = (completionTokens / 1000) * parseFloat(this.costPer1kOutputTokens || 0);
return inputCost + outputCost;
};
return LLMProvider;
};

View File

@ -1,164 +0,0 @@
// src/models/usage-log.model.js
// 사용량 로그 모델
const { DataTypes, Op } = require('sequelize');
module.exports = (sequelize) => {
const UsageLog = sequelize.define('UsageLog', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
comment: '사용자 ID',
},
apiKeyId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'api_keys',
key: 'id',
},
comment: 'API 키 ID',
},
providerId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'llm_providers',
key: 'id',
},
comment: 'LLM 프로바이더 ID',
},
providerName: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'LLM 프로바이더 이름',
},
modelName: {
type: DataTypes.STRING(100),
allowNull: true,
comment: '사용된 모델 이름',
},
promptTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '프롬프트 토큰 수',
},
completionTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '완성 토큰 수',
},
totalTokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '총 토큰 수',
},
costUsd: {
type: DataTypes.DECIMAL(10, 6),
defaultValue: 0,
comment: '비용 (USD)',
},
responseTimeMs: {
type: DataTypes.INTEGER,
allowNull: true,
comment: '응답 시간 (밀리초)',
},
success: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '성공 여부',
},
errorMessage: {
type: DataTypes.TEXT,
allowNull: true,
comment: '에러 메시지',
},
requestIp: {
type: DataTypes.STRING(45),
allowNull: true,
comment: '요청 IP 주소',
},
userAgent: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'User-Agent',
},
}, {
tableName: 'usage_logs',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['user_id'],
},
{
fields: ['api_key_id'],
},
{
fields: ['created_at'],
},
{
fields: ['provider_name'],
},
],
});
// 클래스 메서드: 사용자별 일별 사용량 조회
UsageLog.getDailyUsageByUser = async function(userId, startDate, endDate) {
return this.findAll({
where: {
userId,
createdAt: {
[Op.between]: [startDate, endDate],
},
},
attributes: [
[sequelize.fn('DATE', sequelize.col('created_at')), 'date'],
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'],
[sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'],
[sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'],
],
group: [sequelize.fn('DATE', sequelize.col('created_at'))],
order: [[sequelize.fn('DATE', sequelize.col('created_at')), 'ASC']],
raw: true,
});
};
// 클래스 메서드: 사용자별 월간 총 사용량 조회
UsageLog.getMonthlyTotalByUser = async function(userId, year, month) {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0, 23, 59, 59);
const result = await this.findOne({
where: {
userId,
createdAt: {
[Op.between]: [startDate, endDate],
},
},
attributes: [
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'],
[sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'],
[sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'],
],
raw: true,
});
return {
totalTokens: parseInt(result.totalTokens, 10) || 0,
totalCost: parseFloat(result.totalCost) || 0,
requestCount: parseInt(result.requestCount, 10) || 0,
};
};
return UsageLog;
};

View File

@ -1,92 +0,0 @@
// src/models/user.model.js
// 사용자 모델
const { DataTypes } = require('sequelize');
const bcrypt = require('bcryptjs');
module.exports = (sequelize) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
email: {
type: DataTypes.STRING(255),
allowNull: false,
unique: true,
validate: {
isEmail: true,
},
comment: '이메일 (로그인 ID)',
},
password: {
type: DataTypes.STRING(255),
allowNull: false,
comment: '비밀번호 (해시)',
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '사용자 이름',
},
role: {
type: DataTypes.ENUM('user', 'admin'),
defaultValue: 'user',
comment: '역할 (user: 일반 사용자, admin: 관리자)',
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
defaultValue: 'active',
comment: '계정 상태',
},
plan: {
type: DataTypes.ENUM('free', 'basic', 'pro', 'enterprise'),
defaultValue: 'free',
comment: '요금제 플랜',
},
monthlyTokenLimit: {
type: DataTypes.INTEGER,
defaultValue: 100000, // 무료 플랜 기본 10만 토큰
comment: '월간 토큰 한도',
},
lastLoginAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '마지막 로그인 시간',
},
}, {
tableName: 'users',
timestamps: true,
underscored: true,
hooks: {
// 비밀번호 해싱
beforeCreate: async (user) => {
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
},
});
// 인스턴스 메서드: 비밀번호 검증
User.prototype.validatePassword = async function(password) {
return bcrypt.compare(password, this.password);
};
// 인스턴스 메서드: 안전한 JSON 변환 (비밀번호 제외)
User.prototype.toSafeJSON = function() {
const values = { ...this.get() };
delete values.password;
return values;
};
return User;
};

View File

@ -1,151 +0,0 @@
// src/routes/admin.routes.js
// 관리자 라우트
const express = require('express');
const { body, param } = require('express-validator');
const adminController = require('../controllers/admin.controller');
const { authenticateJWT, requireAdmin } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 + 관리자 권한 필요
router.use(authenticateJWT);
router.use(requireAdmin);
// ===== LLM 프로바이더 관리 =====
/**
* GET /api/v1/admin/providers
* LLM 프로바이더 목록 조회
*/
router.get('/providers', adminController.getProviders);
/**
* POST /api/v1/admin/providers
* LLM 프로바이더 추가
*/
router.post(
'/providers',
[
body('name')
.trim()
.isLength({ min: 1, max: 50 })
.withMessage('프로바이더 이름은 1-50자 사이여야 합니다'),
body('displayName')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('표시 이름은 1-100자 사이여야 합니다'),
body('modelName')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('모델 이름은 1-100자 사이여야 합니다'),
body('apiKey')
.optional()
.isString(),
body('priority')
.optional()
.isInt({ min: 1, max: 100 }),
validateRequest,
],
adminController.createProvider
);
/**
* PATCH /api/v1/admin/providers/:id
* LLM 프로바이더 수정 (API 설정 포함)
*/
router.patch(
'/providers/:id',
[
param('id')
.isUUID()
.withMessage('유효한 프로바이더 ID가 아닙니다'),
body('apiKey')
.optional()
.isString(),
body('modelName')
.optional()
.isString(),
body('isActive')
.optional()
.isBoolean(),
body('priority')
.optional()
.isInt({ min: 1, max: 100 }),
validateRequest,
],
adminController.updateProvider
);
/**
* DELETE /api/v1/admin/providers/:id
* LLM 프로바이더 삭제
*/
router.delete(
'/providers/:id',
[
param('id')
.isUUID()
.withMessage('유효한 프로바이더 ID가 아닙니다'),
validateRequest,
],
adminController.deleteProvider
);
// ===== 사용자 관리 =====
/**
* GET /api/v1/admin/users
* 사용자 목록 조회
*/
router.get('/users', adminController.getUsers);
/**
* PATCH /api/v1/admin/users/:id
* 사용자 정보 수정 (역할, 상태, 플랜 )
*/
router.patch(
'/users/:id',
[
param('id')
.isUUID()
.withMessage('유효한 사용자 ID가 아닙니다'),
body('role')
.optional()
.isIn(['user', 'admin']),
body('status')
.optional()
.isIn(['active', 'inactive', 'suspended']),
body('plan')
.optional()
.isIn(['free', 'basic', 'pro', 'enterprise']),
body('monthlyTokenLimit')
.optional()
.isInt({ min: 0 }),
validateRequest,
],
adminController.updateUser
);
// ===== 시스템 통계 =====
/**
* GET /api/v1/admin/stats
* 시스템 통계 조회
*/
router.get('/stats', adminController.getStats);
/**
* GET /api/v1/admin/usage/by-user
* 사용자별 사용량 통계
*/
router.get('/usage/by-user', adminController.getUsageByUser);
/**
* GET /api/v1/admin/usage/by-provider
* 프로바이더별 사용량 통계
*/
router.get('/usage/by-provider', adminController.getUsageByProvider);
module.exports = router;

View File

@ -1,99 +0,0 @@
// src/routes/api-key.routes.js
// API 키 라우트
const express = require('express');
const { body, param } = require('express-validator');
const apiKeyController = require('../controllers/api-key.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* POST /api/v1/api-keys
* API 발급
*/
router.post(
'/',
[
body('name')
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('API 키 이름은 1-100자 사이여야 합니다'),
body('expiresInDays')
.optional()
.isInt({ min: 1, max: 365 })
.withMessage('만료 기간은 1-365일 사이여야 합니다'),
body('permissions')
.optional()
.isArray()
.withMessage('권한은 배열이어야 합니다'),
validateRequest,
],
apiKeyController.create
);
/**
* GET /api/v1/api-keys
* API 목록 조회
*/
router.get('/', apiKeyController.list);
/**
* GET /api/v1/api-keys/:id
* API 상세 조회
*/
router.get(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
validateRequest,
],
apiKeyController.get
);
/**
* PATCH /api/v1/api-keys/:id
* API 수정
*/
router.patch(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
body('name')
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage('API 키 이름은 1-100자 사이여야 합니다'),
body('status')
.optional()
.isIn(['active', 'revoked'])
.withMessage('상태는 active 또는 revoked여야 합니다'),
validateRequest,
],
apiKeyController.update
);
/**
* DELETE /api/v1/api-keys/:id
* API 폐기
*/
router.delete(
'/:id',
[
param('id')
.isUUID()
.withMessage('유효한 API 키 ID가 아닙니다'),
validateRequest,
],
apiKeyController.revoke
);
module.exports = router;

View File

@ -1,76 +0,0 @@
// src/routes/auth.routes.js
// 인증 라우트
const express = require('express');
const { body } = require('express-validator');
const authController = require('../controllers/auth.controller');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
/**
* POST /api/v1/auth/register
* 회원가입
*/
router.post(
'/register',
[
body('email')
.isEmail()
.normalizeEmail()
.withMessage('유효한 이메일 주소를 입력해주세요'),
body('password')
.isLength({ min: 8 })
.withMessage('비밀번호는 최소 8자 이상이어야 합니다')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'),
body('name')
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('이름은 2-100자 사이여야 합니다'),
validateRequest,
],
authController.register
);
/**
* POST /api/v1/auth/login
* 로그인
*/
router.post(
'/login',
[
body('email')
.isEmail()
.normalizeEmail()
.withMessage('유효한 이메일 주소를 입력해주세요'),
body('password')
.notEmpty()
.withMessage('비밀번호를 입력해주세요'),
validateRequest,
],
authController.login
);
/**
* POST /api/v1/auth/refresh
* 토큰 갱신
*/
router.post(
'/refresh',
[
body('refreshToken')
.notEmpty()
.withMessage('리프레시 토큰을 입력해주세요'),
validateRequest,
],
authController.refresh
);
/**
* POST /api/v1/auth/logout
* 로그아웃
*/
router.post('/logout', authController.logout);
module.exports = router;

View File

@ -1,55 +0,0 @@
// src/routes/chat.routes.js
// 채팅 API 라우트 (OpenAI 호환)
const express = require('express');
const { body } = require('express-validator');
const chatController = require('../controllers/chat.controller');
const { authenticateAny } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const { usageLogger } = require('../middlewares/usage-logger.middleware');
const router = express.Router();
/**
* POST /api/v1/chat/completions
* 채팅 완성 API (OpenAI 호환)
*
* 인증: Bearer API_KEY 또는 JWT 토큰
*/
router.post(
'/completions',
authenticateAny,
[
body('model')
.optional()
.isString()
.withMessage('모델은 문자열이어야 합니다'),
body('messages')
.isArray({ min: 1 })
.withMessage('메시지 배열이 필요합니다'),
body('messages.*.role')
.isIn(['system', 'user', 'assistant'])
.withMessage('메시지 역할은 system, user, assistant 중 하나여야 합니다'),
body('messages.*.content')
.isString()
.notEmpty()
.withMessage('메시지 내용이 필요합니다'),
body('temperature')
.optional()
.isFloat({ min: 0, max: 2 })
.withMessage('온도는 0-2 사이여야 합니다'),
body('max_tokens')
.optional()
.isInt({ min: 1, max: 128000 })
.withMessage('최대 토큰은 1-128000 사이여야 합니다'),
body('stream')
.optional()
.isBoolean()
.withMessage('스트림은 불리언이어야 합니다'),
validateRequest,
],
usageLogger,
chatController.completions
);
module.exports = router;

View File

@ -1,45 +0,0 @@
// src/routes/index.js
// API 라우트 인덱스
const express = require('express');
const authRoutes = require('./auth.routes');
const userRoutes = require('./user.routes');
const apiKeyRoutes = require('./api-key.routes');
const chatRoutes = require('./chat.routes');
const usageRoutes = require('./usage.routes');
const modelRoutes = require('./model.routes');
const adminRoutes = require('./admin.routes');
const router = express.Router();
// API 정보
router.get('/', (req, res) => {
res.json({
success: true,
data: {
name: 'AI Assistant API',
version: '1.0.0',
description: 'LLM API Platform - OpenAI 호환 API',
endpoints: {
auth: '/api/v1/auth',
users: '/api/v1/users',
apiKeys: '/api/v1/api-keys',
chat: '/api/v1/chat',
models: '/api/v1/models',
usage: '/api/v1/usage',
},
documentation: 'https://docs.example.com',
},
});
});
// 라우트 등록
router.use('/auth', authRoutes);
router.use('/users', userRoutes);
router.use('/api-keys', apiKeyRoutes);
router.use('/chat', chatRoutes);
router.use('/models', modelRoutes);
router.use('/usage', usageRoutes);
router.use('/admin', adminRoutes);
module.exports = router;

View File

@ -1,24 +0,0 @@
// src/routes/model.routes.js
// 모델 라우트
const express = require('express');
const modelController = require('../controllers/model.controller');
const { authenticateAny } = require('../middlewares/auth.middleware');
const router = express.Router();
/**
* GET /api/v1/models
* 사용 가능한 모델 목록 조회
* JWT 토큰 또는 API 키로 인증
*/
router.get('/', authenticateAny, modelController.list);
/**
* GET /api/v1/models/:id
* 모델 상세 정보 조회
* JWT 토큰 또는 API 키로 인증
*/
router.get('/:id', authenticateAny, modelController.get);
module.exports = router;

View File

@ -1,81 +0,0 @@
// src/routes/usage.routes.js
// 사용량 라우트
const express = require('express');
const { query } = require('express-validator');
const usageController = require('../controllers/usage.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* GET /api/v1/usage
* 사용량 요약 조회
*/
router.get('/', usageController.getSummary);
/**
* GET /api/v1/usage/daily
* 일별 사용량 조회
*/
router.get(
'/daily',
[
query('startDate')
.optional()
.isISO8601()
.withMessage('시작 날짜는 ISO 8601 형식이어야 합니다'),
query('endDate')
.optional()
.isISO8601()
.withMessage('종료 날짜는 ISO 8601 형식이어야 합니다'),
validateRequest,
],
usageController.getDailyUsage
);
/**
* GET /api/v1/usage/monthly
* 월별 사용량 조회
*/
router.get(
'/monthly',
[
query('year')
.optional()
.isInt({ min: 2020, max: 2100 })
.withMessage('연도는 2020-2100 사이여야 합니다'),
query('month')
.optional()
.isInt({ min: 1, max: 12 })
.withMessage('월은 1-12 사이여야 합니다'),
validateRequest,
],
usageController.getMonthlyUsage
);
/**
* GET /api/v1/usage/logs
* 사용량 로그 목록 조회
*/
router.get(
'/logs',
[
query('page')
.optional()
.isInt({ min: 1 })
.withMessage('페이지는 1 이상이어야 합니다'),
query('limit')
.optional()
.isInt({ min: 1, max: 100 })
.withMessage('한도는 1-100 사이여야 합니다'),
validateRequest,
],
usageController.getLogs
);
module.exports = router;

View File

@ -1,50 +0,0 @@
// src/routes/user.routes.js
// 사용자 라우트
const express = require('express');
const { body } = require('express-validator');
const userController = require('../controllers/user.controller');
const { authenticateJWT } = require('../middlewares/auth.middleware');
const { validateRequest } = require('../middlewares/validation.middleware');
const router = express.Router();
// 모든 라우트에 JWT 인증 적용
router.use(authenticateJWT);
/**
* GET /api/v1/users/me
* 정보 조회
*/
router.get('/me', userController.getMe);
/**
* PATCH /api/v1/users/me
* 정보 수정
*/
router.patch(
'/me',
[
body('name')
.optional()
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('이름은 2-100자 사이여야 합니다'),
body('password')
.optional()
.isLength({ min: 8 })
.withMessage('비밀번호는 최소 8자 이상이어야 합니다')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'),
validateRequest,
],
userController.updateMe
);
/**
* DELETE /api/v1/users/me
* 계정 삭제
*/
router.delete('/me', userController.deleteMe);
module.exports = router;

View File

@ -1,74 +0,0 @@
// src/seeders/001-llm-providers.js
// LLM 프로바이더 시드 데이터
const { v4: uuidv4 } = require('uuid');
module.exports = {
up: async (queryInterface, Sequelize) => {
const now = new Date();
await queryInterface.bulkInsert('llm_providers', [
{
id: uuidv4(),
name: 'gemini',
display_name: 'Google Gemini',
endpoint: null, // SDK 사용
api_key: process.env.GEMINI_API_KEY || '',
model_name: 'gemini-2.0-flash',
priority: 1,
max_tokens: 8192,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00025,
cost_per_1k_output_tokens: 0.001,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
{
id: uuidv4(),
name: 'openai',
display_name: 'OpenAI GPT',
endpoint: 'https://api.openai.com/v1/chat/completions',
api_key: process.env.OPENAI_API_KEY || '',
model_name: 'gpt-4o-mini',
priority: 2,
max_tokens: 4096,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00015,
cost_per_1k_output_tokens: 0.0006,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
{
id: uuidv4(),
name: 'claude',
display_name: 'Anthropic Claude',
endpoint: 'https://api.anthropic.com/v1/messages',
api_key: process.env.CLAUDE_API_KEY || '',
model_name: 'claude-3-haiku-20240307',
priority: 3,
max_tokens: 4096,
temperature: 0.7,
timeout_ms: 60000,
cost_per_1k_input_tokens: 0.00025,
cost_per_1k_output_tokens: 0.00125,
is_active: true,
is_healthy: true,
config: JSON.stringify({}),
created_at: now,
updated_at: now,
},
]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('llm_providers', null, {});
},
};

View File

@ -1,128 +0,0 @@
// src/services/init.service.js
// 초기 데이터 설정 서비스
const { User, LLMProvider } = require('../models');
const logger = require('../config/logger.config');
/**
* 초기 관리자 계정 생성
*/
async function createDefaultAdmin() {
try {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@admin.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'Admin123!';
const existing = await User.findOne({ where: { email: adminEmail } });
if (existing) {
logger.info(`관리자 계정 이미 존재: ${adminEmail}`);
return existing;
}
const admin = await User.create({
email: adminEmail,
password: adminPassword,
name: '관리자',
role: 'admin',
status: 'active',
plan: 'enterprise',
monthlyTokenLimit: 10000000, // 1000만 토큰
});
logger.info(`✅ 기본 관리자 계정 생성: ${adminEmail}`);
return admin;
} catch (error) {
logger.error('관리자 계정 생성 실패:', error);
throw error;
}
}
/**
* 기본 LLM 프로바이더 생성
*/
async function createDefaultProviders() {
try {
const providers = [
{
name: 'gemini',
displayName: 'Google Gemini',
endpoint: null,
apiKey: process.env.GEMINI_API_KEY || '',
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
priority: 1,
maxTokens: 8192,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.001,
},
{
name: 'openai',
displayName: 'OpenAI GPT',
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY || '',
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
priority: 2,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00015,
costPer1kOutputTokens: 0.0006,
},
{
name: 'claude',
displayName: 'Anthropic Claude',
endpoint: 'https://api.anthropic.com/v1/messages',
apiKey: process.env.CLAUDE_API_KEY || '',
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
priority: 3,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.00125,
},
];
for (const providerData of providers) {
const existing = await LLMProvider.findOne({ where: { name: providerData.name } });
if (existing) {
// API 키가 환경변수에 설정되어 있고 DB에는 없으면 업데이트
if (providerData.apiKey && !existing.apiKey) {
existing.apiKey = providerData.apiKey;
existing.modelName = providerData.modelName;
await existing.save();
logger.info(`LLM 프로바이더 API 키 업데이트: ${providerData.name}`);
}
continue;
}
await LLMProvider.create({
...providerData,
isActive: true,
isHealthy: !!providerData.apiKey, // API 키가 있으면 healthy
});
logger.info(`✅ LLM 프로바이더 생성: ${providerData.name} (${providerData.modelName})`);
}
} catch (error) {
logger.error('LLM 프로바이더 생성 실패:', error);
throw error;
}
}
/**
* 초기화 실행
*/
async function initialize() {
logger.info('🔧 초기 데이터 설정 시작...');
await createDefaultAdmin();
await createDefaultProviders();
logger.info('✅ 초기 데이터 설정 완료');
}
module.exports = {
initialize,
createDefaultAdmin,
createDefaultProviders,
};

View File

@ -1,385 +0,0 @@
// src/services/llm.service.js
// LLM 서비스 - 멀티 프로바이더 지원
const axios = require('axios');
const { LLMProvider } = require('../models');
const logger = require('../config/logger.config');
class LLMService {
constructor() {
this.providers = [];
this.initialized = false;
}
/**
* 서비스 초기화
*/
async initialize() {
if (this.initialized) return;
try {
await this.loadProviders();
this.initialized = true;
logger.info('✅ LLM 서비스 초기화 완료');
} catch (error) {
logger.error('❌ LLM 서비스 초기화 실패:', error);
// 초기화 실패 시 기본 프로바이더 사용
this.providers = this.getDefaultProviders();
this.initialized = true;
}
}
/**
* 데이터베이스에서 프로바이더 로드
*/
async loadProviders() {
try {
const providers = await LLMProvider.getHealthyProviders();
if (providers.length === 0) {
logger.warn('⚠️ 활성 프로바이더가 없습니다. 기본 프로바이더 사용');
this.providers = this.getDefaultProviders();
} else {
this.providers = providers.map((p) => ({
id: p.id,
name: p.name,
endpoint: p.endpoint,
apiKey: p.apiKey,
modelName: p.modelName,
priority: p.priority,
maxTokens: p.maxTokens,
temperature: p.temperature,
timeoutMs: p.timeoutMs,
costPer1kInputTokens: parseFloat(p.costPer1kInputTokens) || 0,
costPer1kOutputTokens: parseFloat(p.costPer1kOutputTokens) || 0,
isHealthy: p.isHealthy,
config: p.config,
}));
}
logger.info(`📥 ${this.providers.length}개 프로바이더 로드됨`);
} catch (error) {
logger.error('프로바이더 로드 실패:', error);
throw error;
}
}
/**
* 기본 프로바이더 설정 (환경 변수 기반)
*/
getDefaultProviders() {
const providers = [];
// Gemini
if (process.env.GEMINI_API_KEY) {
providers.push({
id: 'default-gemini',
name: 'gemini',
apiKey: process.env.GEMINI_API_KEY,
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
priority: 1,
maxTokens: 8192,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.001,
isHealthy: true,
});
}
// OpenAI
if (process.env.OPENAI_API_KEY) {
providers.push({
id: 'default-openai',
name: 'openai',
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY,
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
priority: 2,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00015,
costPer1kOutputTokens: 0.0006,
isHealthy: true,
});
}
// Claude
if (process.env.CLAUDE_API_KEY) {
providers.push({
id: 'default-claude',
name: 'claude',
endpoint: 'https://api.anthropic.com/v1/messages',
apiKey: process.env.CLAUDE_API_KEY,
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
priority: 3,
maxTokens: 4096,
temperature: 0.7,
timeoutMs: 60000,
costPer1kInputTokens: 0.00025,
costPer1kOutputTokens: 0.00125,
isHealthy: true,
});
}
return providers;
}
/**
* 채팅 API 호출 (자동 fallback)
*/
async chat(params) {
const {
model,
messages,
temperature = 0.7,
maxTokens = 4096,
userId,
apiKeyId,
} = params;
// 초기화 확인
if (!this.initialized) {
await this.initialize();
}
const startTime = Date.now();
let lastError = null;
// 요청된 모델에 맞는 프로바이더 찾기
const requestedProvider = this.providers.find(
(p) => p.modelName === model || p.name === model
);
// 우선순위 순으로 프로바이더 정렬
const sortedProviders = requestedProvider
? [requestedProvider, ...this.providers.filter((p) => p !== requestedProvider)]
: this.providers;
// 프로바이더 순회 (fallback)
for (const provider of sortedProviders) {
if (!provider.isHealthy) {
logger.warn(`⚠️ ${provider.name} 건강하지 않음, 건너뜀`);
continue;
}
try {
logger.info(`🚀 ${provider.name} (${provider.modelName}) 시도 중...`);
const result = await this.callProvider(provider, {
messages,
maxTokens: maxTokens || provider.maxTokens,
temperature: temperature || provider.temperature,
});
const responseTime = Date.now() - startTime;
// 비용 계산
const cost = this.calculateCost(
result.usage.promptTokens,
result.usage.completionTokens,
provider.costPer1kInputTokens,
provider.costPer1kOutputTokens
);
logger.info(
`${provider.name} 성공 (${responseTime}ms, ${result.usage.totalTokens} tokens)`
);
return {
text: result.text,
provider: provider.name,
providerId: provider.id,
model: provider.modelName,
usage: result.usage,
responseTime,
cost,
};
} catch (error) {
logger.error(`${provider.name} 실패:`, error.message);
lastError = error;
// 다음 프로바이더로 fallback
continue;
}
}
// 모든 프로바이더 실패
throw new Error(
`모든 LLM 프로바이더가 실패했습니다: ${lastError?.message || '알 수 없는 오류'}`
);
}
/**
* 개별 프로바이더 호출
*/
async callProvider(provider, { messages, maxTokens, temperature }) {
const timeout = provider.timeoutMs || 60000;
switch (provider.name) {
case 'gemini':
return this.callGemini(provider, { messages, maxTokens, temperature });
case 'openai':
return this.callOpenAI(provider, { messages, maxTokens, temperature, timeout });
case 'claude':
return this.callClaude(provider, { messages, maxTokens, temperature, timeout });
default:
throw new Error(`지원하지 않는 프로바이더: ${provider.name}`);
}
}
/**
* Gemini API 호출
*/
async callGemini(provider, { messages, maxTokens, temperature }) {
const { GoogleGenAI } = require('@google/genai');
const ai = new GoogleGenAI({ apiKey: provider.apiKey });
// 메시지 변환 (OpenAI 형식 -> Gemini 형식)
const contents = messages.map((msg) => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}));
// system 메시지 처리
const systemMessage = messages.find((m) => m.role === 'system');
const systemInstruction = systemMessage ? systemMessage.content : undefined;
const config = {
maxOutputTokens: maxTokens,
temperature,
};
const result = await ai.models.generateContent({
model: provider.modelName,
contents: contents.filter((c) => c.role !== 'system'),
systemInstruction,
config,
});
// 응답 텍스트 추출
let text = '';
if (result.candidates?.[0]?.content?.parts) {
text = result.candidates[0].content.parts
.filter((p) => p.text)
.map((p) => p.text)
.join('\n');
}
const usage = result.usageMetadata || {};
const promptTokens = usage.promptTokenCount ?? 0;
const completionTokens = usage.candidatesTokenCount ?? 0;
return {
text,
usage: {
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
},
};
}
/**
* OpenAI API 호출
*/
async callOpenAI(provider, { messages, maxTokens, temperature, timeout }) {
const response = await axios.post(
provider.endpoint,
{
model: provider.modelName,
messages,
max_tokens: maxTokens,
temperature,
},
{
timeout,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${provider.apiKey}`,
},
}
);
return {
text: response.data.choices[0].message.content,
usage: {
promptTokens: response.data.usage.prompt_tokens,
completionTokens: response.data.usage.completion_tokens,
totalTokens: response.data.usage.total_tokens,
},
};
}
/**
* Claude API 호출
*/
async callClaude(provider, { messages, maxTokens, temperature, timeout }) {
// system 메시지 분리
const systemMessage = messages.find((m) => m.role === 'system');
const otherMessages = messages.filter((m) => m.role !== 'system');
const response = await axios.post(
provider.endpoint,
{
model: provider.modelName,
messages: otherMessages,
system: systemMessage?.content,
max_tokens: maxTokens,
temperature,
},
{
timeout,
headers: {
'Content-Type': 'application/json',
'x-api-key': provider.apiKey,
'anthropic-version': '2023-06-01',
},
}
);
return {
text: response.data.content[0].text,
usage: {
promptTokens: response.data.usage.input_tokens,
completionTokens: response.data.usage.output_tokens,
totalTokens:
response.data.usage.input_tokens + response.data.usage.output_tokens,
},
};
}
/**
* 스트리밍 채팅 (제너레이터)
*/
async *chatStream(params) {
// 현재는 간단한 구현 (전체 응답 후 청크로 분할)
// 실제 스트리밍은 각 프로바이더의 스트리밍 API 사용 필요
const result = await this.chat(params);
// 텍스트를 청크로 분할하여 전송
const chunkSize = 10;
for (let i = 0; i < result.text.length; i += chunkSize) {
yield {
text: result.text.slice(i, i + chunkSize),
done: i + chunkSize >= result.text.length,
};
}
}
/**
* 비용 계산
*/
calculateCost(promptTokens, completionTokens, inputCost, outputCost) {
const inputTotal = (promptTokens / 1000) * inputCost;
const outputTotal = (completionTokens / 1000) * outputCost;
return parseFloat((inputTotal + outputTotal).toFixed(6));
}
}
// 싱글톤 인스턴스
const llmService = new LLMService();
module.exports = llmService;

View File

@ -1,359 +0,0 @@
// src/swagger/api-docs.js
// Swagger API 문서 정의
/**
* @swagger
* /auth/register:
* post:
* tags: [Auth]
* summary: 회원가입
* description: 계정을 생성합니다.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, password, name]
* properties:
* email:
* type: string
* format: email
* example: user@example.com
* password:
* type: string
* minLength: 8
* example: Password123!
* description: 8 이상, 영문/숫자/특수문자 포함
* name:
* type: string
* example: 홍길동
* responses:
* 201:
* description: 회원가입 성공
* 400:
* description: 유효성 검사 실패
* 409:
* description: 이미 존재하는 이메일
*/
/**
* @swagger
* /auth/login:
* post:
* tags: [Auth]
* summary: 로그인
* description: 이메일과 비밀번호로 로그인합니다.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, password]
* properties:
* email:
* type: string
* format: email
* example: admin@admin.com
* password:
* type: string
* example: Admin123!
* responses:
* 200:
* description: 로그인 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* user:
* type: object
* accessToken:
* type: string
* description: JWT 액세스 토큰
* refreshToken:
* type: string
* description: JWT 리프레시 토큰
* 401:
* description: 인증 실패
*/
/**
* @swagger
* /chat/completions:
* post:
* tags: [Chat]
* summary: 채팅 완성 (OpenAI 호환)
* description: |
* AI 모델에 메시지를 보내고 응답을 받습니다.
* OpenAI API와 호환되는 형식입니다.
*
* **인증**: JWT 토큰 또는 API (sk-xxx)
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ChatCompletionRequest'
* examples:
* simple:
* summary: 간단한 질문
* value:
* model: gemini-2.0-flash
* messages:
* - role: user
* content: 안녕하세요!
* with_system:
* summary: 시스템 프롬프트 포함
* value:
* model: gemini-2.0-flash
* messages:
* - role: system
* content: 당신은 친절한 AI 어시스턴트입니다.
* - role: user
* content: 파이썬으로 Hello World 출력하는 코드 알려줘
* temperature: 0.7
* max_tokens: 1000
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ChatCompletionResponse'
* 401:
* description: 인증 실패
* 429:
* description: 요청 한도 초과
*/
/**
* @swagger
* /models:
* get:
* tags: [Models]
* summary: 모델 목록 조회
* description: 사용 가능한 AI 모델 목록을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* object:
* type: string
* example: list
* data:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* example: gemini-2.0-flash
* object:
* type: string
* example: model
* owned_by:
* type: string
* example: google
*/
/**
* @swagger
* /api-keys:
* get:
* tags: [API Keys]
* summary: API 목록 조회
* description: 발급받은 API 목록을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* post:
* tags: [API Keys]
* summary: API 발급
* description: API 키를 발급받습니다. 키는 번만 표시됩니다.
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name:
* type: string
* example: My API Key
* description: API 이름
* responses:
* 201:
* description: 발급 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* key:
* type: string
* description: 발급된 API ( 번만 표시)
* example: sk-abc123def456...
*/
/**
* @swagger
* /api-keys/{id}:
* delete:
* tags: [API Keys]
* summary: API 폐기
* description: API 키를 폐기합니다.
* security:
* - BearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: API ID
* responses:
* 200:
* description: 폐기 성공
* 404:
* description: API 키를 찾을 없음
*/
/**
* @swagger
* /usage:
* get:
* tags: [Usage]
* summary: 사용량 요약 조회
* description: 오늘/이번 사용량 요약을 조회합니다.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* plan:
* type: string
* example: free
* limit:
* type: object
* properties:
* monthly:
* type: integer
* remaining:
* type: integer
* usage:
* type: object
* properties:
* today:
* type: object
* monthly:
* type: object
*/
/**
* @swagger
* /usage/logs:
* get:
* tags: [Usage]
* summary: 사용 로그 조회
* description: API 호출 로그를 조회합니다.
* security:
* - BearerAuth: []
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* default: 1
* - in: query
* name: limit
* schema:
* type: integer
* default: 20
* responses:
* 200:
* description: 성공
*/
/**
* @swagger
* /admin/users:
* get:
* tags: [Admin]
* summary: 사용자 목록 조회 (관리자)
* description: 모든 사용자 목록을 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
/**
* @swagger
* /admin/providers:
* get:
* tags: [Admin]
* summary: LLM 프로바이더 목록 (관리자)
* description: LLM 프로바이더 설정을 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/
/**
* @swagger
* /admin/stats:
* get:
* tags: [Admin]
* summary: 시스템 통계 (관리자)
* description: 시스템 전체 통계를 조회합니다. 관리자 권한 필요.
* security:
* - BearerAuth: []
* responses:
* 200:
* description: 성공
* 403:
* description: 권한 없음
*/

View File

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

View File

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

View File

@ -16,17 +16,14 @@ import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// ============================================
// 처리되지 않은 Promise 거부 핸들러
process.on(
"unhandledRejection",
(reason: Error | any, promise: Promise<any>) => {
logger.error("⚠️ Unhandled Promise Rejection:", {
reason: reason?.message || reason,
stack: reason?.stack,
});
// 프로세스를 종료하지 않고 로깅만 수행
// 심각한 에러의 경우 graceful shutdown 고려
},
);
process.on("unhandledRejection", (reason: Error | any, promise: Promise<any>) => {
logger.error("⚠️ Unhandled Promise Rejection:", {
reason: reason?.message || reason,
stack: reason?.stack,
});
// 프로세스를 종료하지 않고 로깅만 수행
// 심각한 에러의 경우 graceful shutdown 고려
});
// 처리되지 않은 예외 핸들러
process.on("uncaughtException", (error: Error) => {
@ -41,16 +38,13 @@ process.on("uncaughtException", (error: Error) => {
// SIGTERM 시그널 처리 (Docker/Kubernetes 환경)
process.on("SIGTERM", () => {
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
const { stopAiAssistant } = require("./utils/startAiAssistant");
stopAiAssistant();
// 여기서 연결 풀 정리 등 cleanup 로직 추가 가능
process.exit(0);
});
// SIGINT 시그널 처리 (Ctrl+C)
process.on("SIGINT", () => {
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
const { stopAiAssistant } = require("./utils/startAiAssistant");
stopAiAssistant();
process.exit(0);
});
@ -118,14 +112,11 @@ 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 액션 실행
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
@ -136,7 +127,6 @@ 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 { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
@ -161,7 +151,7 @@ app.use(
], // 프론트엔드 도메인 허용
},
},
}),
})
);
app.use(compression());
app.use(express.json({ limit: "10mb" }));
@ -184,13 +174,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에서 이미 올바른 형태로 처리됨
@ -210,7 +200,7 @@ app.use(
],
preflightContinue: false,
optionsSuccessStatus: 200,
}),
})
);
// Rate Limiting (개발 환경에서는 완화)
@ -327,9 +317,7 @@ app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
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); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);
@ -363,13 +351,11 @@ app.listen(PORT, HOST, async () => {
runDashboardMigration,
runTableHistoryActionMigration,
runDtgManagementLogMigration,
runApprovalSystemMigration,
} = await import("./database/runMigration");
await runDashboardMigration();
await runTableHistoryActionMigration();
await runDtgManagementLogMigration();
await runApprovalSystemMigration();
} catch (error) {
logger.error(`❌ 마이그레이션 실패:`, error);
}
@ -416,14 +402,6 @@ app.listen(PORT, HOST, async () => {
} catch (error) {
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
}
// AI 어시스턴트 서비스 함께 기동 (한 번에 킬 가능)
try {
const { startAiAssistant } = await import("./utils/startAiAssistant");
startAiAssistant();
} catch (error) {
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
}
});
export default app;

View File

@ -3690,8 +3690,6 @@ export async function copyMenu(
? {
removeText: req.body.screenNameConfig.removeText,
addPrefix: req.body.screenNameConfig.addPrefix,
replaceFrom: req.body.screenNameConfig.replaceFrom,
replaceTo: req.body.screenNameConfig.replaceTo,
}
: undefined;

View File

@ -1,892 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne, transaction } from "../database/db";
// ============================================================
// 결재 정의 (Approval Definitions) CRUD
// ============================================================
export class ApprovalDefinitionController {
// 결재 유형 목록 조회
static async getDefinitions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { is_active, search } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (is_active) {
conditions.push(`is_active = $${idx}`);
params.push(is_active);
idx++;
}
if (search) {
conditions.push(`(definition_name ILIKE $${idx} OR definition_name_eng ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const rows = await query<any>(
`SELECT * FROM approval_definitions WHERE ${conditions.join(" AND ")} ORDER BY definition_id ASC`,
params
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("결재 유형 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 상세 조회
static async getDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const row = await queryOne<any>(
"SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!row) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
return res.json({ success: true, data: row });
} catch (error) {
console.error("결재 유형 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 상세 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 생성
static async createDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const {
definition_name,
definition_name_eng,
description,
default_template_id,
max_steps = 5,
allow_self_approval = false,
allow_cancel = true,
is_active = "Y",
} = req.body;
if (!definition_name) {
return res.status(400).json({ success: false, message: "결재 유형명은 필수입니다." });
}
const userId = req.user?.userId || "system";
const [row] = await query<any>(
`INSERT INTO approval_definitions (
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
company_code, created_by, updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
RETURNING *`,
[
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
companyCode, userId,
]
);
return res.status(201).json({ success: true, data: row, message: "결재 유형이 생성되었습니다." });
} catch (error) {
console.error("결재 유형 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 수정
static async updateDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
const {
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
} = req.body;
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (definition_name !== undefined) { fields.push(`definition_name = $${idx++}`); params.push(definition_name); }
if (definition_name_eng !== undefined) { fields.push(`definition_name_eng = $${idx++}`); params.push(definition_name_eng); }
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
if (default_template_id !== undefined) { fields.push(`default_template_id = $${idx++}`); params.push(default_template_id); }
if (max_steps !== undefined) { fields.push(`max_steps = $${idx++}`); params.push(max_steps); }
if (allow_self_approval !== undefined) { fields.push(`allow_self_approval = $${idx++}`); params.push(allow_self_approval); }
if (allow_cancel !== undefined) { fields.push(`allow_cancel = $${idx++}`); params.push(allow_cancel); }
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
params.push(req.user?.userId || "system");
params.push(id, companyCode);
const [row] = await query<any>(
`UPDATE approval_definitions SET ${fields.join(", ")}
WHERE definition_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
params
);
return res.json({ success: true, data: row, message: "결재 유형이 수정되었습니다." });
} catch (error) {
console.error("결재 유형 수정 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 삭제
static async deleteDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
await query<any>(
"DELETE FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
return res.json({ success: true, message: "결재 유형이 삭제되었습니다." });
} catch (error) {
console.error("결재 유형 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 결재선 템플릿 (Approval Line Templates) CRUD
// ============================================================
export class ApprovalTemplateController {
// 템플릿 목록 조회
static async getTemplates(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { definition_id, is_active } = req.query;
const conditions: string[] = ["t.company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (definition_id) {
conditions.push(`t.definition_id = $${idx++}`);
params.push(definition_id);
}
if (is_active) {
conditions.push(`t.is_active = $${idx++}`);
params.push(is_active);
}
const rows = await query<any>(
`SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
WHERE ${conditions.join(" AND ")}
ORDER BY t.template_id ASC`,
params
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("결재선 템플릿 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 상세 조회 (단계 포함)
static async getTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const template = await queryOne<any>(
`SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
WHERE t.template_id = $1 AND t.company_code = $2`,
[id, companyCode]
);
if (!template) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
const steps = await query<any>(
"SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC",
[id, companyCode]
);
return res.json({ success: true, data: { ...template, steps } });
} catch (error) {
console.error("결재선 템플릿 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 상세 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 생성 (단계 포함 트랜잭션)
static async createTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { template_name, description, definition_id, is_active = "Y", steps = [] } = req.body;
if (!template_name) {
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
}
const userId = req.user?.userId || "system";
let result: any;
await transaction(async (client) => {
const { rows } = await client.query(
`INSERT INTO approval_line_templates (template_name, description, definition_id, is_active, company_code, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING *`,
[template_name, description, definition_id, is_active, companyCode, userId]
);
result = rows[0];
// 단계 일괄 삽입
if (Array.isArray(steps) && steps.length > 0) {
for (const step of steps) {
await client.query(
`INSERT INTO approval_line_template_steps
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
result.template_id,
step.step_order,
step.approver_type || "user",
step.approver_user_id || null,
step.approver_position || null,
step.approver_dept_code || null,
step.approver_label || null,
companyCode,
]
);
}
}
});
return res.status(201).json({ success: true, data: result, message: "결재선 템플릿이 생성되었습니다." });
} catch (error) {
console.error("결재선 템플릿 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 수정
static async updateTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
const { template_name, description, definition_id, is_active, steps } = req.body;
const userId = req.user?.userId || "system";
let result: any;
await transaction(async (client) => {
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (template_name !== undefined) { fields.push(`template_name = $${idx++}`); params.push(template_name); }
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
if (definition_id !== undefined) { fields.push(`definition_id = $${idx++}`); params.push(definition_id); }
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
params.push(userId, id, companyCode);
const { rows } = await client.query(
`UPDATE approval_line_templates SET ${fields.join(", ")}
WHERE template_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
params
);
result = rows[0];
// 단계 재등록 (steps 배열이 주어진 경우 전체 교체)
if (Array.isArray(steps)) {
await client.query(
"DELETE FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
for (const step of steps) {
await client.query(
`INSERT INTO approval_line_template_steps
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[id, step.step_order, step.approver_type || "user", step.approver_user_id || null,
step.approver_position || null, step.approver_dept_code || null, step.approver_label || null, companyCode]
);
}
}
});
return res.json({ success: true, data: result, message: "결재선 템플릿이 수정되었습니다." });
} catch (error) {
console.error("결재선 템플릿 수정 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 삭제
static async deleteTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
await query<any>(
"DELETE FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
return res.json({ success: true, message: "결재선 템플릿이 삭제되었습니다." });
} catch (error) {
console.error("결재선 템플릿 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 결재 요청 (Approval Requests) CRUD
// ============================================================
export class ApprovalRequestController {
// 결재 요청 목록 조회
static async getRequests(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { status, target_table, target_record_id, requester_id, my_approvals, page = "1", limit = "20" } = req.query;
const conditions: string[] = ["r.company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (status) {
conditions.push(`r.status = $${idx++}`);
params.push(status);
}
if (target_table) {
conditions.push(`r.target_table = $${idx++}`);
params.push(target_table);
}
if (target_record_id) {
conditions.push(`r.target_record_id = $${idx++}`);
params.push(target_record_id);
}
if (requester_id) {
conditions.push(`r.requester_id = $${idx++}`);
params.push(requester_id);
}
// 내 결재 대기 목록: 현재 사용자가 결재자인 라인만 조회
if (my_approvals === "true") {
conditions.push(
`EXISTS (SELECT 1 FROM approval_lines l WHERE l.request_id = r.request_id AND l.approver_id = $${idx++} AND l.status = 'pending' AND l.company_code = r.company_code)`
);
params.push(userId);
}
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
params.push(parseInt(limit as string), offset);
const rows = await query<any>(
`SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
WHERE ${conditions.join(" AND ")}
ORDER BY r.created_at DESC
LIMIT $${idx++} OFFSET $${idx++}`,
params
);
// 전체 건수 조회
const countParams = params.slice(0, params.length - 2);
const [countRow] = await query<any>(
`SELECT COUNT(*) as total FROM approval_requests r
WHERE ${conditions.join(" AND ")}`,
countParams
);
return res.json({
success: true,
data: rows,
total: parseInt(countRow?.total || "0"),
page: parseInt(page as string),
limit: parseInt(limit as string),
});
} catch (error) {
console.error("결재 요청 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 요청 상세 조회 (라인 포함)
static async getRequest(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const request = await queryOne<any>(
`SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
WHERE r.request_id = $1 AND r.company_code = $2`,
[id, companyCode]
);
if (!request) {
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
}
const lines = await query<any>(
"SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC",
[id, companyCode]
);
return res.json({ success: true, data: { ...request, lines } });
} catch (error) {
console.error("결재 요청 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 상세 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 요청 생성 (결재 라인 자동 생성)
static async createRequest(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const {
title, description, definition_id, target_table, target_record_id,
target_record_data, screen_id, button_component_id,
approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }]
approval_mode, // "sequential" | "parallel"
} = req.body;
if (!title || !target_table) {
return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." });
}
if (!Array.isArray(approvers) || approvers.length === 0) {
return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." });
}
const userId = req.user?.userId || "system";
const userName = req.user?.userName || "";
const deptName = req.user?.deptName || "";
const isParallel = approval_mode === "parallel";
const totalSteps = approvers.length;
// approval_mode를 target_record_data에 병합 저장
const mergedRecordData = {
...(target_record_data || {}),
approval_mode: approval_mode || "sequential",
};
let result: any;
await transaction(async (client) => {
// 결재 요청 생성
const { rows: reqRows } = await client.query(
`INSERT INTO approval_requests (
title, description, definition_id, target_table, target_record_id,
target_record_data, status, current_step, total_steps,
requester_id, requester_name, requester_dept,
screen_id, button_component_id, company_code
) VALUES ($1, $2, $3, $4, $5, $6, 'requested', 1, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
title, description, definition_id, target_table, target_record_id || null,
JSON.stringify(mergedRecordData),
totalSteps,
userId, userName, deptName,
screen_id, button_component_id, companyCode,
]
);
result = reqRows[0];
// 결재 라인 생성
// 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending
for (let i = 0; i < approvers.length; i++) {
const approver = approvers[i];
const lineStatus = isParallel ? "pending" : (i === 0 ? "pending" : "waiting");
await client.query(
`INSERT INTO approval_lines (
request_id, step_order, approver_id, approver_name, approver_position,
approver_dept, approver_label, status, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
result.request_id,
i + 1,
approver.approver_id,
approver.approver_name || null,
approver.approver_position || null,
approver.approver_dept || null,
approver.approver_label || (isParallel ? "동시 결재" : `${i + 1}차 결재`),
lineStatus,
companyCode,
]
);
}
// 상태를 in_progress로 업데이트
await client.query(
"UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1",
[result.request_id]
);
result.status = "in_progress";
});
return res.status(201).json({ success: true, data: result, message: "결재 요청이 생성되었습니다." });
} catch (error) {
console.error("결재 요청 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 요청 회수 (cancel)
static async cancelRequest(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const request = await queryOne<any>(
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!request) {
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
}
if (request.requester_id !== userId) {
return res.status(403).json({ success: false, message: "본인이 요청한 건만 회수할 수 있습니다." });
}
if (!["requested", "in_progress"].includes(request.status)) {
return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." });
}
await query<any>(
"UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2",
[id, companyCode]
);
return res.json({ success: true, message: "결재 요청이 회수되었습니다." });
} catch (error) {
console.error("결재 요청 회수 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 회수 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 결재 라인 처리 (Approval Lines - 승인/반려)
// ============================================================
export class ApprovalLineController {
// 결재 처리 (승인/반려)
static async processApproval(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { lineId } = req.params;
const { action, comment } = req.body; // action: 'approved' | 'rejected'
if (!["approved", "rejected"].includes(action)) {
return res.status(400).json({ success: false, message: "액션은 approved 또는 rejected여야 합니다." });
}
const line = await queryOne<any>(
"SELECT * FROM approval_lines WHERE line_id = $1 AND company_code = $2",
[lineId, companyCode]
);
if (!line) {
return res.status(404).json({ success: false, message: "결재 라인을 찾을 수 없습니다." });
}
if (line.approver_id !== userId) {
return res.status(403).json({ success: false, message: "본인이 결재자로 지정된 건만 처리할 수 있습니다." });
}
if (line.status !== "pending") {
return res.status(400).json({ success: false, message: "대기 중인 결재만 처리할 수 있습니다." });
}
await transaction(async (client) => {
// 현재 라인 처리
await client.query(
`UPDATE approval_lines SET status = $1, comment = $2, processed_at = NOW()
WHERE line_id = $3`,
[action, comment || null, lineId]
);
const { rows: reqRows } = await client.query(
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
[line.request_id, companyCode]
);
const request = reqRows[0];
if (!request) return;
if (action === "rejected") {
// 반려: 전체 요청 반려 처리
await client.query(
`UPDATE approval_requests SET status = 'rejected', final_approver_id = $1, final_comment = $2,
completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3`,
[userId, comment || null, line.request_id]
);
// 남은 pending/waiting 라인도 skipped 처리
await client.query(
`UPDATE approval_lines SET status = 'skipped'
WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2`,
[line.request_id, lineId]
);
} else {
// 승인: 동시결재 vs 다단결재 분기
const recordData = request.target_record_data;
const isParallelMode = recordData?.approval_mode === "parallel";
if (isParallelMode) {
// 동시결재: 남은 pending 라인이 있는지 확인
const { rows: remainingLines } = await client.query(
`SELECT COUNT(*) as cnt FROM approval_lines
WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3`,
[line.request_id, lineId, companyCode]
);
const remaining = parseInt(remainingLines[0]?.cnt || "0");
if (remaining === 0) {
// 모든 동시 결재자 승인 완료 → 최종 승인
await client.query(
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3`,
[userId, comment || null, line.request_id]
);
}
// 아직 남은 결재자 있으면 대기 (상태 변경 없음)
} else {
// 다단결재: 다음 단계 활성화 또는 최종 완료
const nextStep = line.step_order + 1;
if (nextStep <= request.total_steps) {
await client.query(
`UPDATE approval_lines SET status = 'pending'
WHERE request_id = $1 AND step_order = $2 AND company_code = $3`,
[line.request_id, nextStep, companyCode]
);
await client.query(
`UPDATE approval_requests SET current_step = $1, updated_at = NOW() WHERE request_id = $2`,
[nextStep, line.request_id]
);
} else {
await client.query(
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3`,
[userId, comment || null, line.request_id]
);
}
}
}
});
return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." });
} catch (error) {
console.error("결재 처리 오류:", error);
return res.status(500).json({
success: false,
message: "결재 처리 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 내 결재 대기 목록 조회
static async getMyPendingLines(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const rows = await query<any>(
`SELECT l.*, r.title, r.target_table, r.target_record_id, r.requester_name, r.requester_dept, r.created_at as request_created_at
FROM approval_lines l
JOIN approval_requests r ON l.request_id = r.request_id AND l.company_code = r.company_code
WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code = $2
ORDER BY r.created_at ASC`,
[userId, companyCode]
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("내 결재 대기 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "내 결재 대기 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}

View File

@ -2,37 +2,6 @@ import { PostgreSQLService } from "./PostgreSQLService";
import fs from "fs";
import path from "path";
/**
*
* approval_definitions, approval_line_templates, approval_line_template_steps,
* approval_requests, approval_lines
*/
export async function runApprovalSystemMigration() {
try {
console.log("🔄 결재 시스템 마이그레이션 시작...");
const sqlFilePath = path.join(
__dirname,
"../../db/migrations/100_create_approval_system.sql"
);
if (!fs.existsSync(sqlFilePath)) {
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
return;
}
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 결재 시스템 마이그레이션 완료!");
} catch (error) {
console.error("❌ 결재 시스템 마이그레이션 실패:", error);
if (error instanceof Error && error.message.includes("already exists")) {
console.log(" 테이블이 이미 존재합니다.");
}
}
}
/**
*
* dashboard_elements custom_title, show_header

View File

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

View File

@ -1,38 +0,0 @@
import express from "express";
import {
ApprovalDefinitionController,
ApprovalTemplateController,
ApprovalRequestController,
ApprovalLineController,
} from "../controllers/approvalController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
router.use(authenticateToken);
// ==================== 결재 유형 (Definitions) ====================
router.get("/definitions", ApprovalDefinitionController.getDefinitions);
router.get("/definitions/:id", ApprovalDefinitionController.getDefinition);
router.post("/definitions", ApprovalDefinitionController.createDefinition);
router.put("/definitions/:id", ApprovalDefinitionController.updateDefinition);
router.delete("/definitions/:id", ApprovalDefinitionController.deleteDefinition);
// ==================== 결재선 템플릿 (Templates) ====================
router.get("/templates", ApprovalTemplateController.getTemplates);
router.get("/templates/:id", ApprovalTemplateController.getTemplate);
router.post("/templates", ApprovalTemplateController.createTemplate);
router.put("/templates/:id", ApprovalTemplateController.updateTemplate);
router.delete("/templates/:id", ApprovalTemplateController.deleteTemplate);
// ==================== 결재 요청 (Requests) ====================
router.get("/requests", ApprovalRequestController.getRequests);
router.get("/requests/:id", ApprovalRequestController.getRequest);
router.post("/requests", ApprovalRequestController.createRequest);
router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest);
// ==================== 결재 라인 처리 (Lines) ====================
router.get("/my-pending", ApprovalLineController.getMyPendingLines);
router.post("/lines/:lineId/process", ApprovalLineController.processApproval);
export default router;

View File

@ -2,6 +2,7 @@
// Phase 2-1B: 핵심 인증 API 구현
import { Router } from "express";
import { checkAuthStatus } from "../middleware/authMiddleware";
import { AuthController } from "../controllers/authController";
const router = Router();
@ -11,7 +12,7 @@ const router = Router();
* API
* Java ApiLoginController.checkAuthStatus()
*/
router.get("/status", AuthController.checkAuthStatus);
router.get("/status", checkAuthStatus);
/**
* POST /api/auth/login

View File

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

View File

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

View File

@ -56,8 +56,6 @@ interface Menu {
lang_key_desc: string | null;
screen_code: string | null;
menu_code: string | null;
menu_icon: string | null;
screen_group_id: number | null;
}
/**
@ -373,8 +371,7 @@ export class MenuCopyService {
private async collectScreens(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient,
menus?: Menu[]
client: PoolClient
): Promise<Set<number>> {
logger.info(
`📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}`
@ -395,25 +392,9 @@ export class MenuCopyService {
screenIds.add(assignment.screen_id);
}
// 1.5) menu_url에서 참조되는 화면 수집 (/screens/{screenId} 패턴)
if (menus) {
const screenIdPattern = /\/screens\/(\d+)/;
for (const menu of menus) {
if (menu.menu_url) {
const match = menu.menu_url.match(screenIdPattern);
if (match) {
const urlScreenId = parseInt(match[1], 10);
if (!isNaN(urlScreenId) && urlScreenId > 0) {
screenIds.add(urlScreenId);
}
}
}
}
}
logger.info(`📌 직접 할당 화면: ${screenIds.size}`);
logger.info(`📌 직접 할당 + menu_url 화면: ${screenIds.size}`);
// 2) 화면 내부에서 참조되는 화면 (재귀) - V1 + V2 레이아웃 모두 탐색
// 2) 화면 내부에서 참조되는 화면 (재귀)
const queue = Array.from(screenIds);
while (queue.length > 0) {
@ -422,28 +403,16 @@ export class MenuCopyService {
if (visited.has(screenId)) continue;
visited.add(screenId);
const referencedScreens: number[] = [];
// V1 레이아웃에서 참조 화면 추출
// 화면 레이아웃 조회
const layoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
[screenId]
);
referencedScreens.push(
...this.extractReferencedScreens(layoutsResult.rows)
);
// V2 레이아웃에서 참조 화면 추출
const layoutsV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[screenId, sourceCompanyCode]
// 참조 화면 추출
const referencedScreens = this.extractReferencedScreens(
layoutsResult.rows
);
for (const row of layoutsV2Result.rows) {
if (row.layout_data) {
this.extractScreenIdsFromObject(row.layout_data, referencedScreens);
}
}
if (referencedScreens.length > 0) {
logger.info(
@ -926,8 +895,6 @@ export class MenuCopyService {
screenNameConfig?: {
removeText?: string;
addPrefix?: string;
replaceFrom?: string;
replaceTo?: string;
},
additionalCopyOptions?: AdditionalCopyOptions
): Promise<MenuCopyResult> {
@ -970,8 +937,7 @@ export class MenuCopyService {
const screenIds = await this.collectScreens(
menus.map((m) => m.objid),
sourceCompanyCode,
client,
menus
client
);
const flowIds = await this.collectFlows(screenIds, client);
@ -1127,16 +1093,6 @@ export class MenuCopyService {
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
// === 6.7단계: screen_group_screens 복제 ===
logger.info("\n🏷 [6.7단계] screen_group_screens 복제");
await this.copyScreenGroupScreens(
screenIds,
screenIdMap,
sourceCompanyCode,
targetCompanyCode,
client
);
// === 7단계: 테이블 타입 설정 복사 ===
if (additionalCopyOptions?.copyTableTypeColumns) {
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
@ -1461,8 +1417,6 @@ export class MenuCopyService {
screenNameConfig?: {
removeText?: string;
addPrefix?: string;
replaceFrom?: string;
replaceTo?: string;
},
numberingRuleIdMap?: Map<string, string>,
menuIdMap?: Map<number, number>
@ -1562,13 +1516,6 @@ export class MenuCopyService {
// 3) 화면명 변환 적용
let transformedScreenName = screenDef.screen_name;
if (screenNameConfig) {
if (screenNameConfig.replaceFrom?.trim()) {
transformedScreenName = transformedScreenName.replace(
new RegExp(screenNameConfig.replaceFrom.trim(), "g"),
screenNameConfig.replaceTo?.trim() || ""
);
transformedScreenName = transformedScreenName.trim();
}
if (screenNameConfig.removeText?.trim()) {
transformedScreenName = transformedScreenName.replace(
new RegExp(screenNameConfig.removeText.trim(), "g"),
@ -1586,21 +1533,20 @@ export class MenuCopyService {
// === 기존 복사본이 있는 경우: 업데이트 ===
const existingScreenId = existingCopy.screen_id;
// 원본 V2 레이아웃 조회 (모든 레이어)
// 원본 V2 레이아웃 조회
const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`,
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
[originalScreenId]
);
// 대상 V2 레이아웃 조회 (모든 레이어)
// 대상 V2 레이아웃 조회
const targetLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`,
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
[existingScreenId]
);
// 변경 여부 확인: 레이어 수가 다르면 무조건 변경됨
const layerCountDiffers = sourceLayoutV2Result.rows.length !== targetLayoutV2Result.rows.length;
const hasChanges = layerCountDiffers || this.hasLayoutChangesV2(
// 변경 여부 확인 (V2 레이아웃 비교)
const hasChanges = this.hasLayoutChangesV2(
sourceLayoutV2Result.rows[0]?.layout_data,
targetLayoutV2Result.rows[0]?.layout_data
);
@ -1704,7 +1650,7 @@ export class MenuCopyService {
}
}
// === 2단계: screen_conditional_zones + screen_layouts_v2 처리 (멀티 레이어 지원) ===
// === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) ===
logger.info(
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
);
@ -1716,90 +1662,23 @@ export class MenuCopyService {
isUpdate,
} of screenDefsToProcess) {
try {
const sourceCompanyCode = screenDef.company_code;
// 원본 V2 레이아웃 전체 조회 (모든 레이어)
const layoutV2Result = await client.query<{
layout_data: any;
layer_id: number;
layer_name: string;
condition_config: any;
}>(
`SELECT layout_data, layer_id, layer_name, condition_config
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[originalScreenId, sourceCompanyCode]
);
if (layoutV2Result.rows.length === 0) {
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
continue;
}
// 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
const componentIdMap = new Map<string, string>();
const timestamp = Date.now();
let compIdx = 0;
for (const layer of layoutV2Result.rows) {
const components = layer.layout_data?.components || [];
for (const comp of components) {
if (!componentIdMap.has(comp.id)) {
const newId = `comp_${timestamp}_${compIdx++}_${Math.random().toString(36).substr(2, 5)}`;
componentIdMap.set(comp.id, newId);
}
}
}
// screen_conditional_zones 복제 + zoneIdMap 생성
const zoneIdMap = new Map<number, number>();
const zonesResult = await client.query(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
// 원본 V2 레이아웃 조회
const layoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
[originalScreenId]
);
if (isUpdate) {
await client.query(
`DELETE FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2`,
[targetScreenId, targetCompanyCode]
);
}
const layoutData = layoutV2Result.rows[0]?.layout_data;
const components = layoutData?.components || [];
for (const zone of zonesResult.rows) {
const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id;
const newZone = await client.query<{ zone_id: number }>(
`INSERT INTO screen_conditional_zones
(screen_id, company_code, zone_name, x, y, width, height,
trigger_component_id, trigger_operator)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING zone_id`,
[targetScreenId, targetCompanyCode, zone.zone_name,
zone.x, zone.y, zone.width, zone.height,
newTriggerCompId, zone.trigger_operator]
);
zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id);
}
if (zonesResult.rows.length > 0) {
logger.info(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}개 (zoneIdMap: ${zoneIdMap.size}개)`);
}
// 업데이트인 경우 기존 레이아웃 삭제 (레이어 수 변경 대응)
if (isUpdate) {
await client.query(
`DELETE FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`,
[targetScreenId, targetCompanyCode]
);
}
// 각 레이어별 처리
let totalComponents = 0;
for (const layer of layoutV2Result.rows) {
const layoutData = layer.layout_data;
const components = layoutData?.components || [];
if (!layoutData || components.length === 0) continue;
totalComponents += components.length;
if (layoutData && components.length > 0) {
// component_id 매핑 생성 (원본 → 새 ID)
const componentIdMap = new Map<string, string>();
const timestamp = Date.now();
components.forEach((comp: any, idx: number) => {
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
componentIdMap.set(comp.id, newComponentId);
});
// V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
@ -1811,34 +1690,20 @@ export class MenuCopyService {
menuIdMap
);
// condition_config의 zone_id 재매핑
let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null;
if (updatedConditionConfig?.zone_id) {
const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id);
if (newZoneId) {
updatedConditionConfig.zone_id = newZoneId;
}
}
// V2 레이아웃 저장 (레이어별 INSERT)
// V2 레이아웃 저장 (UPSERT)
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
[
targetScreenId,
targetCompanyCode,
layer.layer_id,
layer.layer_name,
JSON.stringify(updatedLayoutData),
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
]
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)]
);
}
const action = isUpdate ? "업데이트" : "복사";
logger.info(` ↳ V2 레이아웃 ${action}: ${layoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
const action = isUpdate ? "업데이트" : "복사";
logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`);
} else {
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
}
} catch (error: any) {
logger.error(
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,
@ -2118,26 +1983,6 @@ export class MenuCopyService {
logger.info(`📂 메뉴 복사 중: ${menus.length}`);
// screen_group_id 재매핑 맵 생성 (source company → target company)
const screenGroupIdMap = new Map<number, number>();
const sourceGroupIds = [...new Set(menus.map(m => m.screen_group_id).filter(Boolean))] as number[];
if (sourceGroupIds.length > 0) {
const sourceGroups = await client.query<{ id: number; group_name: string }>(
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
[sourceGroupIds]
);
for (const sg of sourceGroups.rows) {
const targetGroup = await client.query<{ id: number }>(
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
[sg.group_name, targetCompanyCode]
);
if (targetGroup.rows.length > 0) {
screenGroupIdMap.set(sg.id, targetGroup.rows[0].id);
}
}
logger.info(`🏷️ screen_group 매핑: ${screenGroupIdMap.size}/${sourceGroupIds.length}`);
}
// 위상 정렬 (부모 먼저 삽입)
const sortedMenus = this.topologicalSortMenus(menus);
@ -2261,28 +2106,26 @@ export class MenuCopyService {
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, menu_desc, writer, status, system_name,
company_code, lang_key, lang_key_desc, screen_code, menu_code,
source_menu_objid, menu_icon, screen_group_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
source_menu_objid
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[
newObjId,
menu.menu_type,
newParentObjId,
newParentObjId, // 재매핑
menu.menu_name_kor,
menu.menu_name_eng,
menu.seq,
menu.menu_url,
menu.menu_desc,
userId,
menu.status || 'active',
'active', // 복제된 메뉴는 항상 활성화 상태
menu.system_name,
targetCompanyCode,
targetCompanyCode, // 새 회사 코드
menu.lang_key,
menu.lang_key_desc,
menu.screen_code,
menu.screen_code, // 그대로 유지
menu.menu_code,
sourceMenuObjid,
menu.menu_icon,
menu.screen_group_id ? (screenGroupIdMap.get(menu.screen_group_id) || menu.screen_group_id) : null,
sourceMenuObjid, // 원본 메뉴 ID (최상위만)
]
);
@ -2403,9 +2246,8 @@ export class MenuCopyService {
}
/**
* URL + screen_code ( ID )
* URL ( ID )
* menu_url에 /screens/{screenId} ID를 ID로
* menu_info.screen_code도 screen_definitions.screen_code로
*/
private async updateMenuUrls(
menuIdMap: Map<number, number>,
@ -2413,197 +2255,56 @@ export class MenuCopyService {
client: PoolClient
): Promise<void> {
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
logger.info("📭 메뉴 URL 업데이트 대상 없음");
return;
}
const newMenuObjids = Array.from(menuIdMap.values());
// 복제된 메뉴 조회
const menusToUpdate = await client.query<{
// 복제된 메뉴 중 menu_url이 있는 것 조회
const menusWithUrl = await client.query<{
objid: number;
menu_url: string | null;
screen_code: string | null;
menu_url: string;
}>(
`SELECT objid, menu_url, screen_code FROM menu_info
WHERE objid = ANY($1)`,
`SELECT objid, menu_url FROM menu_info
WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`,
[newMenuObjids]
);
if (menusToUpdate.rows.length === 0) {
logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
if (menusWithUrl.rows.length === 0) {
logger.info("📭 menu_url 업데이트 대상 없음");
return;
}
// screenIdMap의 역방향: 원본 screen_id → 새 screen_id의 screen_code 조회
const newScreenIds = Array.from(screenIdMap.values());
const screenCodeMap = new Map<string, string>();
if (newScreenIds.length > 0) {
const screenCodesResult = await client.query<{
screen_id: number;
screen_code: string;
source_screen_id: number;
}>(
`SELECT sd_new.screen_id, sd_new.screen_code, sd_new.source_screen_id
FROM screen_definitions sd_new
WHERE sd_new.screen_id = ANY($1) AND sd_new.screen_code IS NOT NULL`,
[newScreenIds]
);
for (const row of screenCodesResult.rows) {
if (row.source_screen_id) {
// 원본의 screen_code 조회
const origResult = await client.query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions WHERE screen_id = $1`,
[row.source_screen_id]
);
if (origResult.rows[0]?.screen_code) {
screenCodeMap.set(origResult.rows[0].screen_code, row.screen_code);
}
}
}
}
let updatedUrlCount = 0;
let updatedCodeCount = 0;
let updatedCount = 0;
const screenIdPattern = /\/screens\/(\d+)/;
for (const menu of menusToUpdate.rows) {
let newMenuUrl = menu.menu_url;
let newScreenCode = menu.screen_code;
let changed = false;
for (const menu of menusWithUrl.rows) {
const match = menu.menu_url.match(screenIdPattern);
if (!match) continue;
// menu_url 재매핑
if (menu.menu_url) {
const match = menu.menu_url.match(screenIdPattern);
if (match) {
const originalScreenId = parseInt(match[1], 10);
const newScreenId = screenIdMap.get(originalScreenId);
if (newScreenId && newScreenId !== originalScreenId) {
newMenuUrl = menu.menu_url.replace(
`/screens/${originalScreenId}`,
`/screens/${newScreenId}`
);
changed = true;
updatedUrlCount++;
logger.info(
` 🔗 메뉴 URL 업데이트: ${menu.menu_url}${newMenuUrl}`
);
}
}
// /screen/{screen_code} 형식도 처리
const screenCodeUrlMatch = menu.menu_url.match(/\/screen\/(.+)/);
if (screenCodeUrlMatch && !menu.menu_url.match(/\/screens\//)) {
const origCode = screenCodeUrlMatch[1];
const newCode = screenCodeMap.get(origCode);
if (newCode && newCode !== origCode) {
newMenuUrl = `/screen/${newCode}`;
changed = true;
updatedUrlCount++;
logger.info(
` 🔗 메뉴 URL(코드) 업데이트: ${menu.menu_url}${newMenuUrl}`
);
}
}
}
const originalScreenId = parseInt(match[1], 10);
const newScreenId = screenIdMap.get(originalScreenId);
// screen_code 재매핑
if (menu.screen_code) {
const mappedCode = screenCodeMap.get(menu.screen_code);
if (mappedCode && mappedCode !== menu.screen_code) {
newScreenCode = mappedCode;
changed = true;
updatedCodeCount++;
logger.info(
` 🏷️ screen_code 업데이트: ${menu.screen_code}${newScreenCode}`
);
}
}
if (changed) {
await client.query(
`UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`,
[newMenuUrl, newScreenCode, menu.objid]
if (newScreenId && newScreenId !== originalScreenId) {
const newMenuUrl = menu.menu_url.replace(
`/screens/${originalScreenId}`,
`/screens/${newScreenId}`
);
await client.query(
`UPDATE menu_info SET menu_url = $1 WHERE objid = $2`,
[newMenuUrl, menu.objid]
);
logger.info(
` 🔗 메뉴 URL 업데이트: ${menu.menu_url}${newMenuUrl}`
);
updatedCount++;
}
}
logger.info(`✅ 메뉴 URL 업데이트: ${updatedUrlCount}개, screen_code 업데이트: ${updatedCodeCount}`);
}
/**
* screen_group_screens (- )
*/
private async copyScreenGroupScreens(
screenIds: Set<number>,
screenIdMap: Map<number, number>,
sourceCompanyCode: string,
targetCompanyCode: string,
client: PoolClient
): Promise<void> {
if (screenIds.size === 0 || screenIdMap.size === 0) {
logger.info("📭 screen_group_screens 복제 대상 없음");
return;
}
// 기존 COMPANY_10의 screen_group_screens 삭제 (깨진 이전 데이터 정리)
await client.query(
`DELETE FROM screen_group_screens WHERE company_code = $1`,
[targetCompanyCode]
);
// 소스 회사의 screen_group_screens 조회
const sourceScreenIds = Array.from(screenIds);
const sourceResult = await client.query<{
group_id: number;
screen_id: number;
screen_role: string;
display_order: number;
is_default: string;
}>(
`SELECT group_id, screen_id, screen_role, display_order, is_default
FROM screen_group_screens
WHERE company_code = $1 AND screen_id = ANY($2)`,
[sourceCompanyCode, sourceScreenIds]
);
if (sourceResult.rows.length === 0) {
logger.info("📭 소스에 screen_group_screens 없음");
return;
}
// screen_group ID 매핑 (source group_name → target group_id)
const sourceGroupIds = [...new Set(sourceResult.rows.map(r => r.group_id))];
const sourceGroups = await client.query<{ id: number; group_name: string }>(
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
[sourceGroupIds]
);
const groupIdMap = new Map<number, number>();
for (const sg of sourceGroups.rows) {
const targetGroup = await client.query<{ id: number }>(
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
[sg.group_name, targetCompanyCode]
);
if (targetGroup.rows.length > 0) {
groupIdMap.set(sg.id, targetGroup.rows[0].id);
}
}
let insertedCount = 0;
for (const row of sourceResult.rows) {
const newGroupId = groupIdMap.get(row.group_id);
const newScreenId = screenIdMap.get(row.screen_id);
if (!newGroupId || !newScreenId) continue;
await client.query(
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer)
VALUES ($1, $2, $3, $4, $5, $6, 'system')
ON CONFLICT DO NOTHING`,
[newGroupId, newScreenId, row.screen_role, row.display_order, row.is_default, targetCompanyCode]
);
insertedCount++;
}
logger.info(`✅ screen_group_screens 복제: ${insertedCount}`);
logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}`);
}
/**

View File

@ -124,10 +124,7 @@ export async function syncScreenGroupsToMenu(
// 모든 메뉴의 objid 집합 (삭제 확인용)
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
// 3. objid 충돌 방지: 순차 카운터 사용
let nextObjid = Date.now();
// 4. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
// 없으면 생성
let userMenuRootObjid: number | null = null;
const rootMenuQuery = `
@ -141,18 +138,19 @@ export async function syncScreenGroupsToMenu(
if (rootMenuResult.rows.length > 0) {
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
} else {
const rootObjid = nextObjid++;
// 루트 메뉴가 없으면 생성
const newObjid = Date.now();
const createRootQuery = `
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
RETURNING objid
`;
const createRootResult = await client.query(createRootQuery, [rootObjid, companyCode, userId]);
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
userMenuRootObjid = Number(createRootResult.rows[0].objid);
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
}
// 5. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
const groupToMenuMap: Map<number, number> = new Map();
// screen_groups의 부모 이름 조회를 위한 매핑
@ -282,7 +280,7 @@ export async function syncScreenGroupsToMenu(
} else {
// 새 메뉴 생성
const newObjid = nextObjid++;
const newObjid = Date.now() + groupId; // 고유 ID 보장
// 부모 메뉴 objid 결정
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
@ -336,8 +334,8 @@ export async function syncScreenGroupsToMenu(
INSERT INTO menu_info (
objid, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc,
menu_url, screen_code, menu_icon
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11, $12)
menu_url, screen_code
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11)
RETURNING objid
`;
await client.query(insertMenuQuery, [
@ -352,7 +350,6 @@ export async function syncScreenGroupsToMenu(
group.description || null,
menuUrl,
screenCode,
group.icon || null,
]);
// screen_groups에 menu_objid 업데이트

File diff suppressed because it is too large Load Diff

View File

@ -3482,74 +3482,8 @@ export class ScreenManagementService {
}
console.log(
`V1 screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
`screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
);
// V2 레이아웃(screen_layouts_v2)도 동일하게 처리
const v2LayoutsResult = await client.query(
`SELECT screen_id, layer_id, company_code, layout_data
FROM screen_layouts_v2
WHERE screen_id IN (${placeholders})
AND layout_data::text ~ '"(screenId|targetScreenId|modalScreenId|leftScreenId|rightScreenId|addModalScreenId|editModalScreenId)"'`,
targetScreenIds,
);
console.log(
`🔍 V2 참조 업데이트 대상 레이아웃: ${v2LayoutsResult.rows.length}`,
);
let v2Updated = 0;
for (const v2Layout of v2LayoutsResult.rows) {
let layoutData = v2Layout.layout_data;
if (!layoutData) continue;
let v2HasChanges = false;
const updateV2References = (obj: any): void => {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
for (const item of obj) updateV2References(item);
return;
}
for (const key of Object.keys(obj)) {
const value = obj[key];
if (
(key === "screenId" || key === "targetScreenId" || key === "modalScreenId" ||
key === "leftScreenId" || key === "rightScreenId" ||
key === "addModalScreenId" || key === "editModalScreenId")
) {
const numVal = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numVal) && numVal > 0) {
const newId = screenMap.get(numVal);
if (newId) {
obj[key] = typeof value === "number" ? newId : String(newId);
v2HasChanges = true;
console.log(`🔗 V2 ${key} 매핑: ${numVal}${newId}`);
}
}
}
if (typeof value === "object" && value !== null) {
updateV2References(value);
}
}
};
updateV2References(layoutData);
if (v2HasChanges) {
await client.query(
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
);
v2Updated++;
}
}
console.log(
`✅ V2 참조 업데이트 완료: ${v2Updated}개 레이아웃`,
);
result.updated += v2Updated;
});
return result;
@ -4276,65 +4210,39 @@ export class ScreenManagementService {
const newScreen = newScreenResult.rows[0];
// 4. 원본 화면의 V2 레이아웃 전체 조회 (모든 레이어)
let sourceLayoutV2Result = await client.query<{
layout_data: any;
layer_id: number;
layer_name: string;
condition_config: any;
}>(
`SELECT layout_data, layer_id, layer_name, condition_config
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
// 4. 원본 화면의 V2 레이아웃 조회
let sourceLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[sourceScreenId, sourceScreen.company_code],
);
// 없으면 공통(*) 레이아웃 조회
if (sourceLayoutV2Result.rows.length === 0 && sourceScreen.company_code !== "*") {
sourceLayoutV2Result = await client.query<{
layout_data: any;
layer_id: number;
layer_name: string;
condition_config: any;
}>(
`SELECT layout_data, layer_id, layer_name, condition_config
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'
ORDER BY layer_id`,
let layoutData = sourceLayoutV2Result.rows[0]?.layout_data;
if (!layoutData && sourceScreen.company_code !== "*") {
const fallbackResult = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'`,
[sourceScreenId],
);
layoutData = fallbackResult.rows[0]?.layout_data;
}
// 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
const componentIdMap = new Map<string, string>();
for (const layer of sourceLayoutV2Result.rows) {
const components = layer.layout_data?.components || [];
for (const comp of components) {
if (!componentIdMap.has(comp.id)) {
componentIdMap.set(comp.id, generateId());
}
}
}
const hasComponents = componentIdMap.size > 0;
// 첫 번째 레이어의 layoutData (flowId/ruleId 수집용 - 모든 레이어에서 수집)
const allLayoutDatas = sourceLayoutV2Result.rows.map((r: any) => r.layout_data).filter(Boolean);
const components = layoutData?.components || [];
// 5. 노드 플로우 복사 (회사가 다른 경우)
let flowIdMap = new Map<number, number>();
if (
hasComponents &&
components.length > 0 &&
sourceScreen.company_code !== targetCompanyCode
) {
const flowIds = new Set<number>();
for (const ld of allLayoutDatas) {
const ids = this.collectFlowIdsFromLayoutData(ld);
ids.forEach((id: number) => flowIds.add(id));
}
// V2 레이아웃에서 flowId 수집
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
if (flowIds.size > 0) {
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}`);
// 노드 플로우 복사 및 매핑 생성
flowIdMap = await this.copyNodeFlowsForScreen(
flowIds,
sourceScreen.company_code,
@ -4347,17 +4255,16 @@ export class ScreenManagementService {
// 5.1. 채번 규칙 복사 (회사가 다른 경우)
let ruleIdMap = new Map<string, string>();
if (
hasComponents &&
components.length > 0 &&
sourceScreen.company_code !== targetCompanyCode
) {
const ruleIds = new Set<string>();
for (const ld of allLayoutDatas) {
const ids = this.collectNumberingRuleIdsFromLayoutData(ld);
ids.forEach((id: string) => ruleIds.add(id));
}
// V2 레이아웃에서 채번 규칙 ID 수집
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData);
if (ruleIds.size > 0) {
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}`);
// 채번 규칙 복사 및 매핑 생성
ruleIdMap = await this.copyNumberingRulesForScreen(
ruleIds,
sourceScreen.company_code,
@ -4367,89 +4274,39 @@ export class ScreenManagementService {
}
}
// 5.2. screen_conditional_zones 복제 + zoneIdMap 생성
const zoneIdMap = new Map<number, number>();
if (hasComponents) {
// 6. V2 레이아웃이 있다면 복사
if (layoutData && components.length > 0) {
try {
const zonesResult = await client.query(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
[sourceScreenId]
// componentId 매핑 생성
const componentIdMap = new Map<string, string>();
for (const comp of components) {
componentIdMap.set(comp.id, generateId());
}
// V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutData(
layoutData,
{
componentIdMap,
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
},
);
for (const zone of zonesResult.rows) {
const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id;
const newZone = await client.query<{ zone_id: number }>(
`INSERT INTO screen_conditional_zones
(screen_id, company_code, zone_name, x, y, width, height,
trigger_component_id, trigger_operator)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING zone_id`,
[newScreen.screen_id, targetCompanyCode, zone.zone_name,
zone.x, zone.y, zone.width, zone.height,
newTriggerCompId, zone.trigger_operator]
);
zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id);
}
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
VALUES ($1, $2, 1, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
);
if (zonesResult.rows.length > 0) {
console.log(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}`);
}
} catch (error) {
console.error("조건부 영역 복사 중 오류:", error);
}
}
// 6. V2 레이아웃 복사 (모든 레이어 순회)
if (sourceLayoutV2Result.rows.length > 0 && hasComponents) {
try {
let totalComponents = 0;
for (const layer of sourceLayoutV2Result.rows) {
const layoutData = layer.layout_data;
const components = layoutData?.components || [];
if (!layoutData || components.length === 0) continue;
totalComponents += components.length;
// V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutData(
layoutData,
{
componentIdMap,
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
},
);
// condition_config의 zone_id 재매핑
let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null;
if (updatedConditionConfig?.zone_id) {
const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id);
if (newZoneId) {
updatedConditionConfig.zone_id = newZoneId;
}
}
// V2 레이아웃 저장 (레이어별 INSERT)
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
[
newScreen.screen_id,
targetCompanyCode,
layer.layer_id,
layer.layer_name,
JSON.stringify(updatedLayoutData),
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
],
);
}
console.log(` ↳ V2 레이아웃 복사: ${sourceLayoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
} catch (error) {
console.error("V2 레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지
}
}
@ -4676,60 +4533,9 @@ export class ScreenManagementService {
}
console.log(
`V1: ${updateCount}개 레이아웃 업데이트 완료`,
` ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`,
);
// V2 레이아웃(screen_layouts_v2)에서도 targetScreenId 등 재매핑
const v2Layouts = await query<any>(
`SELECT screen_id, layer_id, company_code, layout_data
FROM screen_layouts_v2
WHERE screen_id = $1
AND layout_data IS NOT NULL`,
[screenId],
);
let v2UpdateCount = 0;
for (const v2Layout of v2Layouts) {
const layoutData = v2Layout.layout_data;
if (!layoutData?.components) continue;
let v2Changed = false;
const updateV2Refs = (obj: any): void => {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) { for (const item of obj) updateV2Refs(item); return; }
for (const key of Object.keys(obj)) {
const value = obj[key];
if (
(key === "targetScreenId" || key === "screenId" || key === "modalScreenId" ||
key === "leftScreenId" || key === "rightScreenId" ||
key === "addModalScreenId" || key === "editModalScreenId")
) {
const numVal = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numVal) && screenIdMapping.has(numVal)) {
obj[key] = typeof value === "number" ? screenIdMapping.get(numVal)! : screenIdMapping.get(numVal)!.toString();
v2Changed = true;
}
}
if (typeof value === "object" && value !== null) updateV2Refs(value);
}
};
updateV2Refs(layoutData);
if (v2Changed) {
await query(
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
);
v2UpdateCount++;
}
}
const total = updateCount + v2UpdateCount;
console.log(
`✅ 총 ${total}개 레이아웃 업데이트 완료 (V1: ${updateCount}, V2: ${v2UpdateCount})`,
);
return total;
return updateCount;
}
/**

View File

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

View File

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

View File

@ -1,6 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -12,7 +12,7 @@ services:
environment:
- NODE_ENV=development
- PORT=8080
- DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
- JWT_EXPIRES_IN=24h
- CORS_ORIGIN=http://localhost:9771

View File

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

View File

@ -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 정화

View File

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

View File

@ -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 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |

View File

@ -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 컴포넌트 사용으로 프로젝트 일관성 유지

View File

@ -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(", ")`로 결합하면 됨

View File

@ -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 | 선택 항목 표시 순서를 옵션 순서 기준으로 변경 |

View File

@ -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` | 관리자 페이지 레지스트리 |

View File

@ -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 지연) | 낮음 (클릭 이벤트 하나) |

View File

@ -1,342 +0,0 @@
# 결재 시스템 구현 현황
## 1. 개요
어떤 화면/테이블에서든 결재 버튼을 추가하여 다단계(순차) 및 다중(병렬) 결재를 처리할 수 있는 범용 결재 시스템.
### 핵심 특징
- **범용성**: 특정 테이블에 종속되지 않고 어떤 화면에서든 사용 가능
- **멀티테넌시**: 모든 데이터가 `company_code`로 격리
- **사용자 주도**: 결재 요청 시 결재 모드/결재자를 직접 설정 (관리자 사전 세팅 불필요)
- **컴포넌트 연동**: 버튼 액션 타입 + 결재 단계 시각화 컴포넌트 제공
---
## 2. 아키텍처
```
[버튼 클릭 (approval 액션)]
[ButtonActionExecutor] → CustomEvent('open-approval-modal') 발송
[ApprovalGlobalListener] → 이벤트 수신
[ApprovalRequestModal] → 결재 모드/결재자 선택 UI
[POST /api/approval/requests] → 결재 요청 생성
[approval_requests + approval_lines 테이블에 저장]
[결재함 / 결재 단계 컴포넌트에서 조회 및 처리]
```
---
## 3. 데이터베이스
### 마이그레이션 파일
- `db/migrations/100_create_approval_system.sql`
### 테이블 구조
| 테이블 | 용도 | 주요 컬럼 |
|--------|------|-----------|
| `approval_definitions` | 결재 유형 정의 (구매결재, 문서결재 등) | definition_id, definition_name, max_steps, company_code |
| `approval_line_templates` | 결재선 템플릿 (미리 저장된 결재선) | template_id, template_name, definition_id, company_code |
| `approval_line_template_steps` | 템플릿별 결재 단계 | step_id, template_id, step_order, approver_user_id, company_code |
| `approval_requests` | 실제 결재 요청 건 | request_id, title, target_table, target_record_id, status, requester_id, company_code |
| `approval_lines` | 결재 건별 각 단계 결재자 | line_id, request_id, step_order, approver_id, status, comment, company_code |
### 결재 상태 흐름
```
[requested] → [in_progress] → [approved] (모든 단계 승인)
→ [rejected] (어느 단계에서든 반려)
→ [cancelled] (요청자가 취소)
```
#### approval_requests.status
| 상태 | 의미 |
|------|------|
| `requested` | 결재 요청됨 (1단계 결재자 처리 대기) |
| `in_progress` | 결재 진행 중 (2단계 이상 진행) |
| `approved` | 최종 승인 완료 |
| `rejected` | 반려됨 |
| `cancelled` | 요청자에 의해 취소 |
#### approval_lines.status
| 상태 | 의미 |
|------|------|
| `waiting` | 아직 차례가 아님 |
| `pending` | 현재 결재 차례 (처리 대기) |
| `approved` | 승인 완료 |
| `rejected` | 반려 |
| `skipped` | 이전 단계 반려로 스킵됨 |
---
## 4. 백엔드 API
### 파일 위치
- **컨트롤러**: `backend-node/src/controllers/approvalController.ts`
- **라우트**: `backend-node/src/routes/approvalRoutes.ts`
### API 엔드포인트
#### 결재 유형 (Definitions)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/approval/definitions` | 결재 유형 목록 |
| GET | `/api/approval/definitions/:id` | 결재 유형 상세 |
| POST | `/api/approval/definitions` | 결재 유형 생성 |
| PUT | `/api/approval/definitions/:id` | 결재 유형 수정 |
| DELETE | `/api/approval/definitions/:id` | 결재 유형 삭제 |
#### 결재선 템플릿 (Templates)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/approval/templates` | 템플릿 목록 |
| GET | `/api/approval/templates/:id` | 템플릿 상세 (단계 포함) |
| POST | `/api/approval/templates` | 템플릿 생성 |
| PUT | `/api/approval/templates/:id` | 템플릿 수정 |
| DELETE | `/api/approval/templates/:id` | 템플릿 삭제 |
#### 결재 요청 (Requests)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/approval/requests` | 결재 요청 목록 (필터 가능) |
| GET | `/api/approval/requests/:id` | 결재 요청 상세 (결재 라인 포함) |
| POST | `/api/approval/requests` | 결재 요청 생성 |
| POST | `/api/approval/requests/:id/cancel` | 결재 취소 |
#### 결재 라인 처리 (Lines)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/approval/my-pending` | 내 미처리 결재 목록 |
| POST | `/api/approval/lines/:lineId/process` | 승인/반려 처리 |
### 결재 요청 생성 시 입력
```typescript
interface CreateApprovalRequestInput {
title: string; // 결재 제목
description?: string; // 결재 설명
target_table: string; // 대상 테이블명 (예: sales_order_mng)
target_record_id?: string; // 대상 레코드 ID (선택)
approval_mode?: "sequential" | "parallel"; // 결재 모드
approvers: { // 결재자 목록
approver_id: string;
approver_name?: string;
approver_position?: string;
approver_dept?: string;
}[];
}
```
### 결재 처리 로직
#### 순차 결재 (sequential)
1. 첫 번째 결재자 `status = 'pending'`, 나머지 `'waiting'`
2. 1단계 승인 → 2단계 `'pending'`으로 변경
3. 모든 단계 승인 → `approval_requests.status = 'approved'`
4. 어느 단계에서 반려 → 이후 단계 `'skipped'`, 요청 `'rejected'`
#### 병렬 결재 (parallel)
1. 모든 결재자 `status = 'pending'` (동시 처리)
2. 모든 결재자 승인 → `'approved'`
3. 한 명이라도 반려 → `'rejected'`
---
## 5. 프론트엔드
### 5.1 결재 요청 모달
**파일**: `frontend/components/approval/ApprovalRequestModal.tsx`
- 결재 모드 선택 (다단 결재 / 다중 결재)
- 결재자 검색 (사용자 API 검색, 한글/영문/ID 검색 가능)
- 결재자 추가/삭제, 순서 변경 (순차 결재 시)
- 대상 테이블/레코드 ID 자동 세팅
### 5.2 결재 글로벌 리스너
**파일**: `frontend/components/approval/ApprovalGlobalListener.tsx`
- `open-approval-modal` CustomEvent를 전역으로 수신
- 이벤트의 `detail`에서 `targetTable`, `targetRecordId`, `formData` 추출
- `ApprovalRequestModal` 열기
### 5.3 결재함 페이지
**파일**: `frontend/app/(main)/admin/approvalBox/page.tsx`
- 탭 구성: 보낸 결재 / 받은 결재 / 완료된 결재
- 결재 상태별 필터링
- 결재 상세 조회 및 승인/반려 처리
**진입점**: 사용자 프로필 드롭다운 > "결재함"
### 5.4 결재 단계 시각화 컴포넌트 (v2-approval-step)
**파일 위치**: `frontend/lib/registry/components/v2-approval-step/`
| 파일 | 역할 |
|------|------|
| `types.ts` | ApprovalStepConfig 타입 정의 |
| `ApprovalStepComponent.tsx` | 결재 단계 시각화 UI (가로형 스테퍼 / 세로형 타임라인) |
| `ApprovalStepConfigPanel.tsx` | 설정 패널 (대상 테이블/컬럼 Combobox, 표시 옵션) |
| `ApprovalStepRenderer.tsx` | 컴포넌트 레지스트리 등록 |
| `index.ts` | 컴포넌트 정의 (이름, 태그, 기본값 등) |
#### 설정 항목
| 설정 | 설명 |
|------|------|
| 대상 테이블 | 결재를 걸 데이터가 있는 테이블 (Combobox 검색) |
| 레코드 ID 필드명 | 테이블의 PK 컬럼 (Combobox 검색) |
| 표시 모드 | 가로형 스테퍼 / 세로형 타임라인 |
| 부서/직급 표시 | 결재자의 부서/직급 정보 표시 여부 |
| 결재 코멘트 표시 | 승인/반려 시 입력한 코멘트 표시 여부 |
| 처리 시각 표시 | 결재 처리 시각 표시 여부 |
| 콤팩트 모드 | 작게 표시 |
### 5.5 API 클라이언트
**파일**: `frontend/lib/api/approval.ts`
| 함수 | 용도 |
|------|------|
| `getApprovalDefinitions()` | 결재 유형 목록 조회 |
| `getApprovalTemplates()` | 결재선 템플릿 목록 조회 |
| `getApprovalRequests()` | 결재 요청 목록 조회 (필터 지원) |
| `getApprovalRequest(id)` | 결재 요청 상세 조회 |
| `createApprovalRequest(data)` | 결재 요청 생성 |
| `cancelApprovalRequest(id)` | 결재 취소 |
| `getMyPendingApprovals()` | 내 미처리 결재 목록 |
| `processApprovalLine(lineId, data)` | 승인/반려 처리 |
### 5.6 버튼 액션 연동
#### 관련 파일
| 파일 | 수정 내용 |
|------|-----------|
| `frontend/lib/utils/buttonActions.ts` | `ButtonActionType``"approval"` 추가, `handleApproval` 구현 |
| `frontend/lib/utils/improvedButtonActionExecutor.ts` | `approval` 액션 핸들러 추가 |
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | `silentActions``"approval"` 추가 |
| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 결재 액션 설정 UI (대상 테이블 자동 세팅) |
#### 동작 흐름
1. 버튼 설정에서 액션 타입 = `"approval"` 선택
2. 대상 테이블 자동 설정 (현재 화면 테이블)
3. 버튼 클릭 시 `CustomEvent('open-approval-modal')` 발송
4. `ApprovalGlobalListener`가 수신하여 `ApprovalRequestModal` 오픈
---
## 6. 멀티테넌시 적용
| 영역 | 적용 |
|------|------|
| DB 테이블 | 모든 테이블에 `company_code NOT NULL` 포함 |
| 인덱스 | `company_code` 컬럼에 인덱스 생성 |
| SELECT | `WHERE company_code = $N` 필수 |
| INSERT | `company_code` 값 포함 필수 |
| UPDATE/DELETE | `WHERE` 절에 `company_code` 조건 포함 |
| 최고관리자 | `company_code = '*'` → 모든 데이터 조회 가능 |
| JOIN | `ON` 절에 `company_code` 매칭 포함 |
---
## 7. 전체 파일 목록
### 데이터베이스
```
db/migrations/100_create_approval_system.sql
```
### 백엔드
```
backend-node/src/controllers/approvalController.ts
backend-node/src/routes/approvalRoutes.ts
```
### 프론트엔드 - 결재 모달/리스너
```
frontend/components/approval/ApprovalRequestModal.tsx
frontend/components/approval/ApprovalGlobalListener.tsx
```
### 프론트엔드 - 결재함 페이지
```
frontend/app/(main)/admin/approvalBox/page.tsx
```
### 프론트엔드 - 결재 단계 컴포넌트
```
frontend/lib/registry/components/v2-approval-step/types.ts
frontend/lib/registry/components/v2-approval-step/ApprovalStepComponent.tsx
frontend/lib/registry/components/v2-approval-step/ApprovalStepConfigPanel.tsx
frontend/lib/registry/components/v2-approval-step/ApprovalStepRenderer.tsx
frontend/lib/registry/components/v2-approval-step/index.ts
```
### 프론트엔드 - API 클라이언트
```
frontend/lib/api/approval.ts
```
### 프론트엔드 - 버튼 액션 연동 (수정된 파일)
```
frontend/lib/utils/buttonActions.ts
frontend/lib/utils/improvedButtonActionExecutor.ts
frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx
frontend/components/screen/config-panels/ButtonConfigPanel.tsx
```
### 프론트엔드 - 레이아웃 (수정된 파일)
```
frontend/components/layout/UserDropdown.tsx (결재함 메뉴 추가)
frontend/components/layout/AppLayout.tsx (결재함 메뉴 추가)
frontend/lib/registry/components/index.ts (v2-approval-step 렌더러 import)
```
---
## 8. 사용 방법
### 결재 버튼 추가
1. 화면 디자이너에서 버튼 컴포넌트 추가
2. 버튼 설정 > 액션 타입 = `결재` 선택
3. 대상 테이블이 자동 설정됨 (수동 변경 가능)
4. 저장
### 결재 요청하기
1. 데이터 행 선택 (선택적)
2. 결재 버튼 클릭
3. 결재 모달에서:
- 결재 제목 입력
- 결재 모드 선택 (다단 결재 / 다중 결재)
- 결재자 검색하여 추가
4. 결재 요청 클릭
### 결재 처리하기
1. 프로필 드롭다운 > 결재함 클릭
2. 받은 결재 탭에서 대기 중인 결재 확인
3. 상세 보기 > 승인 또는 반려
### 결재 단계 표시하기
1. 화면 디자이너에서 `결재 단계` 컴포넌트 추가
2. 설정에서 대상 테이블 / 레코드 ID 필드 선택
3. 표시 모드 (가로/세로) 및 옵션 설정
4. 저장 → 행 선택 시 해당 레코드의 결재 단계가 표시됨
---
## 9. 향후 개선 사항
- [ ] 결재 알림 (실시간 알림, 이메일 연동)
- [ ] 제어관리 시스템 연동 (결재 완료 후 자동 액션)
- [ ] 결재 위임 기능
- [ ] 결재 이력 조회 / 통계 대시보드
- [ ] 결재선 즐겨찾기 (자주 쓰는 결재선 저장)
- [ ] 모바일 결재 처리 최적화

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,419 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import {
Loader2, Send, Inbox, CheckCircle, XCircle, Clock, Eye,
} from "lucide-react";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import {
getApprovalRequests,
getApprovalRequest,
getMyPendingApprovals,
processApprovalLine,
cancelApprovalRequest,
type ApprovalRequest,
type ApprovalLine,
} from "@/lib/api/approval";
const STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
requested: { label: "요청", variant: "outline" },
in_progress: { label: "진행중", variant: "default" },
approved: { label: "승인", variant: "default" },
rejected: { label: "반려", variant: "destructive" },
cancelled: { label: "회수", variant: "secondary" },
waiting: { label: "대기", variant: "outline" },
pending: { label: "결재대기", variant: "default" },
skipped: { label: "건너뜀", variant: "secondary" },
};
function StatusBadge({ status }: { status: string }) {
const info = STATUS_MAP[status] || { label: status, variant: "outline" as const };
return <Badge variant={info.variant}>{info.label}</Badge>;
}
function formatDate(dateStr?: string) {
if (!dateStr) return "-";
return new Date(dateStr).toLocaleDateString("ko-KR", {
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
}
// ============================================================
// 상신함 (내가 올린 결재)
// ============================================================
function SentTab() {
const [requests, setRequests] = useState<ApprovalRequest[]>([]);
const [loading, setLoading] = useState(true);
const [detailOpen, setDetailOpen] = useState(false);
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const fetchRequests = useCallback(async () => {
setLoading(true);
const res = await getApprovalRequests({ my_approvals: false });
if (res.success && res.data) setRequests(res.data);
setLoading(false);
}, []);
useEffect(() => { fetchRequests(); }, [fetchRequests]);
const openDetail = async (req: ApprovalRequest) => {
setDetailLoading(true);
setDetailOpen(true);
const res = await getApprovalRequest(req.request_id);
if (res.success && res.data) {
setSelectedRequest(res.data);
} else {
setSelectedRequest(req);
}
setDetailLoading(false);
};
const handleCancel = async () => {
if (!selectedRequest) return;
const res = await cancelApprovalRequest(selectedRequest.request_id);
if (res.success) {
toast.success("결재가 회수되었습니다.");
setDetailOpen(false);
fetchRequests();
} else {
toast.error(res.error || "회수 실패");
}
};
return (
<div className="space-y-4">
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : requests.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<Send className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[140px] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[60px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((req) => (
<TableRow key={req.request_id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-14 text-sm font-medium">{req.title}</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{req.target_table}</TableCell>
<TableCell className="h-14 text-center text-sm">
{req.current_step}/{req.total_steps}
</TableCell>
<TableCell className="h-14 text-center"><StatusBadge status={req.status} /></TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(req.created_at)}</TableCell>
<TableCell className="h-14 text-center">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openDetail(req)}>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 상세 모달 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedRequest?.title}
</DialogDescription>
</DialogHeader>
{detailLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : selectedRequest && (
<div className="max-h-[50vh] space-y-4 overflow-y-auto">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground text-xs"></span>
<div className="mt-1"><StatusBadge status={selectedRequest.status} /></div>
</div>
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1 font-medium">{selectedRequest.current_step}/{selectedRequest.total_steps}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"> </span>
<p className="mt-1 font-medium">{selectedRequest.target_table}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1">{formatDate(selectedRequest.created_at)}</p>
</div>
</div>
{selectedRequest.description && (
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1 text-sm">{selectedRequest.description}</p>
</div>
)}
{/* 결재선 */}
{selectedRequest.lines && selectedRequest.lines.length > 0 && (
<div>
<span className="text-muted-foreground text-xs"></span>
<div className="mt-2 space-y-2">
{selectedRequest.lines
.sort((a, b) => a.step_order - b.step_order)
.map((line) => (
<div key={line.line_id} className="bg-muted/30 flex items-center justify-between rounded-md border p-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">{line.step_order}</Badge>
<span className="text-sm font-medium">{line.approver_name || line.approver_id}</span>
{line.approver_position && (
<span className="text-muted-foreground text-xs">({line.approver_position})</span>
)}
</div>
<div className="flex items-center gap-2">
<StatusBadge status={line.status} />
{line.processed_at && (
<span className="text-muted-foreground text-[10px]">{formatDate(line.processed_at)}</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
{selectedRequest?.status === "requested" && (
<Button variant="destructive" onClick={handleCancel} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
)}
<Button variant="outline" onClick={() => setDetailOpen(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ============================================================
// 수신함 (내가 결재해야 할 것)
// ============================================================
function ReceivedTab() {
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
const [loading, setLoading] = useState(true);
const [processOpen, setProcessOpen] = useState(false);
const [selectedLine, setSelectedLine] = useState<ApprovalLine | null>(null);
const [comment, setComment] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const fetchPending = useCallback(async () => {
setLoading(true);
const res = await getMyPendingApprovals();
if (res.success && res.data) setPendingLines(res.data);
setLoading(false);
}, []);
useEffect(() => { fetchPending(); }, [fetchPending]);
const openProcess = (line: ApprovalLine) => {
setSelectedLine(line);
setComment("");
setProcessOpen(true);
};
const handleProcess = async (action: "approved" | "rejected") => {
if (!selectedLine) return;
setIsProcessing(true);
const res = await processApprovalLine(selectedLine.line_id, {
action,
comment: comment.trim() || undefined,
});
setIsProcessing(false);
if (res.success) {
toast.success(action === "approved" ? "승인되었습니다." : "반려되었습니다.");
setProcessOpen(false);
fetchPending();
} else {
toast.error(res.error || "처리 실패");
}
};
return (
<div className="space-y-4">
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : pendingLines.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<Inbox className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[140px] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingLines.map((line) => (
<TableRow key={line.line_id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-14 text-sm font-medium">{line.title || "-"}</TableCell>
<TableCell className="h-14 text-sm">
{line.requester_name || "-"}
{line.requester_dept && (
<span className="text-muted-foreground ml-1 text-xs">({line.requester_dept})</span>
)}
</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{line.target_table || "-"}</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant="outline">{line.step_order}</Badge>
</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(line.request_created_at || line.created_at)}</TableCell>
<TableCell className="h-14 text-center">
<Button size="sm" className="h-8 text-xs" onClick={() => openProcess(line)}>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 결재 처리 모달 */}
<Dialog open={processOpen} onOpenChange={setProcessOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedLine?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1 font-medium">{selectedLine?.requester_name || "-"}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"> </span>
<p className="mt-1 font-medium">{selectedLine?.step_order} </p>
</div>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="결재 의견을 입력하세요 (선택사항)"
className="min-h-[80px] text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="destructive"
onClick={() => handleProcess("rejected")}
disabled={isProcessing}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<XCircle className="h-4 w-4" />
</Button>
<Button
onClick={() => handleProcess("approved")}
disabled={isProcessing}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle className="h-4 w-4" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ============================================================
// 메인 페이지
// ============================================================
export default function ApprovalBoxPage() {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground text-sm">
.
</p>
</div>
<Tabs defaultValue="received" className="space-y-4">
<TabsList>
<TabsTrigger value="received" className="gap-2">
<Inbox className="h-4 w-4" />
( )
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
<Send className="h-4 w-4" />
( )
</TabsTrigger>
</TabsList>
<TabsContent value="received">
<ReceivedTab />
</TabsContent>
<TabsContent value="sent">
<SentTab />
</TabsContent>
</Tabs>
</div>
<ScrollToTop />
</div>
);
}

View File

@ -1,788 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { Plus, Edit, Trash2, Search, Users, FileText, Loader2 } from "lucide-react";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import {
type ApprovalDefinition,
type ApprovalLineTemplate,
type ApprovalLineTemplateStep,
getApprovalDefinitions,
createApprovalDefinition,
updateApprovalDefinition,
deleteApprovalDefinition,
getApprovalTemplates,
getApprovalTemplate,
createApprovalTemplate,
updateApprovalTemplate,
deleteApprovalTemplate,
} from "@/lib/api/approval";
// ============================================================
// 결재 유형 관리 탭
// ============================================================
function DefinitionsTab() {
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [editOpen, setEditOpen] = useState(false);
const [editingDef, setEditingDef] = useState<ApprovalDefinition | null>(null);
const [formData, setFormData] = useState({
definition_name: "",
definition_name_eng: "",
description: "",
max_steps: 3,
allow_self_approval: false,
allow_cancel: true,
is_active: "Y",
});
const [deleteTarget, setDeleteTarget] = useState<ApprovalDefinition | null>(null);
const fetchDefinitions = useCallback(async () => {
setLoading(true);
const res = await getApprovalDefinitions({ search: searchTerm || undefined });
if (res.success && res.data) {
setDefinitions(res.data);
}
setLoading(false);
}, [searchTerm]);
useEffect(() => {
fetchDefinitions();
}, [fetchDefinitions]);
const openCreate = () => {
setEditingDef(null);
setFormData({
definition_name: "",
definition_name_eng: "",
description: "",
max_steps: 3,
allow_self_approval: false,
allow_cancel: true,
is_active: "Y",
});
setEditOpen(true);
};
const openEdit = (def: ApprovalDefinition) => {
setEditingDef(def);
setFormData({
definition_name: def.definition_name,
definition_name_eng: def.definition_name_eng || "",
description: def.description || "",
max_steps: def.max_steps,
allow_self_approval: def.allow_self_approval,
allow_cancel: def.allow_cancel,
is_active: def.is_active,
});
setEditOpen(true);
};
const handleSave = async () => {
if (!formData.definition_name.trim()) {
toast.warning("결재 유형명을 입력해주세요.");
return;
}
let res;
if (editingDef) {
res = await updateApprovalDefinition(editingDef.definition_id, formData);
} else {
res = await createApprovalDefinition(formData);
}
if (res.success) {
toast.success(editingDef ? "수정되었습니다." : "등록되었습니다.");
setEditOpen(false);
fetchDefinitions();
} else {
toast.error(res.error || "저장 실패");
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
const res = await deleteApprovalDefinition(deleteTarget.definition_id);
if (res.success) {
toast.success("삭제되었습니다.");
setDeleteTarget(null);
fetchDefinitions();
} else {
toast.error(res.error || "삭제 실패");
}
};
const filtered = definitions.filter(
(d) =>
d.definition_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(d.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="space-y-4">
{/* 검색 + 등록 */}
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="유형명 또는 설명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<span className="text-muted-foreground text-sm">
<span className="text-foreground font-semibold">{filtered.length}</span>
</span>
</div>
<Button onClick={openCreate} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 테이블 */}
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((def) => (
<TableRow key={def.definition_id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-14 text-sm font-medium">{def.definition_name}</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{def.description || "-"}</TableCell>
<TableCell className="h-14 text-center text-sm">{def.max_steps}</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant={def.allow_self_approval ? "default" : "secondary"}>
{def.allow_self_approval ? "허용" : "불가"}
</Badge>
</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant={def.allow_cancel ? "default" : "secondary"}>
{def.allow_cancel ? "허용" : "불가"}
</Badge>
</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant={def.is_active === "Y" ? "default" : "outline"}>
{def.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-14 text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(def)}>
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => setDeleteTarget(def)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 등록/수정 모달 */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editingDef ? "결재 유형 수정" : "결재 유형 등록"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.definition_name}
onChange={(e) => setFormData((p) => ({ ...p, definition_name: e.target.value }))}
placeholder="예: 일반 결재, 긴급 결재"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.definition_name_eng}
onChange={(e) => setFormData((p) => ({ ...p, definition_name_eng: e.target.value }))}
placeholder="예: General Approval"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
placeholder="유형에 대한 설명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
type="number"
min={1}
max={10}
value={formData.max_steps}
onChange={(e) => setFormData((p) => ({ ...p, max_steps: Number(e.target.value) }))}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={formData.allow_self_approval}
onCheckedChange={(v) => setFormData((p) => ({ ...p, allow_self_approval: v }))}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={formData.allow_cancel}
onCheckedChange={(v) => setFormData((p) => ({ ...p, allow_cancel: v }))}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={formData.is_active === "Y"}
onCheckedChange={(v) => setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setEditOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{editingDef ? "수정" : "등록"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.definition_name}&quot;() ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ============================================================
// 결재선 템플릿 관리 탭
// ============================================================
function TemplatesTab() {
const [templates, setTemplates] = useState<ApprovalLineTemplate[]>([]);
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [editOpen, setEditOpen] = useState(false);
const [editingTpl, setEditingTpl] = useState<ApprovalLineTemplate | null>(null);
const [formData, setFormData] = useState({
template_name: "",
description: "",
definition_id: null as number | null,
is_active: "Y",
steps: [] as Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[],
});
const [deleteTarget, setDeleteTarget] = useState<ApprovalLineTemplate | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
const [tplRes, defRes] = await Promise.all([
getApprovalTemplates(),
getApprovalDefinitions({ is_active: "Y" }),
]);
if (tplRes.success && tplRes.data) setTemplates(tplRes.data);
if (defRes.success && defRes.data) setDefinitions(defRes.data);
setLoading(false);
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const openCreate = () => {
setEditingTpl(null);
setFormData({
template_name: "",
description: "",
definition_id: null,
is_active: "Y",
steps: [{ step_order: 1, approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
});
setEditOpen(true);
};
const openEdit = async (tpl: ApprovalLineTemplate) => {
const res = await getApprovalTemplate(tpl.template_id);
if (!res.success || !res.data) {
toast.error("템플릿 정보를 불러올 수 없습니다.");
return;
}
const detail = res.data;
setEditingTpl(detail);
setFormData({
template_name: detail.template_name,
description: detail.description || "",
definition_id: detail.definition_id || null,
is_active: detail.is_active,
steps: (detail.steps || []).map((s) => ({
step_order: s.step_order,
approver_type: s.approver_type,
approver_user_id: s.approver_user_id,
approver_position: s.approver_position,
approver_dept_code: s.approver_dept_code,
approver_label: s.approver_label,
})),
});
setEditOpen(true);
};
const addStep = () => {
setFormData((p) => ({
...p,
steps: [
...p.steps,
{
step_order: p.steps.length + 1,
approver_type: "user" as const,
approver_user_id: "",
approver_label: `${p.steps.length + 1}차 결재자`,
},
],
}));
};
const removeStep = (idx: number) => {
setFormData((p) => ({
...p,
steps: p.steps.filter((_, i) => i !== idx).map((s, i) => ({ ...s, step_order: i + 1 })),
}));
};
const updateStep = (idx: number, field: string, value: string) => {
setFormData((p) => ({
...p,
steps: p.steps.map((s, i) => (i === idx ? { ...s, [field]: value } : s)),
}));
};
const handleSave = async () => {
if (!formData.template_name.trim()) {
toast.warning("템플릿명을 입력해주세요.");
return;
}
if (formData.steps.length === 0) {
toast.warning("결재 단계를 최소 1개 추가해주세요.");
return;
}
const payload = {
template_name: formData.template_name,
description: formData.description || undefined,
definition_id: formData.definition_id || undefined,
is_active: formData.is_active,
steps: formData.steps,
};
let res;
if (editingTpl) {
res = await updateApprovalTemplate(editingTpl.template_id, payload);
} else {
res = await createApprovalTemplate(payload);
}
if (res.success) {
toast.success(editingTpl ? "수정되었습니다." : "등록되었습니다.");
setEditOpen(false);
fetchData();
} else {
toast.error(res.error || "저장 실패");
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
const res = await deleteApprovalTemplate(deleteTarget.template_id);
if (res.success) {
toast.success("삭제되었습니다.");
setDeleteTarget(null);
fetchData();
} else {
toast.error(res.error || "삭제 실패");
}
};
const filtered = templates.filter(
(t) =>
t.template_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(t.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="space-y-4">
{/* 검색 + 등록 */}
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="템플릿명 또는 설명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<span className="text-muted-foreground text-sm">
<span className="text-foreground font-semibold">{filtered.length}</span>
</span>
</div>
<Button onClick={openCreate} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
릿
</Button>
</div>
{/* 테이블 */}
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<p className="text-muted-foreground text-sm"> 릿 .</p>
</div>
) : (
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold">릿</TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((tpl) => (
<TableRow key={tpl.template_id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-14 text-sm font-medium">{tpl.template_name}</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{tpl.description || "-"}</TableCell>
<TableCell className="h-14 text-sm">{tpl.definition_name || "-"}</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant="secondary">{tpl.steps?.length || 0}</Badge>
</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant={tpl.is_active === "Y" ? "default" : "outline"}>
{tpl.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-14 text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(tpl)}>
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => setDeleteTarget(tpl)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 등록/수정 모달 */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editingTpl ? "결재선 템플릿 수정" : "결재선 템플릿 등록"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-3 overflow-y-auto sm:space-y-4">
<div>
<Label className="text-xs sm:text-sm">릿 *</Label>
<Input
value={formData.template_name}
onChange={(e) => setFormData((p) => ({ ...p, template_name: e.target.value }))}
placeholder="예: 일반 3단계 결재선"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
placeholder="템플릿에 대한 설명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.definition_id ? String(formData.definition_id) : "none"}
onValueChange={(v) => setFormData((p) => ({ ...p, definition_id: v === "none" ? null : Number(v) }))}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="결재 유형 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{definitions.map((d) => (
<SelectItem key={d.definition_id} value={String(d.definition_id)}>
{d.definition_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={formData.is_active === "Y"}
onCheckedChange={(v) => setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))}
/>
</div>
{/* 결재 단계 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
<Button variant="outline" size="sm" onClick={addStep} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
{formData.steps.length === 0 && (
<p className="text-muted-foreground py-4 text-center text-xs">
.
</p>
)}
{formData.steps.map((step, idx) => (
<div key={idx} className="bg-muted/30 space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{step.step_order}</span>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
onClick={() => removeStep(idx)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"> </Label>
<Select value={step.approver_type} onValueChange={(v) => updateStep(idx, "approver_type", v)}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user"> </SelectItem>
<SelectItem value="position"> </SelectItem>
<SelectItem value="dept"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={step.approver_label || ""}
onChange={(e) => updateStep(idx, "approver_label", e.target.value)}
placeholder="예: 팀장"
className="h-7 text-xs"
/>
</div>
</div>
{step.approver_type === "user" && (
<div>
<Label className="text-[10px]"> ID</Label>
<Input
value={step.approver_user_id || ""}
onChange={(e) => updateStep(idx, "approver_user_id", e.target.value)}
placeholder="고정 결재자 ID (비워두면 요청 시 지정)"
className="h-7 text-xs"
/>
</div>
)}
{step.approver_type === "position" && (
<div>
<Label className="text-[10px]"></Label>
<Input
value={step.approver_position || ""}
onChange={(e) => updateStep(idx, "approver_position", e.target.value)}
placeholder="예: 부장, 이사"
className="h-7 text-xs"
/>
</div>
)}
{step.approver_type === "dept" && (
<div>
<Label className="text-[10px]"> </Label>
<Input
value={step.approver_dept_code || ""}
onChange={(e) => updateStep(idx, "approver_dept_code", e.target.value)}
placeholder="예: DEPT001"
className="h-7 text-xs"
/>
</div>
)}
</div>
))}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setEditOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{editingTpl ? "수정" : "등록"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> 릿 </AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.template_name}&quot;() ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ============================================================
// 메인 페이지
// ============================================================
export default function ApprovalManagementPage() {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm">
릿 .
</p>
</div>
{/* 탭 */}
<Tabs defaultValue="definitions" className="space-y-4">
<TabsList>
<TabsTrigger value="definitions" className="gap-2">
<FileText className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="templates" className="gap-2">
<Users className="h-4 w-4" />
릿
</TabsTrigger>
</TabsList>
<TabsContent value="definitions">
<DefinitionsTab />
</TabsContent>
<TabsContent value="templates">
<TemplatesTab />
</TabsContent>
</Tabs>
</div>
<ScrollToTop />
</div>
);
}

View File

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

View File

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

View File

@ -1,426 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2, CheckCircle2, XCircle, Clock, FileCheck2 } from "lucide-react";
import {
getApprovalRequests,
getApprovalRequest,
getMyPendingApprovals,
processApprovalLine,
cancelApprovalRequest,
type ApprovalRequest,
type ApprovalLine,
} from "@/lib/api/approval";
// 상태 배지 색상
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
requested: { label: "요청됨", variant: "secondary" },
in_progress: { label: "진행 중", variant: "default" },
approved: { label: "승인됨", variant: "outline" },
rejected: { label: "반려됨", variant: "destructive" },
cancelled: { label: "취소됨", variant: "secondary" },
};
const lineStatusConfig: Record<string, { label: string; icon: React.ReactNode }> = {
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
pending: { label: "진행 중", icon: <Clock className="h-3 w-3 text-primary" /> },
approved: { label: "승인", icon: <CheckCircle2 className="h-3 w-3 text-green-600" /> },
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
};
// 결재 상세 모달
interface ApprovalDetailModalProps {
request: ApprovalRequest | null;
open: boolean;
onClose: () => void;
onRefresh: () => void;
pendingLineId?: number; // 내가 처리해야 할 결재 라인 ID
}
function ApprovalDetailModal({ request, open, onClose, onRefresh, pendingLineId }: ApprovalDetailModalProps) {
const [comment, setComment] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
useEffect(() => {
if (!open) setComment("");
}, [open]);
const handleProcess = async (action: "approved" | "rejected") => {
if (!pendingLineId) return;
setIsProcessing(true);
const res = await processApprovalLine(pendingLineId, { action, comment: comment.trim() || undefined });
setIsProcessing(false);
if (res.success) {
onRefresh();
onClose();
}
};
const handleCancel = async () => {
if (!request) return;
setIsCancelling(true);
const res = await cancelApprovalRequest(request.request_id);
setIsCancelling(false);
if (res.success) {
onRefresh();
onClose();
}
};
if (!request) return null;
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FileCheck2 className="h-5 w-5" />
{request.title}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
<Badge variant={statusInfo.variant} className="mr-2">
{statusInfo.label}
</Badge>
: {request.requester_name || request.requester_id}
{request.requester_dept ? ` (${request.requester_dept})` : ""}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 결재 사유 */}
{request.description && (
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium"> </p>
<p className="rounded-md bg-muted p-3 text-xs sm:text-sm">{request.description}</p>
</div>
)}
{/* 결재선 */}
<div>
<p className="text-muted-foreground mb-2 text-xs font-medium"></p>
<div className="space-y-2">
{(request.lines || []).map((line) => {
const lineStatus = lineStatusConfig[line.status] || { label: line.status, icon: null };
return (
<div
key={line.line_id}
className="flex items-start justify-between rounded-md border p-3"
>
<div className="flex items-center gap-2">
{lineStatus.icon}
<div>
<p className="text-xs font-medium sm:text-sm">
{line.approver_label || `${line.step_order}차 결재`} {line.approver_name || line.approver_id}
</p>
{line.approver_position && (
<p className="text-muted-foreground text-[10px] sm:text-xs">{line.approver_position}</p>
)}
{line.comment && (
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
: {line.comment}
</p>
)}
</div>
</div>
<span className="text-muted-foreground text-[10px] sm:text-xs">{lineStatus.label}</span>
</div>
);
})}
</div>
</div>
{/* 승인/반려 입력 (대기 상태일 때만) */}
{pendingLineId && (
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium"> ()</p>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="결재 의견을 입력하세요"
className="min-h-[60px] text-xs sm:text-sm"
/>
</div>
)}
</div>
<DialogFooter className="flex-wrap gap-2 sm:gap-1">
{/* 요청자만 취소 가능 (요청됨/진행 중 상태) */}
{(request.status === "requested" || request.status === "in_progress") && !pendingLineId && (
<Button
variant="outline"
size="sm"
onClick={handleCancel}
disabled={isCancelling}
className="h-8 text-xs"
>
{isCancelling ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : null}
</Button>
)}
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
{pendingLineId && (
<>
<Button
variant="destructive"
onClick={() => handleProcess("rejected")}
disabled={isProcessing}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <XCircle className="mr-1 h-3 w-3" />}
</Button>
<Button
onClick={() => handleProcess("approved")}
disabled={isProcessing}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <CheckCircle2 className="mr-1 h-3 w-3" />}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 결재 대기 행 (ApprovalLine 기반)
function ApprovalLineRow({ line, onClick }: { line: ApprovalLine; onClick: () => void }) {
const statusInfo = lineStatusConfig[line.status] || { label: line.status, icon: null };
const createdAt = line.request_created_at || line.created_at;
const formattedDate = createdAt
? new Date(createdAt).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
: "-";
return (
<button
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
onClick={onClick}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">{line.title || "제목 없음"}</p>
{line.requester_name && (
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
: {line.requester_name}
</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="flex items-center gap-1 text-[10px] sm:text-xs">
{statusInfo.icon}
{statusInfo.label}
</span>
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
</div>
</div>
</button>
);
}
// 결재 요청 행 (ApprovalRequest 기반)
function ApprovalRequestRow({ request, onClick }: { request: ApprovalRequest; onClick: () => void }) {
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
const formattedDate = request.created_at
? new Date(request.created_at).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
: "-";
return (
<button
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
onClick={onClick}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">{request.title}</p>
{request.requester_name && (
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
: {request.requester_name}
</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<Badge variant={statusInfo.variant} className="text-[10px]">
{statusInfo.label}
</Badge>
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
</div>
</div>
</button>
);
}
// 빈 상태 컴포넌트
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<FileCheck2 className="h-7 w-7 text-muted-foreground" />
</div>
<p className="text-muted-foreground text-sm">{message}</p>
</div>
);
}
// 메인 결재함 페이지
export default function ApprovalPage() {
const [activeTab, setActiveTab] = useState("pending");
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
const [myRequests, setMyRequests] = useState<ApprovalRequest[]>([]);
const [completedRequests, setCompletedRequests] = useState<ApprovalRequest[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 상세 모달
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
const [selectedPendingLineId, setSelectedPendingLineId] = useState<number | undefined>();
const [detailModalOpen, setDetailModalOpen] = useState(false);
const loadData = useCallback(async () => {
setIsLoading(true);
const [pendingRes, myRes, completedRes] = await Promise.all([
getMyPendingApprovals(),
// my_approvals 없이 호출 → 백엔드에서 현재 사용자의 요청 건 반환
getApprovalRequests(),
getApprovalRequests({ status: "approved" }),
]);
if (pendingRes.success && pendingRes.data) setPendingLines(pendingRes.data);
if (myRes.success && myRes.data) setMyRequests(myRes.data);
if (completedRes.success && completedRes.data) setCompletedRequests(completedRes.data);
setIsLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const handleOpenDetail = async (requestId: number, pendingLineId?: number) => {
const res = await getApprovalRequest(requestId);
if (res.success && res.data) {
setSelectedRequest(res.data);
setSelectedPendingLineId(pendingLineId);
setDetailModalOpen(true);
}
};
const handleOpenFromLine = async (line: ApprovalLine) => {
if (!line.request_id) return;
await handleOpenDetail(line.request_id, line.line_id);
};
return (
<div className="container mx-auto max-w-3xl p-4 sm:p-6">
<div className="mb-6">
<h1 className="text-xl font-bold sm:text-2xl"></h1>
<p className="text-muted-foreground mt-1 text-sm"> .</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-4 grid w-full grid-cols-3">
<TabsTrigger value="pending" className="text-xs sm:text-sm">
{pendingLines.length > 0 && (
<Badge variant="destructive" className="ml-1 h-4 min-w-[16px] px-1 text-[10px]">
{pendingLines.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="my-requests" className="text-xs sm:text-sm">
</TabsTrigger>
<TabsTrigger value="completed" className="text-xs sm:text-sm">
</TabsTrigger>
</TabsList>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* 대기함: 내가 결재해야 할 건 */}
<TabsContent value="pending">
{pendingLines.length === 0 ? (
<EmptyState message="결재 대기 중인 건이 없습니다." />
) : (
<div className="space-y-2">
{pendingLines.map((line) => (
<ApprovalLineRow
key={line.line_id}
line={line}
onClick={() => handleOpenFromLine(line)}
/>
))}
</div>
)}
</TabsContent>
{/* 요청함: 내가 요청한 건 */}
<TabsContent value="my-requests">
{myRequests.length === 0 ? (
<EmptyState message="요청한 결재 건이 없습니다." />
) : (
<div className="space-y-2">
{myRequests.map((req) => (
<ApprovalRequestRow
key={req.request_id}
request={req}
onClick={() => handleOpenDetail(req.request_id)}
/>
))}
</div>
)}
</TabsContent>
{/* 완료함 */}
<TabsContent value="completed">
{completedRequests.length === 0 ? (
<EmptyState message="완료된 결재 건이 없습니다." />
) : (
<div className="space-y-2">
{completedRequests.map((req) => (
<ApprovalRequestRow
key={req.request_id}
request={req}
onClick={() => handleOpenDetail(req.request_id)}
/>
))}
</div>
)}
</TabsContent>
</>
)}
</Tabs>
{/* 결재 상세 모달 */}
<ApprovalDetailModal
request={selectedRequest}
open={detailModalOpen}
onClose={() => setDetailModalOpen(false)}
onRefresh={loadData}
pendingLineId={selectedPendingLineId}
/>
</div>
);
}

View File

@ -1,14 +1,12 @@
import { AuthProvider } from "@/contexts/AuthContext";
import { MenuProvider } from "@/contexts/MenuContext";
import { AppLayout } from "@/components/layout/AppLayout";
import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener";
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<MenuProvider>
<AppLayout>{children}</AppLayout>
<ApprovalGlobalListener />
</MenuProvider>
</AuthProvider>
);

View File

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

View File

@ -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 ===== */

View File

@ -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" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@ -1,52 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { ApprovalRequestModal, type ApprovalModalEventDetail } from "./ApprovalRequestModal";
/**
*
*
* CustomEvent('open-approval-modal') ApprovalRequestModal을 .
*
* :
* window.dispatchEvent(new CustomEvent('open-approval-modal', {
* detail: {
* targetTable: 'purchase_orders',
* targetRecordId: '123',
* targetRecordData: { ... },
* definitionId: 1,
* screenId: 10,
* buttonComponentId: 'btn-approval-001',
* }
* }));
*/
export const ApprovalGlobalListener: React.FC = () => {
const [open, setOpen] = useState(false);
const [eventDetail, setEventDetail] = useState<ApprovalModalEventDetail | null>(null);
useEffect(() => {
const handleOpenModal = (e: Event) => {
const customEvent = e as CustomEvent<ApprovalModalEventDetail>;
setEventDetail(customEvent.detail || null);
setOpen(true);
};
window.addEventListener("open-approval-modal", handleOpenModal);
return () => {
window.removeEventListener("open-approval-modal", handleOpenModal);
};
}, []);
return (
<ApprovalRequestModal
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) setEventDetail(null);
}}
eventDetail={eventDetail}
/>
);
};
export default ApprovalGlobalListener;

View File

@ -1,483 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers } from "lucide-react";
import { toast } from "sonner";
import { createApprovalRequest } from "@/lib/api/approval";
import { getUserList } from "@/lib/api/user";
// 결재 방식
type ApprovalMode = "sequential" | "parallel";
interface ApproverRow {
id: string;
user_id: string;
user_name: string;
position_name: string;
dept_name: string;
}
export interface ApprovalModalEventDetail {
targetTable: string;
targetRecordId: string;
targetRecordData?: Record<string, any>;
definitionId?: number;
screenId?: number;
buttonComponentId?: string;
}
interface ApprovalRequestModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
eventDetail?: ApprovalModalEventDetail | null;
}
interface UserSearchResult {
userId: string;
userName: string;
positionName?: string;
deptName?: string;
deptCode?: string;
email?: string;
user_id?: string;
user_name?: string;
position_name?: string;
dept_name?: string;
}
function genId(): string {
return `a_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
}
export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
open,
onOpenChange,
eventDetail,
}) => {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [approvalMode, setApprovalMode] = useState<ApprovalMode>("sequential");
const [approvers, setApprovers] = useState<ApproverRow[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 사용자 검색 상태
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<UserSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
// 모달 닫힐 때 초기화
useEffect(() => {
if (!open) {
setTitle("");
setDescription("");
setApprovalMode("sequential");
setApprovers([]);
setError(null);
setSearchOpen(false);
setSearchQuery("");
setSearchResults([]);
}
}, [open]);
// 사용자 검색 (디바운스)
const searchUsers = useCallback(async (query: string) => {
if (!query.trim() || query.trim().length < 1) {
setSearchResults([]);
return;
}
setIsSearching(true);
try {
const res = await getUserList({ search: query.trim(), limit: 20 });
const data = res?.data || res || [];
const rawUsers: any[] = Array.isArray(data) ? data : [];
const users: UserSearchResult[] = rawUsers.map((u: any) => ({
userId: u.userId || u.user_id || "",
userName: u.userName || u.user_name || "",
positionName: u.positionName || u.position_name || "",
deptName: u.deptName || u.dept_name || "",
deptCode: u.deptCode || u.dept_code || "",
email: u.email || "",
}));
const existingIds = new Set(approvers.map((a) => a.user_id));
setSearchResults(users.filter((u) => u.userId && !existingIds.has(u.userId)));
} catch {
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, [approvers]);
useEffect(() => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
searchTimerRef.current = setTimeout(() => {
searchUsers(searchQuery);
}, 300);
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
};
}, [searchQuery, searchUsers]);
const addApprover = (user: UserSearchResult) => {
setApprovers((prev) => [
...prev,
{
id: genId(),
user_id: user.userId,
user_name: user.userName,
position_name: user.positionName || "",
dept_name: user.deptName || "",
},
]);
setSearchQuery("");
setSearchResults([]);
setSearchOpen(false);
};
const removeApprover = (id: string) => {
setApprovers((prev) => prev.filter((a) => a.id !== id));
};
const moveApprover = (idx: number, direction: "up" | "down") => {
setApprovers((prev) => {
const next = [...prev];
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= next.length) return prev;
[next[idx], next[targetIdx]] = [next[targetIdx], next[idx]];
return next;
});
};
const handleSubmit = async () => {
if (!title.trim()) {
setError("결재 제목을 입력해주세요.");
return;
}
if (approvers.length === 0) {
setError("결재자를 1명 이상 추가해주세요.");
return;
}
if (!eventDetail?.targetTable) {
setError("결재 대상 테이블 정보가 없습니다. 버튼 설정을 확인해주세요.");
return;
}
setIsSubmitting(true);
setError(null);
const res = await createApprovalRequest({
title: title.trim(),
description: description.trim() || undefined,
target_table: eventDetail.targetTable,
target_record_id: eventDetail.targetRecordId || undefined,
target_record_data: eventDetail.targetRecordData,
approval_mode: approvalMode,
screen_id: eventDetail.screenId,
button_component_id: eventDetail.buttonComponentId,
approvers: approvers.map((a, idx) => ({
approver_id: a.user_id,
approver_name: a.user_name,
approver_position: a.position_name || undefined,
approver_dept: a.dept_name || undefined,
approver_label:
approvalMode === "sequential"
? `${idx + 1}차 결재`
: "동시 결재",
})),
});
setIsSubmitting(false);
if (res.success) {
toast.success("결재 요청이 완료되었습니다.");
onOpenChange(false);
} else {
setError(res.error || res.message || "결재 요청에 실패했습니다.");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="max-h-[65vh] space-y-4 overflow-y-auto pr-1">
{/* 결재 제목 */}
<div>
<Label htmlFor="approval-title" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="approval-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="결재 제목을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 결재 사유 */}
<div>
<Label htmlFor="approval-desc" className="text-xs sm:text-sm">
</Label>
<Textarea
id="approval-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="결재 사유를 입력하세요 (선택사항)"
className="min-h-[60px] text-xs sm:text-sm"
/>
</div>
{/* 결재 방식 */}
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1.5 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setApprovalMode("sequential")}
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
approvalMode === "sequential"
? "border-primary bg-primary/5 ring-1 ring-primary"
: "hover:bg-muted/50"
}`}
>
<ArrowDown className="h-4 w-4 shrink-0" />
<div>
<p className="text-xs font-medium sm:text-sm"> </p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
</button>
<button
type="button"
onClick={() => setApprovalMode("parallel")}
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
approvalMode === "parallel"
? "border-primary bg-primary/5 ring-1 ring-primary"
: "hover:bg-muted/50"
}`}
>
<Layers className="h-4 w-4 shrink-0" />
<div>
<p className="text-xs font-medium sm:text-sm"> </p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
</button>
</div>
</div>
{/* 결재자 추가 (사용자 검색) */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<span className="text-muted-foreground text-[10px]">
{approvers.length}
</span>
</div>
{/* 검색 입력 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSearchOpen(true);
}}
onFocus={() => setSearchOpen(true)}
placeholder="이름 또는 사번으로 검색..."
className="h-8 pl-9 text-xs sm:h-10 sm:text-sm"
/>
{/* 검색 결과 드롭다운 */}
{searchOpen && searchQuery.trim() && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
{isSearching ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs"> ...</span>
</div>
) : searchResults.length === 0 ? (
<div className="p-4 text-center">
<p className="text-muted-foreground text-xs"> .</p>
</div>
) : (
<div className="max-h-48 overflow-y-auto">
{searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => addApprover(user)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent"
>
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<Users className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">
{user.userName}
<span className="text-muted-foreground ml-1 text-[10px]">
({user.userId})
</span>
</p>
<p className="text-muted-foreground truncate text-[10px]">
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
</p>
</div>
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
</button>
))}
</div>
)}
</div>
)}
</div>
{/* 클릭 외부 영역 닫기 */}
{searchOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setSearchOpen(false)}
/>
)}
{/* 선택된 결재자 목록 */}
{approvers.length === 0 ? (
<p className="text-muted-foreground mt-3 rounded-md border border-dashed p-4 text-center text-xs">
</p>
) : (
<div className="mt-3 space-y-2">
{approvers.map((approver, idx) => (
<div
key={approver.id}
className="bg-muted/30 flex items-center gap-2 rounded-md border p-2"
>
{/* 순서 표시 */}
{approvalMode === "sequential" ? (
<div className="flex shrink-0 flex-col items-center gap-0.5">
<button
type="button"
onClick={() => moveApprover(idx, "up")}
disabled={idx === 0}
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
>
<GripVertical className="h-3 w-3 rotate-90" />
</button>
<Badge variant="outline" className="h-5 min-w-[24px] justify-center px-1 text-[10px]">
{idx + 1}
</Badge>
<button
type="button"
onClick={() => moveApprover(idx, "down")}
disabled={idx === approvers.length - 1}
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
>
<GripVertical className="h-3 w-3 rotate-90" />
</button>
</div>
) : (
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
</Badge>
)}
{/* 사용자 정보 */}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">
{approver.user_name}
<span className="text-muted-foreground ml-1 text-[10px]">
({approver.user_id})
</span>
</p>
<p className="text-muted-foreground truncate text-[10px]">
{[approver.dept_name, approver.position_name].filter(Boolean).join(" / ") || "-"}
</p>
</div>
{/* 제거 버튼 */}
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => removeApprover(approver.id)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
{/* 결재 흐름 시각화 */}
{approvalMode === "sequential" && approvers.length > 1 && (
<p className="text-muted-foreground text-center text-[10px]">
{approvers.map((a) => a.user_name).join(" → ")}
</p>
)}
</div>
)}
</div>
{/* 에러 메시지 */}
{error && (
<div className="bg-destructive/10 rounded-md p-2">
<p className="text-destructive text-xs">{error}</p>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || approvers.length === 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
`결재 상신 (${approvers.length}명)`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ApprovalRequestModal;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,6 +46,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
useEffect(() => {
if (open) {
codeReaderRef.current = new BrowserMultiFormatReader();
// 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청
}
return () => {
@ -183,7 +184,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>

View File

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

View File

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

View File

@ -0,0 +1,109 @@
"use client";
import React, { useMemo } from "react";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
const LoadingFallback = () => (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
/**
* URL .
* .
* URL은 catch-all fallback으로 .
*/
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
// 관리자 메인
"/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }),
// 메뉴 관리
"/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }),
// 사용자 관리
"/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }),
// 화면 관리
"/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }),
// 시스템 관리
"/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
// 자동화 관리
"/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
// 메일
"/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }),
// 배치 관리
"/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }),
// 기타
"/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }),
"/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }),
"/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
"/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }),
};
// 매핑되지 않은 URL용 Fallback
function AdminPageFallback({ url }: { url: string }) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-semibold text-foreground"> </p>
<p className="mt-1 text-sm text-muted-foreground">
: {url}
</p>
<p className="mt-2 text-xs text-muted-foreground">
AdminPageRenderer URL을 .
</p>
</div>
</div>
);
}
interface AdminPageRendererProps {
url: string;
}
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
const PageComponent = useMemo(() => {
// URL에서 쿼리스트링/해시 제거 후 매칭
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
}, [url]);
if (!PageComponent) {
return <AdminPageFallback url={url} />;
}
return <PageComponent />;
}

View File

@ -18,7 +18,6 @@ import {
LogOut,
User,
Building2,
FileCheck,
} from "lucide-react";
import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth";
@ -30,6 +29,9 @@ import { toast } from "sonner";
import { ProfileModal } from "./ProfileModal";
import { Logo } from "./Logo";
import { SideMenu } from "./SideMenu";
import { TabBar } from "./TabBar";
import { TabContent } from "./TabContent";
import { useTabStore } from "@/stores/tabStore";
import {
DropdownMenu,
DropdownMenuContent,
@ -97,7 +99,8 @@ const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
};
// 메뉴 데이터를 UI용으로 변환하는 함수 (최상위 "사용자", "관리자" 제외)
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0"): any[] => {
// parentPath: 탭 제목에 "기준정보 - 회사관리" 형태로 상위 카테고리를 포함하기 위한 경로
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0", parentPath: string = ""): any[] => {
const filteredMenus = menus
.filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId)
.filter((menu) => (menu.status || menu.STATUS) === "active")
@ -110,40 +113,34 @@ const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, p
for (const menu of filteredMenus) {
const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
// "사용자" 또는 "관리자" 카테고리면 하위 메뉴들을 직접 추가
if (menuName.includes("사용자") || menuName.includes("관리자")) {
const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID);
const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID, "");
allMenus.push(...childMenus);
} else {
// 일반 메뉴는 그대로 추가
allMenus.push(convertSingleMenu(menu, menus, userInfo));
allMenus.push(convertSingleMenu(menu, menus, userInfo, ""));
}
}
return allMenus;
}
return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo));
return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo, parentPath));
};
// 단일 메뉴 변환 함수
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null): any => {
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null, parentPath: string = ""): any => {
const menuId = menu.objid || menu.OBJID;
// 사용자 locale 기준으로 번역 처리
const getDisplayText = (menu: MenuItem) => {
// 다국어 텍스트가 있으면 사용, 없으면 기본 텍스트 사용
if (menu.translated_name || menu.TRANSLATED_NAME) {
return menu.translated_name || menu.TRANSLATED_NAME;
const getDisplayText = (m: MenuItem) => {
if (m.translated_name || m.TRANSLATED_NAME) {
return m.translated_name || m.TRANSLATED_NAME;
}
const baseName = menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음";
// 사용자 정보에서 locale 가져오기
const baseName = m.menu_name_kor || m.MENU_NAME_KOR || "메뉴명 없음";
const userLocale = userInfo?.locale || "ko";
if (userLocale === "EN") {
// 영어 번역
const translations: { [key: string]: string } = {
: "Administrator",
: "User Management",
@ -163,7 +160,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
}
}
} else if (userLocale === "JA") {
// 일본어 번역
const translations: { [key: string]: string } = {
: "管理者",
: "ユーザー管理",
@ -183,7 +179,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
}
}
} else if (userLocale === "ZH") {
// 중국어 번역
const translations: { [key: string]: string } = {
: "管理员",
: "用户管理",
@ -207,11 +202,15 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
return baseName;
};
const children = convertMenuToUI(allMenus, userInfo, menuId);
const displayName = getDisplayText(menu);
const tabTitle = parentPath ? `${parentPath} - ${displayName}` : displayName;
const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle);
return {
id: menuId,
name: getDisplayText(menu),
name: displayName,
tabTitle,
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
url: menu.menu_url || menu.MENU_URL || "#",
children: children.length > 0 ? children : undefined,
@ -231,6 +230,28 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
// URL 직접 접근 시 탭 자동 열기 (북마크/공유 링크 대응)
useEffect(() => {
const store = useTabStore.getState();
const currentModeTabs = store[store.mode].tabs;
if (currentModeTabs.length > 0) return;
// /screens/[screenId] 패턴 감지
const screenMatch = pathname.match(/^\/screens\/(\d+)/);
if (screenMatch) {
const screenId = parseInt(screenMatch[1]);
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
store.openTab({ type: "screen", title: `화면 ${screenId}`, screenId, menuObjid });
return;
}
// /admin/* 패턴 감지 -> admin 모드로 전환 후 탭 열기
if (pathname.startsWith("/admin") && pathname !== "/admin") {
store.setMode("admin");
store.openTab({ type: "admin", title: pathname.split("/").pop() || "관리자", adminUrl: pathname });
}
}, []); // 마운트 시 1회만 실행
// 현재 회사명 조회 (SUPER_ADMIN 전용)
useEffect(() => {
const fetchCurrentCompanyName = async () => {
@ -306,8 +327,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
handleRegisterVehicle,
} = useProfile(user, refreshUserData, refreshMenus);
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
// 탭 스토어에서 현재 모드 가져오기
const tabMode = useTabStore((s) => s.mode);
const setTabMode = useTabStore((s) => s.setMode);
const isAdminMode = tabMode === "admin";
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
const isPreviewMode = searchParams.get("preview") === "true";
@ -327,67 +350,55 @@ function AppLayoutInner({ children }: AppLayoutProps) {
setExpandedMenus(newExpanded);
};
// 메뉴 클릭 핸들러
const { openTab } = useTabStore();
// 메뉴 클릭 핸들러 (탭으로 열기)
const handleMenuClick = async (menu: any) => {
if (menu.hasChildren) {
toggleMenu(menu.id);
} else {
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
const menuName = menu.label || menu.name || "메뉴";
// tabTitle: "기준정보 - 회사관리" 형태의 상위 포함 이름
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName);
}
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
try {
const menuObjid = menu.objid || menu.id;
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
if (assignedScreens.length > 0) {
// 할당된 화면이 있으면 첫 번째 화면으로 이동
const firstScreen = assignedScreens[0];
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달
const params = new URLSearchParams();
if (isAdminMode) {
params.set("mode", "admin");
}
params.set("menuObjid", menuObjid.toString());
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
router.push(screenPath);
if (isMobile) {
setSidebarOpen(false);
}
openTab({
type: "screen",
title: menuName,
screenId: firstScreen.screenId,
menuObjid: parseInt(menuObjid),
});
if (isMobile) setSidebarOpen(false);
return;
}
} catch (error) {
console.warn("할당된 화면 조회 실패:", error);
}
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
if (menu.url && menu.url !== "#") {
router.push(menu.url);
if (isMobile) {
setSidebarOpen(false);
}
openTab({
type: "admin",
title: menuName,
adminUrl: menu.url,
});
if (isMobile) setSidebarOpen(false);
} else {
// URL도 없고 할당된 화면도 없으면 경고 메시지
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
}
}
};
// 모드 전환 핸들러
// 모드 전환: 탭 스토어의 모드만 변경 (각 모드 탭은 독립 보존)
const handleModeSwitch = () => {
if (isAdminMode) {
// 관리자 → 사용자 모드: 선택한 회사 유지
router.push("/main");
} else {
// 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음)
router.push("/admin");
}
setTabMode(isAdminMode ? "user" : "admin");
};
// 로그아웃 핸들러
@ -400,13 +411,57 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
};
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
// 사이드바 메뉴 -> 탭 바 드래그용 데이터 생성
const buildMenuDragData = async (menu: any): Promise<string | null> => {
const menuName = menu.label || menu.name || "메뉴";
const menuObjid = menu.objid || menu.id;
try {
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
if (assignedScreens.length > 0) {
return JSON.stringify({
type: "screen" as const,
title: menuName,
screenId: assignedScreens[0].screenId,
menuObjid: parseInt(menuObjid),
});
}
} catch { /* ignore */ }
if (menu.url && menu.url !== "#") {
return JSON.stringify({
type: "admin" as const,
title: menuName,
adminUrl: menu.url,
});
}
return null;
};
const handleMenuDragStart = (e: React.DragEvent, menu: any) => {
if (menu.hasChildren) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = "copy";
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
const menuObjid = menu.objid || menu.id;
const dragPayload = JSON.stringify({ menuName, menuObjid, url: menu.url });
e.dataTransfer.setData("application/tab-menu-pending", dragPayload);
e.dataTransfer.setData("text/plain", menuName);
};
// 메뉴 트리 렌더링 (드래그 가능)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
const isLeaf = !menu.hasChildren;
return (
<div key={menu.id}>
<div
draggable={isLeaf}
onDragStart={(e) => handleMenuDragStart(e, menu)}
className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${
pathname === menu.url
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
@ -435,6 +490,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
{menu.children?.map((child: any) => (
<div
key={child.id}
draggable={!child.hasChildren}
onDragStart={(e) => handleMenuDragStart(e, child)}
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
pathname === child.url
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
@ -525,11 +582,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
@ -698,11 +750,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
@ -712,9 +759,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
</aside>
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
{children}
{/* 가운데 컨텐츠 영역 - 탭 시스템 */}
<main className={`flex min-w-0 flex-1 flex-col overflow-hidden bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
<TabBar />
<TabContent />
</main>
</div>

View File

@ -0,0 +1,23 @@
"use client";
import { LayoutGrid } from "lucide-react";
export function EmptyDashboard() {
return (
<div className="flex h-full items-center justify-center bg-white">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
<LayoutGrid className="h-10 w-10 text-muted-foreground" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground">
</h2>
<p className="max-w-sm text-sm text-muted-foreground">
.
</p>
</div>
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,698 @@
"use client";
import React, { useRef, useState, useEffect, useLayoutEffect, useCallback } from "react";
import { X, RotateCw, ChevronDown } from "lucide-react";
import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore";
import { menuScreenApi } from "@/lib/api/screen";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
const TAB_WIDTH = 180;
const TAB_GAP = 2;
const TAB_UNIT = TAB_WIDTH + TAB_GAP;
const OVERFLOW_BTN_WIDTH = 48;
const DRAG_THRESHOLD = 5;
const SETTLE_MS = 70;
const DROP_SETTLE_MS = 180;
const BAR_PAD_X = 8;
interface DragState {
tabId: string;
pointerId: number;
startX: number;
currentX: number;
tabRect: DOMRect;
fromIndex: number;
targetIndex: number;
activated: boolean;
settling: boolean;
}
interface DropGhost {
title: string;
startX: number;
startY: number;
targetIdx: number;
tabCountAtCreation: number;
}
export function TabBar() {
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
const {
switchTab,
closeTab,
refreshTab,
closeOtherTabs,
closeTabsToLeft,
closeTabsToRight,
closeAllTabs,
updateTabOrder,
openTab,
} = useTabStore();
// --- Refs ---
const containerRef = useRef<HTMLDivElement>(null);
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const dragActiveRef = useRef(false);
const dragLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dropGhostRef = useRef<HTMLDivElement>(null);
const prevTabCountRef = useRef(tabs.length);
// --- State ---
const [visibleCount, setVisibleCount] = useState(tabs.length);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
tabId: string;
} | null>(null);
const [dragState, setDragState] = useState<DragState | null>(null);
const [externalDragIdx, setExternalDragIdx] = useState<number | null>(null);
const [dropGhost, setDropGhost] = useState<DropGhost | null>(null);
dragActiveRef.current = !!dragState;
// --- 타이머 정리 ---
useEffect(() => {
return () => {
if (settleTimer.current) clearTimeout(settleTimer.current);
if (dragLeaveTimerRef.current) clearTimeout(dragLeaveTimerRef.current);
};
}, []);
// --- 드롭 고스트: Web Animations API로 드롭 위치 → 목표 슬롯 이동 ---
useEffect(() => {
if (!dropGhost) return;
const el = dropGhostRef.current;
const bar = containerRef.current?.getBoundingClientRect();
if (!el || !bar) return;
const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT;
const targetY = bar.bottom - 28;
const dx = dropGhost.startX - targetX;
const dy = dropGhost.startY - targetY;
const anim = el.animate(
[
{ transform: `translate(${dx}px, ${dy}px)`, opacity: 0.85 },
{ transform: "translate(0, 0)", opacity: 1 },
],
{
duration: DROP_SETTLE_MS,
easing: "cubic-bezier(0.25, 1, 0.5, 1)",
fill: "forwards",
},
);
anim.onfinish = () => {
setDropGhost(null);
setExternalDragIdx(null);
};
const safety = setTimeout(() => {
setDropGhost(null);
setExternalDragIdx(null);
}, DROP_SETTLE_MS + 500);
return () => {
anim.cancel();
clearTimeout(safety);
};
}, [dropGhost]);
// --- 오버플로우 계산 (드래그 중 재계산 방지) ---
const recalcVisible = useCallback(() => {
if (dragActiveRef.current) return;
if (!containerRef.current) return;
const w = containerRef.current.clientWidth;
setVisibleCount(Math.max(1, Math.floor((w - OVERFLOW_BTN_WIDTH) / TAB_UNIT)));
}, []);
useEffect(() => {
recalcVisible();
const obs = new ResizeObserver(recalcVisible);
if (containerRef.current) obs.observe(containerRef.current);
return () => obs.disconnect();
}, [recalcVisible]);
useLayoutEffect(() => {
recalcVisible();
}, [tabs.length, recalcVisible]);
const visibleTabs = tabs.slice(0, visibleCount);
const overflowTabs = tabs.slice(visibleCount);
const hasOverflow = overflowTabs.length > 0;
const activeInOverflow = activeTabId && overflowTabs.some((t) => t.id === activeTabId);
let displayVisible = visibleTabs;
let displayOverflow = overflowTabs;
if (activeInOverflow && activeTabId) {
const activeTab = tabs.find((t) => t.id === activeTabId)!;
displayVisible = [...visibleTabs.slice(0, -1), activeTab];
displayOverflow = overflowTabs.filter((t) => t.id !== activeTabId);
if (visibleTabs.length > 0) {
displayOverflow = [visibleTabs[visibleTabs.length - 1], ...displayOverflow];
}
}
// ============================================================
// 사이드바 -> 탭 바 드롭 (네이티브 DnD + 삽입 위치 애니메이션)
// ============================================================
useLayoutEffect(() => {
if (tabs.length !== prevTabCountRef.current && externalDragIdx !== null) {
setExternalDragIdx(null);
}
prevTabCountRef.current = tabs.length;
}, [tabs.length, externalDragIdx]);
const resolveMenuAndOpenTab = async (
menuName: string,
menuObjid: string | number,
url: string,
insertIndex?: number,
) => {
const numericObjid = typeof menuObjid === "string" ? parseInt(menuObjid) : menuObjid;
try {
const screens = await menuScreenApi.getScreensByMenu(numericObjid);
if (screens.length > 0) {
openTab(
{ type: "screen", title: menuName, screenId: screens[0].screenId, menuObjid: numericObjid },
insertIndex,
);
return;
}
} catch {
/* ignore */
}
if (url && url !== "#") {
openTab({ type: "admin", title: menuName, adminUrl: url }, insertIndex);
} else {
setExternalDragIdx(null);
}
};
const handleBarDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
if (dragLeaveTimerRef.current) {
clearTimeout(dragLeaveTimerRef.current);
dragLeaveTimerRef.current = null;
}
const bar = containerRef.current?.getBoundingClientRect();
if (bar) {
const idx = Math.max(
0,
Math.min(Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT), displayVisible.length),
);
setExternalDragIdx(idx);
}
};
const handleBarDragLeave = (e: React.DragEvent) => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
dragLeaveTimerRef.current = setTimeout(() => {
setExternalDragIdx(null);
dragLeaveTimerRef.current = null;
}, 50);
}
};
const createDropGhost = (e: React.DragEvent, title: string, targetIdx: number) => {
setDropGhost({
title,
startX: e.clientX - TAB_WIDTH / 2,
startY: e.clientY - 14,
targetIdx,
tabCountAtCreation: tabs.length,
});
};
const handleBarDrop = (e: React.DragEvent) => {
e.preventDefault();
if (dragLeaveTimerRef.current) {
clearTimeout(dragLeaveTimerRef.current);
dragLeaveTimerRef.current = null;
}
const insertIdx = externalDragIdx ?? undefined;
const ghostIdx = insertIdx ?? displayVisible.length;
const pending = e.dataTransfer.getData("application/tab-menu-pending");
if (pending) {
try {
const { menuName, menuObjid, url } = JSON.parse(pending);
createDropGhost(e, menuName, ghostIdx);
resolveMenuAndOpenTab(menuName, menuObjid, url, insertIdx);
} catch {
setExternalDragIdx(null);
}
return;
}
const menuData = e.dataTransfer.getData("application/tab-menu");
if (menuData && menuData.length > 2) {
try {
const parsed = JSON.parse(menuData);
createDropGhost(e, parsed.title || "새 탭", ghostIdx);
setExternalDragIdx(null);
openTab(parsed, insertIdx);
} catch {
setExternalDragIdx(null);
}
} else {
setExternalDragIdx(null);
}
};
// ============================================================
// 탭 드래그 (Pointer Events) - 임계값 + settling 애니메이션
// ============================================================
const calcTarget = useCallback(
(clientX: number, startX: number, fromIndex: number): number => {
const delta = Math.round((clientX - startX) / TAB_UNIT);
return Math.max(0, Math.min(fromIndex + delta, displayVisible.length - 1));
},
[displayVisible.length],
);
const handlePointerDown = (e: React.PointerEvent, tabId: string, idx: number) => {
if ((e.target as HTMLElement).closest("button")) return;
if (dragState?.settling) return;
if (settleTimer.current) {
clearTimeout(settleTimer.current);
settleTimer.current = null;
}
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDragState({
tabId,
pointerId: e.pointerId,
startX: e.clientX,
currentX: e.clientX,
tabRect: (e.currentTarget as HTMLElement).getBoundingClientRect(),
fromIndex: idx,
targetIndex: idx,
activated: false,
settling: false,
});
};
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragState || dragState.settling) return;
if (e.pointerId !== dragState.pointerId) return;
const bar = containerRef.current?.getBoundingClientRect();
if (!bar) return;
const clampedX = Math.max(bar.left, Math.min(e.clientX, bar.right));
if (!dragState.activated) {
if (Math.abs(clampedX - dragState.startX) < DRAG_THRESHOLD) return;
setDragState((p) =>
p
? {
...p,
activated: true,
currentX: clampedX,
targetIndex: calcTarget(clampedX, p.startX, p.fromIndex),
}
: null,
);
return;
}
setDragState((p) =>
p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX, p.startX, p.fromIndex) } : null,
);
},
[dragState, calcTarget],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!dragState || dragState.settling) return;
if (e.pointerId !== dragState.pointerId) return;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
if (!dragState.activated) {
switchTab(dragState.tabId);
setDragState(null);
return;
}
const { fromIndex, targetIndex, tabId } = dragState;
setDragState((p) => (p ? { ...p, settling: true } : null));
if (targetIndex === fromIndex) {
settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 10);
return;
}
const actualFrom = tabs.findIndex((t) => t.id === tabId);
const tgtTab = displayVisible[targetIndex];
const actualTo = tgtTab ? tabs.findIndex((t) => t.id === tgtTab.id) : actualFrom;
settleTimer.current = setTimeout(() => {
setDragState(null);
if (actualFrom !== -1 && actualTo !== -1 && actualFrom !== actualTo) {
updateTabOrder(actualFrom, actualTo);
}
}, SETTLE_MS + 10);
},
[dragState, tabs, displayVisible, switchTab, updateTabOrder],
);
const handleLostPointerCapture = useCallback(() => {
if (dragState && !dragState.settling) {
setDragState(null);
if (settleTimer.current) {
clearTimeout(settleTimer.current);
settleTimer.current = null;
}
}
}, [dragState]);
// ============================================================
// 스타일 계산
// ============================================================
const getTabAnimStyle = (tabId: string, index: number): React.CSSProperties => {
if (externalDragIdx !== null && !dragState) {
return {
transform: index >= externalDragIdx ? `translateX(${TAB_UNIT}px)` : "none",
transition: `transform ${DROP_SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
};
}
if (!dragState || !dragState.activated) return {};
const { fromIndex, targetIndex, tabId: draggedId } = dragState;
if (tabId === draggedId) {
return { opacity: 0, transition: "none" };
}
let shift = 0;
if (fromIndex < targetIndex) {
if (index > fromIndex && index <= targetIndex) shift = -TAB_UNIT;
} else if (fromIndex > targetIndex) {
if (index >= targetIndex && index < fromIndex) shift = TAB_UNIT;
}
return {
transform: shift !== 0 ? `translateX(${shift}px)` : "none",
transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
};
};
const getGhostStyle = (): React.CSSProperties | null => {
if (!dragState || !dragState.activated) return null;
const bar = containerRef.current?.getBoundingClientRect();
if (!bar) return null;
const base: React.CSSProperties = {
position: "fixed",
top: dragState.tabRect.top,
width: TAB_WIDTH,
height: dragState.tabRect.height,
zIndex: 100,
pointerEvents: "none",
opacity: 0.9,
};
if (dragState.settling) {
return {
...base,
left: bar.left + BAR_PAD_X + dragState.targetIndex * TAB_UNIT,
opacity: 1,
boxShadow: "none",
transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 80ms ease-out`,
};
}
const offsetX = dragState.currentX - dragState.startX;
const rawLeft = dragState.tabRect.left + offsetX;
return {
...base,
left: Math.max(bar.left, Math.min(rawLeft, bar.right - TAB_WIDTH)),
transition: "none",
};
};
const ghostStyle = getGhostStyle();
const draggedTab = dragState ? tabs.find((t) => t.id === dragState.tabId) : null;
// ============================================================
// 우클릭 컨텍스트 메뉴
// ============================================================
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
};
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
window.addEventListener("click", close);
window.addEventListener("scroll", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("scroll", close);
};
}, [contextMenu]);
// ============================================================
// 렌더링
// ============================================================
const renderTab = (tab: Tab, displayIndex: number) => {
const isActive = tab.id === activeTabId;
const animStyle = getTabAnimStyle(tab.id, displayIndex);
const hiddenByGhost =
!!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation;
return (
<div
key={tab.id}
onPointerDown={(e) => handlePointerDown(e, tab.id, displayIndex)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onLostPointerCapture={handleLostPointerCapture}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
className={cn(
"group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none",
isActive
? "text-foreground z-10 -mb-px h-[30px] bg-white"
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent",
)}
style={{
width: TAB_WIDTH,
touchAction: "none",
...animStyle,
...(hiddenByGhost ? { opacity: 0 } : {}),
...(isActive ? { boxShadow: "0 -1px 28px rgba(0,0,0,0.9)" } : {}),
}}
title={tab.title}
>
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{tab.title}</span>
<div className="flex shrink-0 items-center">
{isActive && (
<button
onClick={(e) => {
e.stopPropagation();
refreshTab(tab.id);
}}
className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-4 w-4 items-center justify-center rounded-sm transition-colors"
>
<RotateCw className="h-2.5 w-2.5" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
className={cn(
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 items-center justify-center rounded-sm transition-colors",
!isActive && "opacity-0 group-hover:opacity-100",
)}
>
<X className="h-2.5 w-2.5" />
</button>
</div>
</div>
);
};
if (tabs.length === 0) return null;
return (
<>
<div
ref={containerRef}
className="border-border bg-muted/30 relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
onDragOver={handleBarDragOver}
onDragLeave={handleBarDragLeave}
onDrop={handleBarDrop}
>
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
<div className="pointer-events-none absolute inset-0 z-5 bg-black/15" />
{displayVisible.map((tab, i) => renderTab(tab, i))}
{hasOverflow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground flex h-7 shrink-0 items-center gap-0.5 rounded-t-md border border-b-0 border-transparent px-2 text-[11px] font-medium transition-colors">
+{displayOverflow.length}
<ChevronDown className="h-2.5 w-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[300px] overflow-y-auto">
{displayOverflow.map((tab) => (
<DropdownMenuItem
key={tab.id}
onClick={() => switchTab(tab.id)}
className="flex items-center justify-between gap-2"
>
<span className="min-w-0 flex-1 truncate text-xs">{tab.title}</span>
<button
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
className="hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 shrink-0 items-center justify-center rounded-sm"
>
<X className="h-3 w-3" />
</button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* 탭 드래그 고스트 (내부 재정렬) */}
{ghostStyle && draggedTab && (
<div
style={ghostStyle}
className="border-primary/50 bg-background rounded-t-md border border-b-0 px-3 shadow-lg"
>
<div className="flex h-full items-center">
<span className="truncate text-[11px] font-medium">{draggedTab.title}</span>
</div>
</div>
)}
{/* 사이드바 드롭 고스트 (드롭 지점 → 탭 슬롯 이동) */}
{dropGhost &&
(() => {
const bar = containerRef.current?.getBoundingClientRect();
if (!bar) return null;
const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT;
const targetY = bar.bottom - 28;
return (
<div
ref={dropGhostRef}
style={{
position: "fixed",
left: targetX,
top: targetY,
width: TAB_WIDTH,
height: 28,
zIndex: 100,
pointerEvents: "none",
}}
className="border-border bg-background rounded-t-md border border-b-0 px-3"
>
<div className="flex h-full items-center">
<span className="truncate text-[11px] font-medium">{dropGhost.title}</span>
</div>
</div>
);
})()}
{/* 우클릭 컨텍스트 메뉴 */}
{contextMenu && (
<div
className="border-border bg-popover fixed z-50 min-w-[180px] rounded-md border p-1 shadow-md"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<ContextMenuItem
label="새로고침"
onClick={() => {
refreshTab(contextMenu.tabId);
setContextMenu(null);
}}
/>
<div className="bg-border my-1 h-px" />
<ContextMenuItem
label="왼쪽 탭 닫기"
onClick={() => {
closeTabsToLeft(contextMenu.tabId);
setContextMenu(null);
}}
/>
<ContextMenuItem
label="오른쪽 탭 닫기"
onClick={() => {
closeTabsToRight(contextMenu.tabId);
setContextMenu(null);
}}
/>
<ContextMenuItem
label="다른 탭 모두 닫기"
onClick={() => {
closeOtherTabs(contextMenu.tabId);
setContextMenu(null);
}}
/>
<div className="bg-border my-1 h-px" />
<ContextMenuItem
label="모든 탭 닫기"
onClick={() => {
closeAllTabs();
setContextMenu(null);
}}
destructive
/>
</div>
)}
</>
);
}
function ContextMenuItem({
label,
onClick,
destructive,
}: {
label: string;
onClick: () => void;
destructive?: boolean;
}) {
return (
<button
onClick={onClick}
className={cn(
"flex w-full items-center rounded-sm px-2 py-1.5 text-xs transition-colors",
destructive ? "text-destructive hover:bg-destructive/10" : "text-foreground hover:bg-accent",
)}
>
{label}
</button>
);
}

View File

@ -0,0 +1,248 @@
"use client";
import React, { useRef, useEffect, useCallback } from "react";
import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore";
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
import { AdminPageRenderer } from "./AdminPageRenderer";
import { EmptyDashboard } from "./EmptyDashboard";
import { TabIdProvider } from "@/contexts/TabIdContext";
import { registerModalPortal } from "@/lib/modalPortalRef";
import ScreenModal from "@/components/common/ScreenModal";
import {
saveTabCacheImmediate,
loadTabCache,
captureAllScrollPositions,
restoreAllScrollPositions,
getElementPath,
captureFormState,
restoreFormState,
clearTabCache,
} from "@/lib/tabStateCache";
export function TabContent() {
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
const refreshKeys = useTabStore((s) => s.refreshKeys);
// 한 번이라도 활성화된 탭만 마운트 (지연 마운트)
const mountedTabIdsRef = useRef<Set<string>>(new Set());
// 각 탭의 스크롤 컨테이너 ref
const scrollRefsMap = useRef<Map<string, HTMLDivElement | null>>(new Map());
// 이전 활성 탭 ID 추적
const prevActiveTabIdRef = useRef<string | null>(null);
// 활성 탭의 스크롤 위치를 실시간 추적 (display:none 전에 캡처하기 위함)
// Map<tabId, Map<elementPath, {top, left}>> - 탭 내 여러 스크롤 영역을 각각 추적
const lastScrollMapRef = useRef<Map<string, Map<string, { top: number; left: number }>>>(new Map());
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(new WeakMap());
if (activeTabId) {
mountedTabIdsRef.current.add(activeTabId);
}
// 활성 탭의 scroll 이벤트를 감지하여 요소별 위치를 실시간 저장
useEffect(() => {
if (!activeTabId) return;
const container = scrollRefsMap.current.get(activeTabId);
if (!container) return;
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
let path = pathCacheRef.current.get(target);
if (path === undefined) {
path = getElementPath(target, container);
pathCacheRef.current.set(target, path);
}
if (path === null) return;
let tabMap = lastScrollMapRef.current.get(activeTabId);
if (!tabMap) {
tabMap = new Map();
lastScrollMapRef.current.set(activeTabId, tabMap);
}
if (target.scrollTop > 0 || target.scrollLeft > 0) {
tabMap.set(path, { top: target.scrollTop, left: target.scrollLeft });
} else {
tabMap.delete(path);
}
};
container.addEventListener("scroll", handleScroll, true);
return () => container.removeEventListener("scroll", handleScroll, true);
}, [activeTabId]);
// 복원 관련 cleanup ref
const scrollRestoreCleanupRef = useRef<(() => void) | null>(null);
const formRestoreCleanupRef = useRef<(() => void) | null>(null);
// 탭 전환 시: 이전 탭 상태 캐싱, 새 탭 상태 복원
useEffect(() => {
// 이전 복원 작업 취소
if (scrollRestoreCleanupRef.current) {
scrollRestoreCleanupRef.current();
scrollRestoreCleanupRef.current = null;
}
if (formRestoreCleanupRef.current) {
formRestoreCleanupRef.current();
formRestoreCleanupRef.current = null;
}
const prevId = prevActiveTabIdRef.current;
// 이전 활성 탭의 스크롤 + 폼 상태 저장
// 키를 항상 포함하여 이전 캐시의 오래된 값이 병합으로 살아남지 않도록 함
if (prevId && prevId !== activeTabId) {
const tabMap = lastScrollMapRef.current.get(prevId);
const scrollPositions =
tabMap && tabMap.size > 0
? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos }))
: undefined;
const prevEl = scrollRefsMap.current.get(prevId);
const formFields = captureFormState(prevEl ?? null);
saveTabCacheImmediate(prevId, {
scrollPositions,
domFormFields: formFields ?? undefined,
});
}
// 새 활성 탭의 스크롤 + 폼 상태 복원
if (activeTabId) {
const cache = loadTabCache(activeTabId);
if (cache) {
const el = scrollRefsMap.current.get(activeTabId);
if (cache.scrollPositions) {
const cleanup = restoreAllScrollPositions(el ?? null, cache.scrollPositions);
if (cleanup) scrollRestoreCleanupRef.current = cleanup;
}
if (cache.domFormFields) {
const cleanup = restoreFormState(el ?? null, cache.domFormFields ?? null);
if (cleanup) formRestoreCleanupRef.current = cleanup;
}
}
}
prevActiveTabIdRef.current = activeTabId;
}, [activeTabId]);
// F5 새로고침 직전에 활성 탭의 스크롤/폼 상태를 저장
useEffect(() => {
const handleBeforeUnload = () => {
const currentActiveId = prevActiveTabIdRef.current;
if (!currentActiveId) return;
const el = scrollRefsMap.current.get(currentActiveId);
// 활성 탭은 display:block이므로 DOM에서 직접 캡처 (가장 정확)
const scrollPositions = captureAllScrollPositions(el ?? null);
// DOM 캡처 실패 시 실시간 추적 데이터 fallback
const tabMap = lastScrollMapRef.current.get(currentActiveId);
const trackedPositions =
!scrollPositions && tabMap && tabMap.size > 0
? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos }))
: undefined;
const finalPositions = scrollPositions || trackedPositions;
const formFields = captureFormState(el ?? null);
saveTabCacheImmediate(currentActiveId, {
scrollPositions: finalPositions,
domFormFields: formFields ?? undefined,
});
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
if (scrollRestoreCleanupRef.current) scrollRestoreCleanupRef.current();
if (formRestoreCleanupRef.current) formRestoreCleanupRef.current();
};
}, []);
// 탭 닫기 시 캐시 정리 (tabs 배열 변화 감지)
useEffect(() => {
const currentTabIds = new Set(tabs.map((t) => t.id));
const mountedIds = mountedTabIdsRef.current;
mountedIds.forEach((id) => {
if (!currentTabIds.has(id)) {
clearTabCache(id);
scrollRefsMap.current.delete(id);
mountedIds.delete(id);
}
});
}, [tabs]);
const setScrollRef = useCallback((tabId: string, el: HTMLDivElement | null) => {
scrollRefsMap.current.set(tabId, el);
}, []);
// 포탈 컨테이너 ref callback: 전역 레퍼런스에 등록
const portalRefCallback = useCallback((el: HTMLDivElement | null) => {
registerModalPortal(el);
}, []);
if (tabs.length === 0) {
return <EmptyDashboard />;
}
const tabLookup = new Map(tabs.map((t) => [t.id, t]));
const stableIds = Array.from(mountedTabIdsRef.current);
return (
<div ref={portalRefCallback} className="relative min-h-0 flex-1 overflow-hidden">
{stableIds.map((tabId) => {
const tab = tabLookup.get(tabId);
if (!tab) return null;
const isActive = tab.id === activeTabId;
const refreshKey = refreshKeys[tab.id] || 0;
return (
<div
key={tab.id}
ref={(el) => setScrollRef(tab.id, el)}
className="absolute inset-0 overflow-hidden"
style={{ display: isActive ? "block" : "none" }}
>
<TabIdProvider value={tab.id}>
<TabPageRenderer tab={tab} refreshKey={refreshKey} />
<ScreenModal key={`modal-${tab.id}-${refreshKey}`} />
</TabIdProvider>
</div>
);
})}
</div>
);
}
function TabPageRenderer({
tab,
refreshKey,
}: {
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
refreshKey: number;
}) {
if (tab.type === "screen" && tab.screenId != null) {
return (
<ScreenViewPageWrapper
key={`${tab.id}-${refreshKey}`}
screenIdProp={tab.screenId}
menuObjidProp={tab.menuObjid}
/>
);
}
if (tab.type === "admin" && tab.adminUrl) {
return (
<div key={`${tab.id}-${refreshKey}`} className="h-full">
<AdminPageRenderer url={tab.adminUrl} />
</div>
);
}
return null;
}

View File

@ -8,8 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LogOut, User, FileCheck } from "lucide-react";
import { useRouter } from "next/navigation";
import { LogOut, User } from "lucide-react";
interface UserDropdownProps {
user: any;
@ -21,8 +20,6 @@ interface UserDropdownProps {
*
*/
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
const router = useRouter();
if (!user) return null;
return (
@ -82,11 +79,6 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span></span>

View File

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

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