Merge remote-tracking branch 'origin/main' into kwshin-node

Made-with: Cursor

# Conflicts:
#	frontend/app/(main)/admin/screenMng/reportList/page.tsx
This commit is contained in:
shin
2026-03-12 17:02:54 +09:00
1445 changed files with 113736 additions and 534044 deletions
+66
View File
@@ -0,0 +1,66 @@
---
name: pipeline-backend
description: Agent Pipeline 백엔드 전문가. Express + TypeScript + PostgreSQL Raw Query 기반 API 구현. 멀티테넌시(company_code) 필터링 필수.
model: inherit
---
# Role
You are a Backend specialist for ERP-node project.
Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query.
# CRITICAL PROJECT RULES
## 1. Multi-tenancy (ABSOLUTE MUST!)
- ALL queries MUST include company_code filter
- Use req.user!.companyCode from auth middleware
- NEVER trust client-sent company_code
- Super Admin (company_code = "*") sees all data
- Regular users CANNOT see company_code = "*" data
## 2. Required Code Pattern
```typescript
const companyCode = req.user!.companyCode;
if (companyCode === "*") {
query = "SELECT * FROM table ORDER BY company_code";
} else {
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
params = [companyCode];
}
```
## 3. Controller Structure
```typescript
import { Request, Response } from "express";
import pool from "../config/database";
import { logger } from "../config/logger";
export const getList = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
// ... company_code 분기 처리
const result = await pool.query(query, params);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("조회 실패", error);
res.status(500).json({ success: false, message: error.message });
}
};
```
## 4. Route Registration
- backend-node/src/routes/index.ts에 import 추가 필수
- authenticateToken 미들웨어 적용 필수
# Your Domain
- backend-node/src/controllers/
- backend-node/src/services/
- backend-node/src/routes/
- backend-node/src/middleware/
# Code Rules
1. TypeScript strict mode
2. Error handling with try/catch
3. Comments in Korean
4. Follow existing code patterns
5. Use logger for important operations
6. Parameter binding ($1, $2) for SQL injection prevention
+182
View File
@@ -0,0 +1,182 @@
# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수)
## 1. 화면 유형 구분 (절대 규칙!)
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
기능 구현 시 반드시 어느 유형인지 먼저 판단하라.
### 관리자 메뉴 (Admin)
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx`
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
- **특징**: 하드코딩된 UI, 관리자만 접근
### 사용자 메뉴 (User/Screen) - 절대 하드코딩 금지!!!
- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장)
- **데이터 저장**: `screen_layouts_v2` 테이블에 V2 JSON 형식 보관
- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성
- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리
- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등
- **특징**: 코드 수정 없이 화면 구성 변경 가능
- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것!
### 판단 기준
| 질문 | 관리자 메뉴 | 사용자 메뉴 |
|------|-------------|-------------|
| 누가 쓰나? | 시스템 관리자 | 일반 사용자 |
| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) |
| URL 패턴 | `/admin/*` | `/screen/{screen_code}` |
| 메뉴 등록 | `menu_info` INSERT | `screen_definitions` + `menu_info` INSERT |
| 프론트엔드 코드 | `frontend/app/(main)/admin/` 하위에 page.tsx 작성 | **코드 작성 금지!** DB에 스크린 정의만 등록 |
### 사용자 메뉴 구현 방법 (반드시 이 방식으로!)
**절대 규칙: 사용자 메뉴는 React 페이지(.tsx)를 직접 만들지 않는다!**
이미 `/screen/[screenCode]/page.tsx``/screens/[screenId]/page.tsx` 렌더링 시스템이 존재한다.
새 화면이 필요하면 DB에 등록만 하면 자동으로 렌더링된다.
#### Step 1: screen_definitions에 화면 등록
```sql
INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active)
VALUES ('포장/적재정보 관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y')
RETURNING screen_id;
```
- `screen_code`: `{company_code}_{기능약어}` 형식 (예: COMPANY_7_PKG)
- `table_name`: 메인 테이블명 (V2 컴포넌트가 이 테이블 기준으로 동작)
- `company_code`: 대상 회사 코드
#### Step 2: screen_layouts_v2에 V2 레이아웃 JSON 등록
```sql
INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data)
VALUES (
{screen_id},
'COMPANY_7',
1,
'기본 레이어',
'{
"version": "2.0",
"components": [
{
"id": "comp_split_1",
"url": "@/lib/registry/components/v2-split-panel-layout",
"position": {"x": 0, "y": 0},
"size": {"width": 1200, "height": 800},
"displayOrder": 0,
"overrides": {
"leftTitle": "포장단위 목록",
"rightTitle": "상세 정보",
"splitRatio": 40,
"leftTableName": "pkg_unit",
"rightTableName": "pkg_unit",
"tabs": [
{"id": "basic", "label": "기본정보"},
{"id": "items", "label": "매칭품목"}
]
}
}
]
}'::jsonb
);
```
- V2 컴포넌트 목록: v2-split-panel-layout, v2-table-list, v2-table-search-widget, v2-repeater, v2-button-primary, v2-tabs-widget 등
- 상세 컴포넌트 가이드: `.cursor/rules/component-development-guide.mdc` 참조
#### Step 3: menu_info에 메뉴 등록
```sql
-- 먼저 부모 메뉴 objid 조회
-- SELECT objid, menu_name_kor FROM menu_info WHERE company_code = '{회사코드}' AND menu_name_kor LIKE '%물류%';
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, screen_code, company_code, status)
VALUES (
(SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info),
2, -- 2 = 메뉴 항목
{_objid}, -- 상위 메뉴의 objid
'포장/적재정보',
10, -- 정렬 순서
'/screen/COMPANY_7_PKG', -- /screen/{screen_code} 형식 (절대!)
'COMPANY_7_PKG', -- screen_definitions.screen_code와 일치
'COMPANY_7',
'Y'
);
```
**핵심**: `menu_url`은 반드시 `/screen/{screen_code}` 형식이어야 한다!
프론트엔드가 이 URL을 받아 `screen_definitions`에서 screen_id를 찾고, `screen_layouts_v2`에서 레이아웃을 로드한다.
## 2. 관리자 메뉴 등록 (코드 구현 후 필수!)
관리자 기능을 코드로 만들었으면 반드시 `menu_info`에 등록해야 한다.
```sql
-- 예시: 결재 템플릿 관리 메뉴 등록
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, company_code, status)
VALUES (
(SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info),
2, {_objid}, '결재 템플릿', 40, '/admin/approvalTemplate', '대상회사코드', 'Y'
);
```
- 기존 메뉴 구조를 먼저 조회해서 parent_obj_id, seq 등을 맞춰라
- company_code 별로 등록이 필요할 수 있다
- menu_auth_group 권한 매핑도 필요하면 추가
## 3. 하드코딩 금지 / 범용성 필수
- 특정 회사에만 동작하는 코드 금지
- 특정 사용자 ID에 의존하는 로직 금지
- 매직 넘버 사용 금지 (상수 또는 설정 파일로 관리)
- 하드코딩 색상 금지 (CSS 변수 사용: bg-primary, text-destructive 등)
- 하드코딩 URL 금지 (환경 변수 또는 API 클라이언트 사용)
## 4. 테스트 환경 정보
- **테스트 계정**: userId=`wace`, password=`qlalfqjsgh11`
- **역할**: SUPER_ADMIN (company_code = "*")
- **개발 프론트엔드**: http://localhost:9771
- **개발 백엔드 API**: http://localhost:8080
- **개발 DB**: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
## 5. 기능 구현 완성 체크리스트
기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다:
### 공통
- [ ] DB: 마이그레이션 작성 + 실행 완료
- [ ] DB: company_code 컬럼 + 인덱스 존재
- [ ] BE: API 엔드포인트 구현 + 라우트 등록 (app.ts에 import + use 추가!)
- [ ] BE: company_code 필터링 적용
- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc
### 관리자 메뉴인 경우
- [ ] FE: `frontend/app/(main)/admin/{기능}/page.tsx` 작성
- [ ] FE: API 클라이언트 함수 작성 (lib/api/)
- [ ] DB: `menu_info` INSERT (menu_url = `/admin/{기능}`)
### 사용자 메뉴인 경우 (코드 작성 금지!)
- [ ] DB: `screen_definitions` INSERT (screen_code, table_name, company_code)
- [ ] DB: `screen_layouts_v2` INSERT (V2 레이아웃 JSON)
- [ ] DB: `menu_info` INSERT (menu_url = `/screen/{screen_code}`)
- [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만)
- [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링)
## 6. 절대 하지 말 것
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
3. company_code 필터링 빠뜨리기
4. 하드코딩 색상/URL/사용자ID 사용
5. Card 안에 Card 중첩 (중첩 박스 금지)
6. 백엔드 재실행하기 (nodemon이 자동 재시작)
7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)**
- `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지
- 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현
- 이미 `/screen/[screenCode]``/screens/[screenId]` 렌더링 시스템이 존재함
- 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능
- 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성
+50
View File
@@ -0,0 +1,50 @@
---
name: pipeline-db
description: Agent Pipeline DB 전문가. PostgreSQL 스키마 설계, 마이그레이션 작성 및 실행. 모든 테이블에 company_code 필수.
model: inherit
---
# Role
You are a Database specialist for ERP-node project.
Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/.
# CRITICAL PROJECT RULES
## 1. Multi-tenancy (ABSOLUTE MUST!)
- ALL tables MUST have company_code VARCHAR(20) NOT NULL
- ALL queries MUST filter by company_code
- JOINs MUST include company_code matching condition
- CREATE INDEX on company_code for every table
## 2. Migration Rules
- File naming: NNN_description.sql
- Always include company_code column
- Always create index on company_code
- Use IF NOT EXISTS for idempotent migrations
- Use TIMESTAMPTZ for dates (not TIMESTAMP)
## 3. MIGRATION EXECUTION (절대 규칙!)
마이그레이션 SQL 파일을 생성한 후, 반드시 직접 실행해서 테이블을 생성해라.
절대 사용자에게 "직접 실행해주세요"라고 떠넘기지 마라.
Docker 환경:
```bash
DOCKER_HOST=unix:///Users/gbpark/.orbstack/run/docker.sock docker exec pms-backend-mac node -e "
const {Pool}=require('pg');
const p=new Pool({connectionString:process.env.DATABASE_URL,ssl:false});
const fs=require('fs');
const sql=fs.readFileSync('/app/db/migrations/파일명.sql','utf8');
p.query(sql).then(()=>{console.log('OK');p.end()}).catch(e=>{console.error(e.message);p.end();process.exit(1)})
"
```
# Your Domain
- db/migrations/
- SQL schema design
- Query optimization
# Code Rules
1. PostgreSQL syntax only
2. Parameter binding ($1, $2)
3. Use COALESCE for NULL handling
4. Use TIMESTAMPTZ for dates
+92
View File
@@ -0,0 +1,92 @@
---
name: pipeline-frontend
description: Agent Pipeline 프론트엔드 전문가. Next.js 14 + React + TypeScript + shadcn/ui 기반 화면 구현. fetch 직접 사용 금지, lib/api/ 클라이언트 필수.
model: inherit
---
# Role
You are a Frontend specialist for ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui.
# CRITICAL PROJECT RULES
## 1. API Client (ABSOLUTE RULE!)
- NEVER use fetch() directly!
- ALWAYS use lib/api/ clients (Axios-based)
- 환경별 URL 자동 처리: v1.vexplor.com → api.vexplor.com, localhost → localhost:8080
## 2. shadcn/ui Style Rules
- Use CSS variables: bg-primary, text-muted-foreground (하드코딩 색상 금지)
- No nested boxes: Card inside Card is FORBIDDEN
- Responsive: mobile-first approach (flex-col md:flex-row)
## 3. V2 Component Standard
V2 컴포넌트를 만들거나 수정할 때 반드시 이 규격을 따라야 한다.
### 폴더 구조 (필수)
```
frontend/lib/registry/components/v2-{name}/
├── index.ts # createComponentDefinition() 호출
├── types.ts # Config extends ComponentConfig
├── {Name}Component.tsx # React 함수 컴포넌트
├── {Name}Renderer.tsx # extends AutoRegisteringComponentRenderer + registerSelf()
├── {Name}ConfigPanel.tsx # ConfigPanelBuilder 사용
└── config.ts # 기본 설정값 상수
```
### ConfigPanel 규칙 (절대!)
- 반드시 ConfigPanelBuilder 또는 ConfigSection 사용
- 직접 JSX로 설정 UI 작성 금지
## 4. API Client 생성 패턴
```typescript
// frontend/lib/api/yourModule.ts
import apiClient from "@/lib/api/client";
export async function getYourData(id: number) {
const response = await apiClient.get(`/api/your-endpoint/${id}`);
return response.data;
}
```
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!!
**이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.**
사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다!
## 금지 패턴 (절대 하지 말 것)
```
frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라!
frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라!
```
## 올바른 패턴
사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다:
1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등)
2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등)
3. `menu_info` 테이블에 메뉴 등록 (menu_url = `/screen/{screen_code}`)
이미 존재하는 렌더링 시스템:
- `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환
- `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링
## 프론트엔드 에이전트가 할 수 있는 것
- `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신)
- V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`)
- 관리자 메뉴(`/admin/*`)는 React 페이지 코딩 가능
## 프론트엔드 에이전트가 할 수 없는 것
- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것
# Your Domain
- frontend/components/
- frontend/app/
- frontend/lib/
- frontend/hooks/
# Code Rules
1. TypeScript strict mode
2. React functional components with hooks
3. Prefer shadcn/ui components
4. Use cn() utility for conditional classes
5. Comments in Korean
+64
View File
@@ -0,0 +1,64 @@
---
name: pipeline-ui
description: Agent Pipeline UI/UX 디자인 전문가. 모던 엔터프라이즈 UI 구현. CSS 변수 필수, 하드코딩 색상 금지, 반응형 필수.
model: inherit
---
# Role
You are a UI/UX Design specialist for the ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons.
# Design Philosophy
- Apple-level polish with enterprise functionality
- Consistent spacing, typography, color usage
- Subtle animations and micro-interactions
- Dark mode compatible using CSS variables
# CRITICAL STYLE RULES
## 1. Color System (CSS Variables ONLY)
- bg-background / text-foreground (base)
- bg-primary / text-primary-foreground (actions)
- bg-muted / text-muted-foreground (secondary)
- bg-destructive / text-destructive-foreground (danger)
FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black
## 2. Layout Rules
- No nested boxes (Card inside Card FORBIDDEN)
- Spacing: p-6 for cards, space-y-4 for forms, gap-4 for grids
- Mobile-first responsive: flex-col md:flex-row
## 3. Typography
- Page title: text-3xl font-bold
- Section: text-xl font-semibold
- Body: text-sm
- Helper: text-xs text-muted-foreground
## 4. Components
- ALWAYS use shadcn/ui components
- Use cn() for conditional classes
- Use lucide-react for ALL icons
# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!!
사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다.
React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지!
UI 에이전트가 할 수 있는 것:
- V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`)
- 관리자 메뉴(`/admin/*`) 페이지의 UI 개선
- 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선
UI 에이전트가 할 수 없는 것:
- 사용자 메뉴 화면을 React 페이지로 직접 코딩
# Your Domain
- frontend/components/ (UI components)
- frontend/app/ (pages - 관리자 메뉴만)
- frontend/lib/registry/components/v2-*/ (V2 컴포넌트)
# Output Rules
1. TypeScript strict mode
2. "use client" for client components
3. Comments in Korean
4. MINIMAL targeted changes when modifying existing files
+57
View File
@@ -0,0 +1,57 @@
---
name: pipeline-verifier
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증.
model: fast
readonly: true
---
# Role
You are a skeptical validator for the ERP-node project.
Your job is to verify that work claimed as complete actually works.
# Verification Checklist
## 1. Multi-tenancy (최우선)
- [ ] 모든 SQL에 company_code 필터 존재
- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님)
- [ ] INSERT에 company_code 포함
- [ ] JOIN에 company_code 매칭 조건 존재
- [ ] company_code = "*" 최고관리자 예외 처리
## 2. Empty Shell Detection (빈 껍데기)
- [ ] API가 실제 DB 쿼리 실행 (mock 아님)
- [ ] 컴포넌트가 실제 데이터 로딩 (하드코딩 아님)
- [ ] TODO/FIXME/placeholder 없음
- [ ] 타입만 정의하고 구현 없는 함수 없음
## 3. Pattern Compliance (패턴 준수)
- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용)
- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음)
- [ ] Frontend: V2 컴포넌트 규격 준수
- [ ] Backend: logger 사용
- [ ] Backend: try/catch 에러 처리
## 4. Integration Check
- [ ] Route가 index.ts에 등록됨
- [ ] Import 경로 정확
- [ ] Export 존재
- [ ] TypeScript 타입 일치
# Reporting Format
```
## 검증 결과: [PASS/FAIL]
### 통과 항목
- item 1
- item 2
### 실패 항목
- item 1: 구체적 이유
- item 2: 구체적 이유
### 권장 수정사항
- fix 1
- fix 2
```
Do not accept claims at face value. Check the actual code.
-7
View File
@@ -3,13 +3,6 @@
"Framelink Figma MCP": {
"command": "npx",
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
},
"notion": {
"command": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"env": {
"OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer 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\",\"Notion-Version\":\"2022-06-28\"}"
}
}
}
}
@@ -0,0 +1,394 @@
# AI-개발자 협업 작업 수칙
## 핵심 원칙: "추측 금지, 확인 필수"
AI는 코드 작성 전에 반드시 실제 상황을 확인해야 합니다.
---
## 1. 데이터베이스 관련 작업
### 필수 확인 사항
- ✅ **항상 MCP Postgres로 실제 테이블 구조를 먼저 확인**
- ✅ 컬럼명, 데이터 타입, 제약조건을 추측하지 말고 쿼리로 확인
- ✅ 변경 후 실제로 데이터가 어떻게 보이는지 SELECT로 검증
### 확인 방법
```sql
-- 테이블 구조 확인
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = '테이블명'
ORDER BY ordinal_position;
-- 실제 데이터 확인
SELECT * FROM 테이블명 LIMIT 5;
```
### 금지 사항
- ❌ "아마도 `created_at` 컬럼일 것입니다" → 확인 필수!
- ❌ "보통 이렇게 되어있습니다" → 이 프로젝트에서 확인!
- ❌ 다른 테이블 구조를 보고 추측 → 각 테이블마다 확인!
---
## 2. 코드 수정 작업
### 작업 전
1. **관련 파일 읽기**: 수정할 파일의 현재 상태 확인
2. **의존성 파악**: 다른 파일에 영향이 있는지 검색
3. **기존 패턴 확인**: 프로젝트의 코딩 스타일 준수
### 작업 중
1. **한 번에 하나씩**: 하나의 명확한 작업만 수행
2. **로그 추가**: 디버깅이 필요하면 명확한 로그 추가
3. **점진적 수정**: 큰 변경은 여러 단계로 나눔
### 작업 후
1. **로그 제거**: 디버깅 로그는 반드시 제거
2. **테스트 제안**: 브라우저로 테스트할 것을 제안
3. **변경사항 요약**: 무엇을 어떻게 바꿨는지 명확히 설명
---
## 3. 확인 및 검증
### 확인 도구 사용
- **MCP Postgres**: 데이터베이스 구조 및 데이터 확인
- **MCP Browser**: 실제 화면에서 동작 확인
- **codebase_search**: 관련 코드 패턴 검색
- **grep**: 특정 문자열 사용처 찾기
### 검증 프로세스
1. **변경 전 상태 확인** → 문제 파악
2. **변경 적용**
3. **변경 후 상태 확인** → 해결 검증
4. **부작용 확인** → 다른 기능에 영향 없는지
### 사용자 피드백 대응
- 사용자가 "확인 안하지?"라고 하면:
1. 즉시 사과
2. MCP/브라우저로 실제 확인
3. 정확한 정보를 바탕으로 재작업
---
## 4. 커뮤니케이션
### 작업 시작 시
```
✅ 좋은 예:
"MCP로 item_info 테이블 구조를 먼저 확인하겠습니다."
❌ 나쁜 예:
"보통 created_at 컬럼이 있을 것이므로 수정하겠습니다."
```
### 작업 완료 시
```
✅ 좋은 예:
"완료! 두 가지를 수정했습니다:
1. 기본 높이를 40px → 30px로 변경 (ScreenDesigner.tsx:2174)
2. 숨김 컬럼을 created_date, updated_date, writer, company_code로 수정 (TablesPanel.tsx:57)
테스트해보세요!"
❌ 나쁜 예:
"수정했습니다!"
```
### 불확실할 때
```
✅ 좋은 예:
"컬럼명이 created_at인지 created_date인지 확실하지 않습니다.
MCP로 확인해도 될까요?"
❌ 나쁜 예:
"created_at일 것 같으니 일단 이렇게 하겠습니다."
```
---
## 5. 금지 사항
### 절대 금지
1. ❌ **확인 없이 "완료했습니다" 말하기**
- 반드시 실제로 확인하고 보고
2. ❌ **이전에 실패한 방법 반복하기**
- 같은 실수를 두 번 하지 않기
3. ❌ **디버깅 로그를 남겨둔 채 작업 종료**
- 모든 console.log 제거 확인
4. ❌ **추측으로 답변하기**
- "아마도", "보통", "일반적으로" 금지
- 확실하지 않으면 먼저 확인
5. ❌ **여러 문제를 한 번에 수정하려고 시도**
- 한 번에 하나씩 해결
---
## 6. 프로젝트 특별 규칙
### 백엔드 관련
- 🔥 **백엔드 재시작 절대 금지** (사용자 명시 규칙)
- 🔥 Node.js 프로세스를 건드리지 않음
### 데이터베이스 관련
- 🔥 **멀티테넌시 규칙 준수**
- 모든 쿼리에 `company_code` 필터링 필수
- `company_code = "*"`는 최고 관리자 전용
- 자세한 내용: `.cursor/rules/multi-tenancy-guide.mdc`
### API 관련
- 🔥 **API 클라이언트 사용 필수**
- `fetch()` 직접 사용 금지
- `lib/api/` 의 클라이언트 함수 사용
- 환경별 URL 자동 처리
### UI 관련
- 🔥 **shadcn/ui 스타일 가이드 준수**
- CSS 변수 사용 (하드코딩 금지)
- 중첩 박스 금지 (명시 요청 전까지)
- 이모지 사용 금지 (명시 요청 전까지)
---
## 7. 에러 처리
### 에러 발생 시 프로세스
1. **에러 로그 전체 읽기**
- 스택 트레이스 확인
- 에러 메시지 정확히 파악
2. **근본 원인 파악**
- 증상이 아닌 원인 찾기
- 왜 이 에러가 발생했는지 이해
3. **해결책 적용**
- 임시방편이 아닌 근본적 해결
- 같은 에러가 재발하지 않도록
4. **검증**
- 실제로 에러가 해결되었는지 확인
- 다른 부작용은 없는지 확인
### 에러 로깅
```typescript
// ✅ 좋은 로그 (디버깅 시)
console.log("🔍 [컴포넌트명] 작업명:", {
관련변수1,
관련변수2,
예상결과,
});
// ❌ 나쁜 로그
console.log("here");
console.log(data); // 무슨 데이터인지 알 수 없음
```
---
## 8. 작업 완료 체크리스트
모든 작업 완료 전에 다음을 확인:
- [ ] 실제 데이터베이스/파일을 확인했는가?
- [ ] 변경사항이 의도대로 작동하는가?
- [ ] 디버깅 로그를 모두 제거했는가?
- [ ] 다른 기능에 부작용이 없는가?
- [ ] 멀티테넌시 규칙을 준수했는가?
- [ ] 사용자에게 명확히 설명했는가?
---
## 9. 모범 사례
### 데이터베이스 확인 예시
```typescript
// 1. MCP로 테이블 구조 확인
mcp_postgres_query: SELECT column_name FROM information_schema.columns
WHERE table_name = 'item_info';
// 2. 실제 컬럼명 확인 후 코드 작성
const hiddenColumns = new Set([
'id',
'created_date', // ✅ 실제 확인한 컬럼명
'updated_date', // ✅ 실제 확인한 컬럼명
'writer', // ✅ 실제 확인한 컬럼명
'company_code'
]);
```
### 브라우저 테스트 제안 예시
```
"수정이 완료되었습니다!
다음을 테스트해주세요:
1. 화면관리 > 테이블 탭 열기
2. item_info 테이블 확인
3. 기본 5개 컬럼(id, created_date 등)이 안 보이는지 확인
4. 새 컬럼 드래그앤드롭 시 높이가 30px인지 확인
브라우저 테스트를 원하시면 말씀해주세요!"
```
---
## 10. 요약: 핵심 3원칙
1. **확인 우선** 🔍
- 추측하지 말고, 항상 확인하고 작업
2. **한 번에 하나** 🎯
- 여러 문제를 동시에 해결하려 하지 말기
3. **철저한 마무리** ✨
- 로그 제거, 테스트, 명확한 설명
---
## 11. 화면관리 시스템 위젯 개발 가이드
### 위젯 크기 설정의 핵심 원칙
화면관리 시스템에서 위젯을 개발할 때, **크기 제어는 상위 컨테이너(`RealtimePreviewDynamic`)가 담당**합니다.
#### ✅ 올바른 크기 설정 패턴
```tsx
// 위젯 컴포넌트 내부
export function YourWidget({ component }: YourWidgetProps) {
return (
<div
className="flex h-full w-full items-center justify-between gap-2"
style={{
padding: component.style?.padding || "0.75rem",
backgroundColor: component.style?.backgroundColor,
// ❌ width, height, minHeight 등 크기 관련 속성은 제거!
}}
>
{/* 위젯 내용 */}
</div>
);
}
```
#### ❌ 잘못된 크기 설정 패턴
```tsx
// 이렇게 하면 안 됩니다!
<div
style={{
width: component.style?.width || "100%", // ❌ 상위에서 이미 제어함
height: component.style?.height || "80px", // ❌ 상위에서 이미 제어함
minHeight: "80px", // ❌ 내부 컨텐츠가 줄어듦
}}
>
```
### 이유
1. **`RealtimePreviewDynamic`**이 `baseStyle`로 이미 크기를 제어:
```tsx
const baseStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: getWidth(), // size.width 사용
height: getHeight(), // size.height 사용
};
```
2. 위젯 내부에서 크기를 다시 설정하면:
- 중복 설정으로 인한 충돌
- 내부 컨텐츠가 설정한 크기보다 작게 표시됨
- 편집기에서 설정한 크기와 실제 렌더링 크기 불일치
### 위젯이 관리해야 할 스타일
위젯 컴포넌트는 **위젯 고유의 스타일**만 관리합니다:
- ✅ `padding`: 내부 여백
- ✅ `backgroundColor`: 배경색
- ✅ `border`, `borderRadius`: 테두리
- ✅ `gap`: 자식 요소 간격
- ✅ `flexDirection`, `alignItems`: 레이아웃 방향
### 위젯 등록 시 defaultSize
```tsx
ComponentRegistry.registerComponent({
id: "your-widget",
name: "위젯 이름",
category: "utility",
defaultSize: { width: 1200, height: 80 }, // 픽셀 단위 (필수)
component: YourWidget,
defaultProps: {
style: {
padding: "0.75rem",
// width, height는 defaultSize로 제어되므로 여기 불필요
},
},
});
```
### 레이아웃 구조
```tsx
// 전체 높이를 차지하고 내부 요소를 정렬
<div className="flex h-full w-full items-center justify-between gap-2">
{/* 왼쪽 컨텐츠 */}
<div className="flex items-center gap-3">{/* ... */}</div>
{/* 오른쪽 버튼들 */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* flex-shrink-0으로 버튼이 줄어들지 않도록 보장 */}
</div>
</div>
```
### 체크리스트
위젯 개발 시 다음을 확인하세요:
- [ ] 위젯 루트 요소에 `h-full w-full` 클래스 사용
- [ ] `width`, `height`, `minHeight` 인라인 스타일 **제거**
- [ ] `padding`, `backgroundColor` 등 위젯 고유 스타일만 관리
- [ ] `defaultSize`에 적절한 기본 크기 설정
- [ ] 양끝 정렬이 필요하면 `justify-between` 사용
- [ ] 줄어들면 안 되는 요소에 `flex-shrink-0` 적용
---
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
+731
View File
@@ -0,0 +1,731 @@
# WACE ERP/PLM 프로젝트 관행 (Project Conventions)
이 문서는 AI 에이전트가 새 기능을 구현할 때 기존 코드베이스의 관행을 따르기 위한 참조 문서입니다.
코드를 작성하기 전에 반드시 이 문서를 읽고 동일한 패턴을 사용하세요.
---
## 1. 프로젝트 구조
```
ERP-node/
├── backend-node/src/ # Express + TypeScript 백엔드
│ ├── app.ts # 엔트리포인트 (미들웨어, 라우트 등록)
│ ├── controllers/ # API 컨트롤러 (요청 처리, 응답 반환)
│ ├── services/ # 비즈니스 로직 (DB 접근, 트랜잭션)
│ ├── routes/ # Express 라우터 (URL 매핑)
│ ├── middleware/ # 인증, 에러처리, 권한 미들웨어
│ ├── database/db.ts # PostgreSQL 연결 풀, query/queryOne/transaction
│ ├── config/environment.ts # 환경 변수 설정
│ ├── types/ # TypeScript 타입 정의
│ └── utils/logger.ts # winston 로거
├── frontend/ # Next.js 15 (App Router) 프론트엔드
│ ├── app/ # 페이지 (Route Groups: (main), (auth), (admin))
│ ├── components/ # React 컴포넌트
│ │ ├── ui/ # shadcn/ui 기본 컴포넌트 (33개)
│ │ ├── admin/ # 관리자 화면 컴포넌트
│ │ └── screen/ # 화면 디자이너/렌더러 컴포넌트
│ ├── hooks/ # 커스텀 React 훅
│ ├── lib/api/ # API 클라이언트 모듈 (63개 파일)
│ ├── lib/utils.ts # cn() 등 유틸리티
│ ├── types/ # 프론트엔드 타입 정의
│ └── contexts/ # React Context (Auth, Menu 등)
├── db/migrations/ # SQL 마이그레이션 파일
└── docs/ # 프로젝트 문서
```
---
## 2. 백엔드 관행
### 2.1 새 기능 추가 시 파일 생성 순서
1. `backend-node/src/types/` — 타입 정의 (필요 시)
2. `backend-node/src/services/xxxService.ts` — 비즈니스 로직
3. `backend-node/src/controllers/xxxController.ts` — 컨트롤러
4. `backend-node/src/routes/xxxRoutes.ts` — 라우터
5. `backend-node/src/app.ts` — 라우트 등록 (`app.use("/api/xxx", xxxRoutes)`)
### 2.2 컨트롤러 패턴
```typescript
// backend-node/src/controllers/xxxController.ts
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
// 패턴 A: named async function (가장 많이 사용)
export async function getXxxList(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
logger.info("XXX 목록 조회 요청", { companyCode, userId });
// ... 비즈니스 로직 ...
res.status(200).json({
success: true,
message: "XXX 목록 조회 성공",
data: result,
});
} catch (error) {
logger.error("XXX 목록 조회 중 오류:", error);
res.status(500).json({
success: false,
message: "XXX 목록 조회 중 오류가 발생했습니다.",
error: {
code: "XXX_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
}
```
**핵심 규칙:**
- `AuthenticatedRequest`로 인증된 사용자 정보 접근
- `req.user?.companyCode`로 회사 코드 추출
- `try-catch` + `logger.error` + `res.status().json()` 패턴
- 응답 형식: `{ success, data?, message?, error?: { code, details } }`
### 2.3 서비스 패턴
```typescript
// backend-node/src/services/xxxService.ts
import { logger } from "../utils/logger";
import { query, queryOne, transaction } from "../database/db";
export class XxxService {
// static 메서드 또는 인스턴스 메서드 (둘 다 사용됨)
static async getList(companyCode: string, filters?: any) {
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIndex = 2;
if (filters?.search) {
conditions.push(`name ILIKE $${paramIndex}`);
params.push(`%${filters.search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(" AND ")}`
: "";
const rows = await query(
`SELECT * FROM xxx_table ${whereClause} ORDER BY created_date DESC`,
params
);
return rows;
}
}
```
**핵심 규칙:**
- `query<T>(sql, params)` — 다건 조회 (배열 반환)
- `queryOne<T>(sql, params)` — 단건 조회 (객체 | null 반환)
- `transaction(async (client) => { ... })` — 트랜잭션
- 동적 WHERE: `conditions[]` + `params[]` + `paramIndex` 패턴
- 파라미터 바인딩: `$1`, `$2`, ... (절대 문자열 삽입 금지)
### 2.4 DB 쿼리 함수 (database/db.ts)
```typescript
import { query, queryOne, transaction } from "../database/db";
// 다건 조회
const rows = await query<{ id: string; name: string }>(
"SELECT * FROM xxx WHERE company_code = $1",
[companyCode]
);
// 단건 조회
const row = await queryOne<{ id: string }>(
"SELECT * FROM xxx WHERE id = $1 AND company_code = $2",
[id, companyCode]
);
// 트랜잭션
const result = await transaction(async (client) => {
await client.query("INSERT INTO xxx (...) VALUES (...)", [params]);
await client.query("UPDATE yyy SET ... WHERE ...", [params]);
return { success: true };
});
```
### 2.5 라우터 패턴
```typescript
// backend-node/src/routes/xxxRoutes.ts
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { getXxxList, createXxx, updateXxx, deleteXxx } from "../controllers/xxxController";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// CRUD 라우트
router.get("/", getXxxList); // GET /api/xxx
router.get("/:id", getXxxDetail); // GET /api/xxx/:id
router.post("/", createXxx); // POST /api/xxx
router.put("/:id", updateXxx); // PUT /api/xxx/:id
router.delete("/:id", deleteXxx); // DELETE /api/xxx/:id
export default router;
```
**URL 네이밍:**
- 리소스명: 복수형, kebab-case (`/api/flow-definitions`, `/api/admin/users`)
- 하위 리소스: `/api/xxx/:id/yyy`
- 액션: `/api/xxx/:id/toggle`, `/api/xxx/check-duplicate`
### 2.6 app.ts 라우트 등록
```typescript
// backend-node/src/app.ts 에 추가
import xxxRoutes from "./routes/xxxRoutes";
// ...
app.use("/api/xxx", xxxRoutes);
```
라우트 등록 위치: 기존 라우트들 사이에 알파벳 순서 또는 관련 기능 근처에 배치.
### 2.7 타입 정의
```typescript
// backend-node/src/types/xxx.ts
export interface XxxItem {
id: string;
company_code: string;
name: string;
created_date?: string;
updated_date?: string;
writer?: string;
}
```
**공통 타입 (types/common.ts):**
- `ApiResponse<T>` — 표준 API 응답
- `AuthenticatedRequest` — 인증된 요청 (req.user 포함)
- `PaginationParams` — 페이지네이션 파라미터
**인증 타입 (types/auth.ts):**
- `PersonBean` — 세션 사용자 정보 (userId, companyCode, userType 등)
- `AuthenticatedRequest` — Request + PersonBean
### 2.8 로깅
```typescript
import { logger } from "../utils/logger";
logger.info("작업 시작", { companyCode, userId });
logger.error("작업 실패:", error);
logger.warn("경고 상황", { details });
logger.debug("디버그 정보", { query, params });
```
---
## 3. 프론트엔드 관행
### 3.1 새 기능 추가 시 파일 생성 순서
1. `frontend/lib/api/xxx.ts` — API 클라이언트 함수
2. `frontend/hooks/useXxx.ts` — 커스텀 훅 (선택)
3. `frontend/components/xxx/XxxComponent.tsx` — 비즈니스 컴포넌트
4. `frontend/app/(main)/xxx/page.tsx` — 페이지
### 3.2 페이지 패턴
```tsx
// frontend/app/(main)/xxx/page.tsx
"use client";
import { useState } from "react";
import { useXxx } from "@/hooks/useXxx";
import { XxxToolbar } from "@/components/xxx/XxxToolbar";
import { XxxTable } from "@/components/xxx/XxxTable";
export default function XxxPage() {
const { data, isLoading, ... } = useXxx();
return (
<div className="flex min-h-screen flex-col bg-background">
<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-sm text-muted-foreground">페이지 설명</p>
</div>
{/* 툴바 + 테이블 + 모달 등 */}
<XxxToolbar ... />
<XxxTable ... />
</div>
</div>
);
}
```
**핵심 규칙:**
- 모든 페이지: `"use client"` + `export default function`
- 비즈니스 로직은 커스텀 훅으로 분리
- 페이지는 훅 + UI 컴포넌트 조합에 집중
### 3.3 컴포넌트 패턴
```tsx
// frontend/components/xxx/XxxToolbar.tsx
interface XxxToolbarProps {
searchFilter: SearchFilter;
totalCount: number;
onSearchChange: (filter: Partial<SearchFilter>) => void;
onCreateClick: () => void;
}
export function XxxToolbar({
searchFilter,
totalCount,
onSearchChange,
onCreateClick,
}: XxxToolbarProps) {
return (
<div className="flex items-center justify-between">
{/* ... */}
</div>
);
}
```
**핵심 규칙:**
- `export function ComponentName()` (arrow function 아님)
- `interface XxxProps` 정의 후 props 구조 분해
- 이벤트 핸들러: 내부 `handle` 접두사, props 콜백 `on` 접두사
- shadcn/ui 컴포넌트 우선 사용
### 3.4 커스텀 훅 패턴
```typescript
// frontend/hooks/useXxx.ts
import { useState, useCallback, useEffect, useMemo } from "react";
import { xxxApi } from "@/lib/api/xxx";
import { toast } from "sonner";
export const useXxx = () => {
const [data, setData] = useState<XxxItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const response = await xxxApi.getList();
if (response.success) {
setData(response.data);
}
} catch (err) {
setError("데이터 로딩 실패");
toast.error("데이터를 불러올 수 없습니다.");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
return {
data,
isLoading,
error,
refreshData: loadData,
};
};
```
### 3.5 API 클라이언트 패턴
```typescript
// frontend/lib/api/xxx.ts
import { apiClient } from "./client";
interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
export async function getXxxList(params?: Record<string, any>) {
try {
const response = await apiClient.get("/xxx", { params });
return response.data;
} catch (error) {
console.error("XXX 목록 API 오류:", error);
throw error;
}
}
export async function createXxx(data: any) {
try {
const response = await apiClient.post("/xxx", data);
return response.data;
} catch (error) {
console.error("XXX 생성 API 오류:", error);
throw error;
}
}
export async function updateXxx(id: string, data: any) {
const response = await apiClient.put(`/xxx/${id}`, data);
return response.data;
}
export async function deleteXxx(id: string) {
const response = await apiClient.delete(`/xxx/${id}`);
return response.data;
}
// 객체로도 export (선택)
export const xxxApi = {
getList: getXxxList,
create: createXxx,
update: updateXxx,
delete: deleteXxx,
};
```
**핵심 규칙:**
- `apiClient` (Axios) 사용 — 절대 `fetch` 직접 사용 금지
- `apiClient`는 자동으로 Authorization 헤더, 환경별 URL, 토큰 갱신 처리
- URL에 `/api` 접두사 불필요 (client.ts에서 baseURL에 포함됨)
- 개별 함수 export + 객체 export 둘 다 가능
### 3.6 토스트/알림
```typescript
import { toast } from "sonner";
toast.success("저장되었습니다.");
toast.error("저장에 실패했습니다.");
toast.info("처리 중입니다.");
```
- `sonner` 라이브러리 직접 사용
- 루트 레이아웃에 `<Toaster position="top-right" />` 설정됨
### 3.7 모달/다이얼로그
```tsx
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
DialogDescription, DialogFooter
} from "@/components/ui/dialog";
interface XxxModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
editingItem?: XxxItem | null;
}
export function XxxModal({ isOpen, onClose, onSuccess, editingItem }: XxxModalProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">모달 제목</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">설명</DialogDescription>
</DialogHeader>
{/* 컨텐츠 */}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onClose}>취소</Button>
<Button onClick={handleSubmit}>확인</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
### 3.8 레이아웃 계층
```
app/layout.tsx → QueryProvider, RegistryProvider, Toaster
app/(main)/layout.tsx → AuthProvider, MenuProvider, AppLayout
app/(main)/admin/xxx/page.tsx → 실제 페이지
app/(auth)/layout.tsx → 로그인 등 인증 페이지
```
---
## 4. 데이터베이스 관행
### 4.1 테이블 생성 패턴
```sql
CREATE TABLE xxx_table (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
name VARCHAR(500),
description VARCHAR(500),
status VARCHAR(500) DEFAULT 'active',
created_date TIMESTAMP DEFAULT NOW(),
updated_date TIMESTAMP DEFAULT NOW(),
writer VARCHAR(500) DEFAULT NULL
);
CREATE INDEX idx_xxx_table_company_code ON xxx_table(company_code);
```
**기본 컬럼 (모든 테이블 필수):**
- `id` — VARCHAR(500), PK, `gen_random_uuid()::text`
- `company_code` — VARCHAR(500), NOT NULL
- `created_date` — TIMESTAMP, DEFAULT NOW()
- `updated_date` — TIMESTAMP, DEFAULT NOW()
- `writer` — VARCHAR(500)
**컬럼 타입 관행:**
- 문자열: `VARCHAR(500)` (거의 모든 컬럼에 통일)
- 날짜: `TIMESTAMP`
- ID: `VARCHAR(500)` + `gen_random_uuid()::text`
### 4.2 마이그레이션 파일명
```
db/migrations/NNN_description.sql
예: 034_create_numbering_rules.sql
078_create_production_plan_tables.sql
1003_add_source_menu_objid_to_menu_info.sql
```
---
## 5. 멀티테넌시 (가장 중요)
### 5.1 모든 쿼리에 company_code 필수
```typescript
// SELECT
WHERE company_code = $1
// INSERT
INSERT INTO xxx (company_code, ...) VALUES ($1, ...)
// UPDATE
UPDATE xxx SET ... WHERE id = $1 AND company_code = $2
// DELETE
DELETE FROM xxx WHERE id = $1 AND company_code = $2
// JOIN
LEFT JOIN yyy ON xxx.yyy_id = yyy.id AND xxx.company_code = yyy.company_code
WHERE xxx.company_code = $1
```
### 5.2 최고 관리자(SUPER_ADMIN) 예외
```typescript
const companyCode = req.user?.companyCode;
if (companyCode === "*") {
// 최고 관리자: 전체 데이터 조회
query = "SELECT * FROM xxx ORDER BY company_code, created_date DESC";
params = [];
} else {
// 일반 사용자: 자기 회사만
query = "SELECT * FROM xxx WHERE company_code = $1 ORDER BY created_date DESC";
params = [companyCode];
}
```
### 5.3 최고 관리자 가시성 제한
사용자 관련 API에서 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없음:
```typescript
if (req.user && req.user.companyCode !== "*") {
whereConditions.push(`company_code != '*'`);
}
```
---
## 6. 인증 체계
### 6.1 JWT 토큰 기반
- 로그인 → JWT 발급 → `localStorage`에 저장
- 모든 API 요청: `Authorization: Bearer {token}` 헤더
- 프론트엔드 `apiClient`가 자동으로 토큰 관리
### 6.2 사용자 권한 3단계
| 역할 | company_code | userType |
|------|-------------|----------|
| 최고 관리자 | `"*"` | `SUPER_ADMIN` |
| 회사 관리자 | `"COMPANY_A"` | `COMPANY_ADMIN` |
| 일반 사용자 | `"COMPANY_A"` | `USER` |
### 6.3 미들웨어
- `authenticateToken` — JWT 검증 (대부분의 라우트에 적용)
- `requireSuperAdmin` — 최고 관리자 전용
- `requireAdmin` — 관리자(슈퍼+회사) 전용
---
## 7. 코드 스타일 관행
### 7.1 백엔드
- TypeScript strict: `false` (느슨한 타입 체크)
- 로거: `winston` (`logger` import)
- 컬럼명: `snake_case` (DB), `camelCase` (TypeScript 변수)
- 에러 코드: `UPPER_SNAKE_CASE` (예: `XXX_LIST_ERROR`)
### 7.2 프론트엔드
- TypeScript strict: `true`
- 스타일: Tailwind CSS v4 + shadcn/ui
- 클래스 병합: `cn()` (clsx + tailwind-merge)
- 색상: CSS 변수 기반 (`bg-primary`, `text-muted-foreground`)
- 아이콘: `lucide-react`
- 상태 관리: `zustand` (전역), `useState`/`useReducer` (로컬)
- 데이터 패칭: `@tanstack/react-query` 또는 직접 `useEffect` + API 호출
- 폼: `react-hook-form` + `zod` 또는 직접 `useState`
- 테이블: `@tanstack/react-table` 또는 shadcn `Table`
- 차트: `recharts`
- 날짜: `date-fns`
### 7.3 네이밍 컨벤션
| 대상 | 컨벤션 | 예시 |
|------|--------|------|
| 파일명 (백엔드) | camelCase | `xxxController.ts`, `xxxService.ts`, `xxxRoutes.ts` |
| 파일명 (프론트엔드 컴포넌트) | PascalCase | `XxxToolbar.tsx`, `XxxModal.tsx` |
| 파일명 (프론트엔드 훅) | camelCase | `useXxx.ts` |
| 파일명 (프론트엔드 API) | camelCase | `xxx.ts` |
| 파일명 (프론트엔드 페이지) | camelCase 폴더 | `app/(main)/xxxMng/page.tsx` |
| DB 테이블명 | snake_case | `xxx_table`, `user_info` |
| DB 컬럼명 | snake_case | `company_code`, `created_date` |
| 컴포넌트명 | PascalCase | `XxxToolbar`, `XxxModal` |
| 함수명 | camelCase | `getXxxList`, `handleSubmit` |
| 이벤트 핸들러 (내부) | handle 접두사 | `handleCreateUser` |
| 이벤트 콜백 (props) | on 접두사 | `onSearchChange`, `onClose` |
| 상수 | UPPER_SNAKE_CASE | `MAX_PAGE_SIZE`, `DEFAULT_LIMIT` |
---
## 8. 응답 형식 표준
### 8.1 성공 응답
```json
{
"success": true,
"message": "조회 성공",
"data": [ ... ],
"pagination": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
}
}
```
### 8.2 에러 응답
```json
{
"success": false,
"message": "조회 중 오류가 발생했습니다.",
"error": {
"code": "XXX_LIST_ERROR",
"details": "에러 상세 메시지"
}
}
```
---
## 9. 환경별 URL 매핑
| 환경 | 프론트엔드 | 백엔드 API |
|------|-----------|-----------|
| 프로덕션 | `v1.vexplor.com` | `https://api.vexplor.com/api` |
| 개발 (로컬) | `localhost:9771` 또는 `localhost:3000` | `http://localhost:8080/api` |
- 프론트엔드 `apiClient`가 `window.location.hostname` 기반으로 자동 판별
- 프론트엔드에서 API URL 하드코딩 금지
---
## 10. 자주 사용하는 import 경로
### 백엔드
```typescript
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import { AuthenticatedRequest, PersonBean } from "../types/auth";
import { ApiResponse } from "../types/common";
import { query, queryOne, transaction } from "../database/db";
import { authenticateToken } from "../middleware/authMiddleware";
```
### 프론트엔드
```typescript
import { apiClient } from "@/lib/api/client";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, ... } from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
```
---
## 11. 체크리스트: 새 기능 구현 시
### 백엔드
- [ ] `company_code` 필터링이 모든 SELECT/INSERT/UPDATE/DELETE에 포함되어 있는가?
- [ ] `req.user?.companyCode`를 사용하는가? (클라이언트 입력 아님)
- [ ] SUPER_ADMIN (`company_code === "*"`) 예외 처리가 되어 있는가?
- [ ] JOIN 쿼리에도 `company_code` 매칭이 있는가?
- [ ] 파라미터 바인딩 (`$1`, `$2`) 사용하는가? (SQL 인젝션 방지)
- [ ] `try-catch` + `logger` + 적절한 HTTP 상태 코드를 반환하는가?
- [ ] `app.ts`에 라우트가 등록되어 있는가?
### 프론트엔드
- [ ] `apiClient`를 통해 API를 호출하는가? (fetch 직접 사용 금지)
- [ ] `"use client"` 지시어가 있는가?
- [ ] 비즈니스 로직이 커스텀 훅으로 분리되어 있는가?
- [ ] shadcn/ui 컴포넌트를 사용하는가?
- [ ] 에러 시 `toast.error()`로 사용자에게 피드백하는가?
- [ ] 로딩 상태를 표시하는가?
- [ ] 반응형 디자인 (모바일 우선)을 적용했는가?
---
## 12. 주의사항
1. **백엔드 재시작 금지** — nodemon이 파일 변경 감지 시 자동 재시작
2. **fetch 직접 사용 금지** — 반드시 `apiClient` 사용
3. **하드코딩 색상 금지** — `bg-blue-500` 대신 `bg-primary` 등 CSS 변수 사용
4. **company_code 누락 금지** — 모든 비즈니스 테이블/쿼리에 필수
5. **중첩 박스 금지** — Card 안에 Card, Border 안에 Border 금지
6. **항상 한글로 답변**
+471
View File
@@ -0,0 +1,471 @@
---
description: 스크롤 문제 디버깅 및 해결 가이드 - Flexbox 레이아웃에서 스크롤이 작동하지 않을 때 체계적인 진단과 해결 방법
---
# 스크롤 문제 디버깅 및 해결 가이드
React/Next.js 프로젝트에서 Flexbox 레이아웃의 스크롤이 작동하지 않을 때 사용하는 체계적인 디버깅 및 해결 방법입니다.
## 1. 스크롤 문제의 일반적인 원인
### 근본 원인: Flexbox의 높이 계산 실패
Flexbox 레이아웃에서 스크롤이 작동하지 않는 이유:
1. **부모 컨테이너의 높이가 확정되지 않음**: `h-full`은 부모가 명시적인 높이를 가져야만 작동
2. **`minHeight: auto` 기본값**: Flex item은 콘텐츠 크기만큼 늘어나려고 함
3. **`overflow` 속성 누락**: 부모가 `overflow: hidden`이 없으면 자식이 부모를 밀어냄
4. **`display: flex` 누락**: Flex container가 명시적으로 선언되지 않음
## 2. 디버깅 프로세스
### 단계 1: 시각적 디버깅 (컬러 테두리)
문제가 발생한 컴포넌트에 **컬러 테두리**를 추가하여 각 레이어의 실제 크기를 확인:
```tsx
// 최상위 컨테이너 (빨간색)
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
border: "3px solid red", // 🔍 디버그
}}
>
{/* 헤더 (파란색) */}
<div
style={{
flexShrink: 0,
height: "64px",
border: "3px solid blue", // 🔍 디버그
}}
>
헤더
</div>
{/* 스크롤 영역 (초록색) */}
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
border: "3px solid green", // 🔍 디버그
}}
>
콘텐츠
</div>
</div>
```
**브라우저에서 확인할 사항:**
- 🔴 빨간색 테두리가 화면 전체 높이를 차지하는가?
- 🔵 파란색 테두리가 고정된 높이를 유지하는가?
- 🟢 초록색 테두리가 남은 공간을 차지하는가?
### 단계 2: 부모 체인 추적
스크롤이 작동하지 않으면 **부모 컨테이너부터 역순으로 추적**:
```tsx
// ❌ 문제 예시
<div className="flex flex-col"> {/* 높이가 확정되지 않음 */}
<div className="flex-1"> {/* flex-1이 작동하지 않음 */}
<ComponentWithScroll /> {/* 스크롤 실패 */}
</div>
</div>
// ✅ 해결
<div className="flex flex-col h-screen"> {/* 높이 확정 */}
<div className="flex-1 overflow-hidden"> {/* overflow 제한 */}
<ComponentWithScroll /> {/* 스크롤 성공 */}
</div>
</div>
```
### 단계 3: 개발자 도구로 Computed Style 확인
브라우저 개발자 도구에서 확인:
1. **Height**: `auto`가 아닌 구체적인 px 값이 있는가?
2. **Display**: `flex`가 제대로 적용되었는가?
3. **Overflow**: `overflow-y: auto` 또는 `scroll`이 적용되었는가?
4. **Min-height**: `minHeight: 0`이 적용되었는가? (Flex item의 경우)
## 3. 해결 패턴
### 패턴 A: 최상위 Fixed/Absolute 컨테이너
```tsx
// 페이지 레벨 (예: dataflow/page.tsx)
<div className="fixed inset-0 z-50 bg-background">
<div className="flex h-full flex-col">
{/* 헤더 (고정) */}
<div className="flex items-center gap-4 border-b bg-background p-4">
헤더
</div>
{/* 에디터 (flex-1) */}
<div className="flex-1 overflow-hidden">
{" "}
{/* ⚠️ overflow-hidden 필수! */}
<FlowEditor />
</div>
</div>
</div>
```
**핵심 포인트:**
- `fixed inset-0`: 뷰포트 전체 차지
- `flex h-full flex-col`: Flex column 레이아웃
- `flex-1 overflow-hidden`: 자식이 부모를 넘지 못하게 제한
### 패턴 B: 중첩된 Flex 컨테이너
```tsx
// 컴포넌트 레벨 (예: FlowEditor.tsx)
<div
className="flex h-full w-full"
style={{ height: "100%", overflow: "hidden" }} // ⚠️ 인라인 스타일로 강제
>
{/* 좌측 사이드바 */}
<div className="h-full w-[300px] border-r bg-white">사이드바</div>
{/* 중앙 캔버스 */}
<div className="relative flex-1">캔버스</div>
{/* 우측 속성 패널 */}
<div
style={{
height: "100%",
width: "350px",
display: "flex", // ⚠️ Flex 컨테이너 명시
flexDirection: "column",
}}
className="border-l bg-white"
>
<PropertiesPanel />
</div>
</div>
```
**핵심 포인트:**
- 인라인 스타일 `height: '100%'`: Tailwind보다 우선순위 높음
- `display: "flex"`: Flex 컨테이너 명시
- `overflow: 'hidden'`: 자식 크기 제한
### 패턴 C: 스크롤 가능 영역
```tsx
// 스크롤 영역 (예: PropertiesPanel.tsx)
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
overflow: "hidden", // ⚠️ 최상위는 overflow hidden
}}
>
{/* 헤더 (고정) */}
<div
style={{
flexShrink: 0, // ⚠️ 축소 방지
height: "64px", // ⚠️ 명시적 높이
}}
className="flex items-center justify-between border-b bg-white p-4"
>
헤더
</div>
{/* 스크롤 영역 */}
<div
style={{
flex: 1, // ⚠️ 남은 공간 차지
minHeight: 0, // ⚠️ 핵심! Flex item 축소 허용
overflowY: "auto", // ⚠️ 세로 스크롤
overflowX: "hidden", // ⚠️ 가로 스크롤 방지
}}
>
{/* 실제 콘텐츠 */}
<PropertiesContent />
</div>
</div>
```
**핵심 포인트:**
- `flexShrink: 0`: 헤더가 축소되지 않도록 고정
- `minHeight: 0`: **가장 중요!** Flex item이 축소되도록 허용
- `flex: 1`: 남은 공간 모두 차지
- `overflowY: 'auto'`: 콘텐츠가 넘치면 스크롤 생성
## 4. 왜 `minHeight: 0`이 필요한가?
### Flexbox의 기본 동작
```css
/* Flexbox의 기본값 */
.flex-item {
min-height: auto; /* 콘텐츠 크기만큼 늘어남 */
}
```
**문제:**
- Flex item은 **콘텐츠 크기만큼 늘어나려고 함**
- `flex: 1`만으로는 **스크롤이 생기지 않고 부모를 밀어냄**
- 결과: 스크롤 영역이 화면 밖으로 넘어감
**해결:**
```css
.flex-item {
flex: 1;
min-height: 0; /* 축소 허용 → 스크롤 발생 */
overflow-y: auto;
}
```
## 5. Tailwind vs 인라인 스타일
### 언제 인라인 스타일을 사용하는가?
**Tailwind가 작동하지 않을 때:**
```tsx
// ❌ Tailwind가 작동하지 않음
<div className="flex flex-col h-full">
// ✅ 인라인 스타일로 강제
<div
className="flex flex-col"
style={{ height: '100%', overflow: 'hidden' }}
>
```
**이유:**
1. **CSS 특이성**: 인라인 스타일이 가장 높은 우선순위
2. **동적 계산**: 브라우저가 직접 해석
3. **디버깅 쉬움**: 개발자 도구에서 바로 확인 가능
## 6. 체크리스트
스크롤 문제 발생 시 확인할 사항:
### 레이아웃 체크
- [ ] 최상위 컨테이너: `fixed` 또는 `absolute`로 높이 확정
- [ ] 부모: `flex flex-col h-full`
- [ ] 중간 컨테이너: `flex-1 overflow-hidden`
- [ ] 스크롤 컨테이너 부모: `display: flex, flexDirection: column, height: 100%`
### 스크롤 영역 체크
- [ ] 헤더: `flexShrink: 0` + 명시적 높이
- [ ] 스크롤 영역: `flex: 1, minHeight: 0, overflowY: auto`
- [ ] 콘텐츠: 자연스러운 높이 (height 제약 없음)
### 디버깅 체크
- [ ] 컬러 테두리로 각 레이어의 크기 확인
- [ ] 개발자 도구로 Computed Style 확인
- [ ] 부모 체인을 역순으로 추적
- [ ] `minHeight: 0` 적용 확인
## 7. 일반적인 실수
### 실수 1: 부모의 높이 미확정
```tsx
// ❌ 부모의 높이가 auto
<div className="flex flex-col">
<div className="flex-1">
<ScrollComponent /> {/* 작동 안 함 */}
</div>
</div>
// ✅ 부모의 높이 확정
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-hidden">
<ScrollComponent /> {/* 작동 */}
</div>
</div>
```
### 실수 2: overflow-hidden 누락
```tsx
// ❌ overflow-hidden 없음
<div className="flex-1">
<ScrollComponent /> {/* 부모를 밀어냄 */}
</div>
// ✅ overflow-hidden 추가
<div className="flex-1 overflow-hidden">
<ScrollComponent /> {/* 제한됨 */}
</div>
```
### 실수 3: minHeight: 0 누락
```tsx
// ❌ minHeight: 0 없음
<div style={{ flex: 1, overflowY: 'auto' }}>
{/* 스크롤 안 됨 */}
</div>
// ✅ minHeight: 0 추가
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
{/* 스크롤 됨 */}
</div>
```
### 실수 4: display: flex 누락
```tsx
// ❌ Flex 컨테이너 미지정
<div style={{ height: '100%', width: '350px' }}>
<PropertiesPanel /> {/* flex-1이 작동 안 함 */}
</div>
// ✅ Flex 컨테이너 명시
<div style={{
height: '100%',
width: '350px',
display: 'flex',
flexDirection: 'column'
}}>
<PropertiesPanel /> {/* 작동 */}
</div>
```
## 8. 완전한 예시
### 전체 레이아웃 구조
```tsx
// 페이지 (dataflow/page.tsx)
<div className="fixed inset-0 z-50 bg-background">
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="flex items-center gap-4 border-b bg-background p-4">
헤더
</div>
{/* 에디터 */}
<div className="flex-1 overflow-hidden">
<FlowEditor />
</div>
</div>
</div>
// 에디터 (FlowEditor.tsx)
<div
className="flex h-full w-full"
style={{ height: '100%', overflow: 'hidden' }}
>
{/* 사이드바 */}
<div className="h-full w-[300px] border-r">
사이드바
</div>
{/* 캔버스 */}
<div className="relative flex-1">
캔버스
</div>
{/* 속성 패널 */}
<div
style={{
height: "100%",
width: "350px",
display: "flex",
flexDirection: "column",
}}
className="border-l bg-white"
>
<PropertiesPanel />
</div>
</div>
// 속성 패널 (PropertiesPanel.tsx)
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
overflow: 'hidden'
}}
>
{/* 헤더 */}
<div
style={{
flexShrink: 0,
height: '64px'
}}
className="flex items-center justify-between border-b bg-white p-4"
>
헤더
</div>
{/* 스크롤 영역 */}
<div
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden'
}}
>
{/* 콘텐츠 */}
<PropertiesContent />
</div>
</div>
```
## 9. 요약
### 핵심 원칙
1. **높이 확정**: 부모 체인의 모든 요소가 명시적인 높이를 가져야 함
2. **overflow 제어**: 중간 컨테이너는 `overflow-hidden`으로 자식 제한
3. **Flex 명시**: `display: flex` + `flexDirection: column` 명시
4. **minHeight: 0**: 스크롤 영역의 Flex item은 반드시 `minHeight: 0` 적용
5. **인라인 스타일**: Tailwind가 작동하지 않으면 인라인 스타일 사용
### 디버깅 순서
1. 🎨 **컬러 테두리** 추가로 시각적 확인
2. 🔍 **개발자 도구**로 Computed Style 확인
3. 🔗 **부모 체인** 역순으로 추적
4. ✅ **체크리스트** 항목 확인
5. 🔧 **패턴 적용** 및 테스트
### 최종 구조
```
페이지 (fixed inset-0)
└─ flex flex-col h-full
├─ 헤더 (고정)
└─ 컨테이너 (flex-1 overflow-hidden)
└─ 에디터 (height: 100%, overflow: hidden)
└─ flex row
└─ 패널 (display: flex, flexDirection: column)
└─ 패널 내부 (height: 100%)
├─ 헤더 (flexShrink: 0, height: 64px)
└─ 스크롤 (flex: 1, minHeight: 0, overflowY: auto)
```
## 10. 참고 자료
이 가이드는 다음 파일을 기반으로 작성되었습니다:
- [dataflow/page.tsx](<mdc:frontend/app/(main)/admin/dataflow/page.tsx>)
- [FlowEditor.tsx](mdc:frontend/components/dataflow/node-editor/FlowEditor.tsx)
- [PropertiesPanel.tsx](mdc:frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx)
@@ -0,0 +1,310 @@
# TableListComponent 개발 가이드
## 개요
`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다.
**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
---
## 핵심 기능 목록
### 1. 인라인 편집 (Inline Editing)
- 셀 더블클릭 또는 F2 키로 편집 모드 진입
- 직접 타이핑으로도 편집 모드 진입 가능
- Enter로 저장, Escape로 취소
- **컬럼별 편집 가능 여부 설정** (`editable` 속성)
```typescript
// ColumnConfig에서 editable 속성 사용
interface ColumnConfig {
editable?: boolean; // false면 해당 컬럼 인라인 편집 불가
}
```
**편집 불가 컬럼 체크 필수 위치**:
1. `handleCellDoubleClick` - 더블클릭 편집
2. `onKeyDown` F2 케이스 - 키보드 편집
3. `onKeyDown` default 케이스 - 직접 타이핑 편집
4. 컨텍스트 메뉴 "셀 편집" 옵션
### 2. 배치 편집 (Batch Editing)
- 여러 셀 수정 후 일괄 저장/취소
- `pendingChanges` Map으로 변경사항 추적
- 저장 전 유효성 검증
### 3. 데이터 유효성 검증 (Validation)
```typescript
type ValidationRule = {
required?: boolean;
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
customMessage?: string;
validate?: (value: any, row: any) => string | null;
};
```
### 4. 컬럼 헤더 필터 (Header Filter)
- 각 컬럼 헤더에 필터 아이콘
- 고유값 목록에서 다중 선택 필터링
- `headerFilters` Map으로 필터 상태 관리
### 5. 필터 빌더 (Filter Builder)
```typescript
interface FilterCondition {
id: string;
column: string;
operator: "equals" | "notEquals" | "contains" | "notContains" |
"startsWith" | "endsWith" | "greaterThan" | "lessThan" |
"greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty";
value: string;
}
interface FilterGroup {
id: string;
logic: "AND" | "OR";
conditions: FilterCondition[];
}
```
### 6. 검색 패널 (Search Panel)
- 전체 데이터 검색
- 검색어 하이라이팅
- `searchHighlights` Map으로 하이라이트 위치 관리
### 7. 엑셀 내보내기 (Excel Export)
- `xlsx` 라이브러리 사용
- 현재 표시 데이터 또는 전체 데이터 내보내기
```typescript
import * as XLSX from "xlsx";
// 사용 예시
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`);
```
### 8. 클립보드 복사 (Copy to Clipboard)
- 선택된 행 또는 전체 데이터 복사
- 탭 구분자로 엑셀 붙여넣기 호환
### 9. 컨텍스트 메뉴 (Context Menu)
- 우클릭으로 메뉴 표시
- 셀 편집, 행 복사, 행 삭제 등 옵션
- 편집 불가 컬럼은 "(잠김)" 표시
### 10. 키보드 네비게이션
| 키 | 동작 |
|---|---|
| Arrow Keys | 셀 이동 |
| Tab | 다음 셀 |
| Shift+Tab | 이전 셀 |
| F2 | 편집 모드 |
| Enter | 저장 후 아래로 이동 |
| Escape | 편집 취소 |
| Ctrl+C | 복사 |
| Delete | 셀 값 삭제 |
### 11. 컬럼 리사이징
- 컬럼 헤더 경계 드래그로 너비 조절
- `columnWidths` 상태로 관리
- localStorage에 저장
### 12. 컬럼 순서 변경
- 드래그 앤 드롭으로 컬럼 순서 변경
- `columnOrder` 상태로 관리
- localStorage에 저장
### 13. 상태 영속성 (State Persistence)
```typescript
// localStorage 키 패턴
const stateKey = `tableState_${tableName}_${userId}`;
// 저장되는 상태
interface TableState {
columnWidths: Record<string, number>;
columnOrder: string[];
sortBy: string;
sortOrder: "asc" | "desc";
frozenColumns: string[];
columnVisibility: Record<string, boolean>;
}
```
### 14. 그룹화 및 그룹 소계
```typescript
interface GroupedData {
groupKey: string;
groupValues: Record<string, any>;
items: any[];
count: number;
summary?: Record<string, { sum: number; avg: number; count: number }>;
}
```
### 15. 총계 요약 (Total Summary)
- 숫자 컬럼의 합계, 평균, 개수 표시
- 테이블 하단에 요약 행 렌더링
---
## 캐싱 전략
```typescript
// 테이블 컬럼 캐시
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
// API 호출 디바운싱
const debouncedApiCall = <T extends any[], R>(
key: string,
fn: (...args: T) => Promise<R>,
delay: number = 300
) => { ... };
```
---
## 필수 Import
```typescript
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import * as XLSX from "xlsx";
import { toast } from "sonner";
```
---
## 주요 상태 (State)
```typescript
// 데이터 관련
const [tableData, setTableData] = useState<any[]>([]);
const [filteredData, setFilteredData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 편집 관련
const [editingCell, setEditingCell] = useState<{
rowIndex: number;
colIndex: number;
columnName: string;
originalValue: any;
} | null>(null);
const [editingValue, setEditingValue] = useState<string>("");
const [pendingChanges, setPendingChanges] = useState<Map<string, Map<string, any>>>(new Map());
const [validationErrors, setValidationErrors] = useState<Map<string, Map<string, string>>>(new Map());
// 필터 관련
const [headerFilters, setHeaderFilters] = useState<Map<string, Set<string>>>(new Map());
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
const [globalSearchText, setGlobalSearchText] = useState("");
const [searchHighlights, setSearchHighlights] = useState<Map<string, number[]>>(new Map());
// 컬럼 관련
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [columnOrder, setColumnOrder] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({});
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
// 선택 관련
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
// 정렬 관련
const [sortBy, setSortBy] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
// 페이지네이션
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [totalCount, setTotalCount] = useState(0);
```
---
## 편집 불가 컬럼 구현 체크리스트
새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요:
- [ ] `column.editable === false` 체크 추가
- [ ] 편집 불가 시 `toast.warning()` 메시지 표시
- [ ] `return` 또는 `break`로 편집 모드 진입 방지
```typescript
// 표준 편집 불가 체크 패턴
const column = visibleColumns.find((col) => col.columnName === columnName);
if (column?.editable === false) {
toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`);
return;
}
```
---
## 시각적 표시
### 편집 불가 컬럼 표시
```tsx
// 헤더에 잠금 아이콘
{column.editable === false && (
<Lock className="ml-1 h-3 w-3 text-muted-foreground" />
)}
// 셀 배경색
className={cn(
column.editable === false && "bg-gray-50 dark:bg-gray-900/30"
)}
```
---
## 성능 최적화
1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값
2. **useCallback 사용**: 이벤트 핸들러 함수들
3. **디바운싱**: API 호출, 검색, 필터링
4. **캐싱**: 테이블 컬럼 정보, 코드 데이터
---
## 주의사항
1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함
2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인
3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성
4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리)
---
## 관련 파일
- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의
- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널
- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달
- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블
+592
View File
@@ -0,0 +1,592 @@
# 테이블 타입 관리 SQL 작성 가이드
테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다.
## 핵심 원칙
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)`
2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등
3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수
4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns`
---
## 1. 테이블 생성 DDL 템플릿
### 기본 구조
```sql
CREATE TABLE "테이블명" (
-- 시스템 기본 컬럼 (자동 포함)
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),b
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
"컬럼1" varchar(500),
"컬럼2" varchar(500),
"컬럼3" varchar(500)
);
```
### 예시: 고객 테이블 생성
```sql
CREATE TABLE "customer_info" (
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
"customer_name" varchar(500),
"customer_code" varchar(500),
"phone" varchar(500),
"email" varchar(500),
"address" varchar(500),
"status" varchar(500),
"registration_date" varchar(500)
);
```
---
## 2. 메타데이터 테이블 등록
테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다.
### 2.1 table_labels (테이블 메타데이터)
```sql
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = EXCLUDED.table_label,
description = EXCLUDED.description,
updated_date = now();
```
### 2.2 table_type_columns (컬럼 타입 정보)
**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order`
```sql
-- 기본 컬럼 등록 (display_order: -5 ~ -1)
INSERT INTO table_type_columns (
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES
('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
display_order = EXCLUDED.display_order,
updated_date = now();
-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작)
INSERT INTO table_type_columns (
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES
('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()),
('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()),
('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
display_order = EXCLUDED.display_order,
updated_date = now();
```
### 2.3 column_labels (레거시 호환용 - 필수)
```sql
-- 기본 컬럼 등록
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES
('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()),
('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = EXCLUDED.column_label,
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
description = EXCLUDED.description,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
updated_date = now();
-- 사용자 정의 컬럼 등록
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES
('테이블명', '컬럼1', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()),
('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, now(), now())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = EXCLUDED.column_label,
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
description = EXCLUDED.description,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
updated_date = now();
```
---
## 3. Input Type 정의
### 지원되는 Input Type 목록
| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 |
| ---------- | ------------- | ------------ | -------------------- |
| `text` | 텍스트 입력 | VARCHAR(500) | Input |
| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) |
| `date` | 날짜/시간 | VARCHAR(500) | DatePicker |
| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) |
| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) |
| `select` | 선택 목록 | VARCHAR(500) | Select |
| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox |
| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup |
| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea |
| `file` | 파일 업로드 | VARCHAR(500) | FileUpload |
### WebType → InputType 변환 규칙
```
text, textarea, email, tel, url, password → text
number, decimal → number
date, datetime, time → date
select, dropdown → select
checkbox, boolean → checkbox
radio → radio
code → code
entity → entity
file → text
button → text
```
---
## 4. Detail Settings 설정
### 4.1 Code 타입 (공통코드 참조)
```json
{
"codeCategory": "코드_카테고리_ID"
}
```
```sql
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...);
```
### 4.2 Entity 타입 (테이블 참조)
```json
{
"referenceTable": "참조_테이블명",
"referenceColumn": "참조_컬럼명(보통 id)",
"displayColumn": "표시할_컬럼명"
}
```
```sql
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...);
```
### 4.3 Select 타입 (정적 옵션)
```json
{
"options": [
{ "label": "옵션1", "value": "value1" },
{ "label": "옵션2", "value": "value2" }
]
}
```
---
## 5. 전체 예시: 주문 테이블 생성
### Step 1: DDL 실행
```sql
CREATE TABLE "order_info" (
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
"order_no" varchar(500),
"order_date" varchar(500),
"customer_id" varchar(500),
"total_amount" varchar(500),
"status" varchar(500),
"notes" varchar(500)
);
```
### Step 2: table_labels 등록
```sql
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = EXCLUDED.table_label,
description = EXCLUDED.description,
updated_date = now();
```
### Step 3: table_type_columns 등록
```sql
-- 기본 컬럼
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES
('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
-- 사용자 정의 컬럼
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES
('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()),
('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()),
('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()),
('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()),
('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()),
('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now();
```
### Step 4: column_labels 등록 (레거시 호환)
```sql
-- 기본 컬럼
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES
('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()),
('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
-- 사용자 정의 컬럼
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES
('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()),
('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()),
('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()),
('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()),
('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()),
('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now();
```
---
## 6. 컬럼 추가 시
### DDL
```sql
ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500);
```
### 메타데이터 등록
```sql
-- table_type_columns
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
-- column_labels
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
```
---
## 7. 로그 테이블 생성 (선택사항)
변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다.
### 7.1 로그 테이블 DDL 템플릿
```sql
-- 로그 테이블 생성
CREATE TABLE 테이블명_log (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE
original_id VARCHAR(100), -- 원본 테이블 PK 값
changed_column VARCHAR(100), -- 변경된 컬럼명
old_value TEXT, -- 변경 전 값
new_value TEXT, -- 변경 후 값
changed_by VARCHAR(50), -- 변경자 ID
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
ip_address VARCHAR(50), -- 변경 요청 IP
user_agent TEXT, -- User Agent
full_row_before JSONB, -- 변경 전 전체 행
full_row_after JSONB -- 변경 후 전체 행
);
-- 인덱스 생성
CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id);
CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at);
CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type);
-- 코멘트 추가
COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력';
```
### 7.2 트리거 함수 DDL 템플릿
```sql
CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name
FROM information_schema.columns
WHERE table_name = '테이블명'
AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value
USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
INSERT INTO 테이블명_log (
operation_type, original_id, changed_column, old_value, new_value,
changed_by, ip_address, full_row_before, full_row_after
)
VALUES (
'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value,
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
);
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
```
### 7.3 트리거 DDL 템플릿
```sql
CREATE TRIGGER 테이블명_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON 테이블명
FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func();
```
### 7.4 로그 설정 등록
```sql
INSERT INTO table_log_config (
original_table_name, log_table_name, trigger_name,
trigger_function_name, is_active, created_by, created_at
) VALUES (
'테이블명', '테이블명_log', '테이블명_audit_trigger',
'테이블명_log_trigger_func', 'Y', '생성자ID', now()
);
```
### 7.5 table_labels에 use_log_table 플래그 설정
```sql
UPDATE table_labels
SET use_log_table = 'Y', updated_date = now()
WHERE table_name = '테이블명';
```
### 7.6 전체 예시: order_info 로그 테이블 생성
```sql
-- Step 1: 로그 테이블 생성
CREATE TABLE order_info_log (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL,
original_id VARCHAR(100),
changed_column VARCHAR(100),
old_value TEXT,
new_value TEXT,
changed_by VARCHAR(50),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(50),
user_agent TEXT,
full_row_before JSONB,
full_row_after JSONB
);
CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id);
CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at);
CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type);
COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력';
-- Step 2: 트리거 함수 생성
CREATE OR REPLACE FUNCTION order_info_log_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name FROM information_schema.columns
WHERE table_name = 'order_info' AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Step 3: 트리거 생성
CREATE TRIGGER order_info_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON order_info
FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func();
-- Step 4: 로그 설정 등록
INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at)
VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now());
-- Step 5: table_labels 플래그 업데이트
UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info';
```
### 7.7 로그 테이블 삭제
```sql
-- 트리거 삭제
DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명;
-- 트리거 함수 삭제
DROP FUNCTION IF EXISTS 테이블명_log_trigger_func();
-- 로그 테이블 삭제
DROP TABLE IF EXISTS 테이블명_log;
-- 로그 설정 삭제
DELETE FROM table_log_config WHERE original_table_name = '테이블명';
-- table_labels 플래그 업데이트
UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명';
```
---
## 8. 체크리스트
### 테이블 생성/수정 시 반드시 확인할 사항:
- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
- [ ] `table_labels`에 테이블 메타데이터 등록
- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*')
- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환)
- [ ] 기본 컬럼 display_order: -5 ~ -1
- [ ] 사용자 정의 컬럼 display_order: 0부터 순차
- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
### 로그 테이블 생성 시 확인할 사항 (선택):
- [ ] 로그 테이블 생성 (`테이블명_log`)
- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type)
- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`)
- [ ] 트리거 생성 (`테이블명_audit_trigger`)
- [ ] `table_log_config`에 로그 설정 등록
- [ ] `table_labels.use_log_table = 'Y'` 업데이트
---
## 9. 금지 사항
1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수
4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수
5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용
---
## 참조 파일
- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스
- `backend-node/src/types/ddl.ts`: DDL 타입 정의
- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러
+343
View File
@@ -0,0 +1,343 @@
# 고정 헤더 테이블 표준 가이드
## 개요
스크롤 가능한 테이블에서 헤더를 상단에 고정하는 표준 구조입니다.
플로우 위젯의 스텝 데이터 리스트 테이블을 참조 기준으로 합니다.
## 필수 구조
### 1. 기본 HTML 구조
```tsx
<div className="relative overflow-auto" style={{ height: "450px" }}>
<Table noWrapper>
<TableHeader>
<TableRow>
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
헤더 1
</TableHead>
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
헤더 2
</TableHead>
</TableRow>
</TableHeader>
<TableBody>{/* 데이터 행들 */}</TableBody>
</Table>
</div>
```
### 2. 필수 클래스 설명
#### 스크롤 컨테이너 (외부 div)
```tsx
className="relative overflow-auto"
style={{ height: "450px" }}
```
**필수 요소:**
- `relative`: sticky positioning의 기준점
- `overflow-auto`: 스크롤 활성화
- `height`: 고정 높이 (인라인 스타일 또는 Tailwind 클래스)
#### Table 컴포넌트
```tsx
<Table noWrapper>
```
**필수 props:**
- `noWrapper`: Table 컴포넌트의 내부 wrapper 제거 (매우 중요!)
- 이것이 없으면 sticky header가 작동하지 않음
#### TableHead (헤더 셀)
```tsx
className =
"bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
```
**필수 클래스:**
- `bg-background`: 배경색 (스크롤 시 데이터가 보이지 않도록)
- `sticky top-0`: 상단 고정
- `z-10`: 다른 요소 위에 표시
- `border-b`: 하단 테두리
- `shadow-[0_1px_0_0_rgb(0,0,0,0.1)]`: 얇은 그림자 (헤더와 본문 구분)
### 3. 왼쪽 열 고정 (체크박스 등)
첫 번째 열도 고정하려면:
```tsx
<TableHead className="bg-background sticky top-0 left-0 z-20 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
<Checkbox />
</TableHead>
```
**z-index 규칙:**
- 왼쪽+상단 고정: `z-20`
- 상단만 고정: `z-10`
- 왼쪽만 고정: `z-10`
- 일반 셀: z-index 없음
### 4. 완전한 예제 (체크박스 포함)
```tsx
<div className="relative overflow-auto" style={{ height: "450px" }}>
<Table noWrapper>
<TableHeader>
<TableRow>
{/* 왼쪽 고정 체크박스 열 */}
<TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
<Checkbox checked={allSelected} onCheckedChange={handleSelectAll} />
</TableHead>
{/* 일반 헤더 열들 */}
{columns.map((col) => (
<TableHead
key={col}
className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm"
>
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow key={index}>
{/* 왼쪽 고정 체크박스 */}
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
<Checkbox
checked={selectedRows.has(index)}
onCheckedChange={() => toggleRow(index)}
/>
</TableCell>
{/* 데이터 셀들 */}
{columns.map((col) => (
<TableCell key={col} className="border-b px-3 py-2">
{row[col]}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
```
## 반응형 대응
### 모바일: 카드 뷰
```tsx
{
/* 모바일: 카드 뷰 */
}
<div className="overflow-y-auto sm:hidden" style={{ height: "450px" }}>
<div className="space-y-2 p-3">
{data.map((item, index) => (
<div key={index} className="bg-card rounded-md border p-3">
{/* 카드 내용 */}
</div>
))}
</div>
</div>;
{
/* 데스크톱: 테이블 뷰 */
}
<div
className="relative hidden overflow-auto sm:block"
style={{ height: "450px" }}
>
<Table noWrapper>{/* 위의 테이블 구조 */}</Table>
</div>;
```
## 자주하는 실수
### ❌ 잘못된 예시
```tsx
{
/* 1. noWrapper 없음 - sticky 작동 안함 */
}
<Table>
<TableHeader>...</TableHeader>
</Table>;
{
/* 2. 배경색 없음 - 스크롤 시 데이터가 보임 */
}
<TableHead className="sticky top-0">헤더</TableHead>;
{
/* 3. relative 없음 - sticky 기준점 없음 */
}
<div className="overflow-auto">
<Table noWrapper>...</Table>
</div>;
{
/* 4. 고정 높이 없음 - 스크롤 발생 안함 */
}
<div className="relative overflow-auto">
<Table noWrapper>...</Table>
</div>;
```
### ✅ 올바른 예시
```tsx
{
/* 모든 필수 요소 포함 */
}
<div className="relative overflow-auto" style={{ height: "450px" }}>
<Table noWrapper>
<TableHeader>
<TableRow>
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
헤더
</TableHead>
</TableRow>
</TableHeader>
<TableBody>...</TableBody>
</Table>
</div>;
```
## 높이 설정 가이드
### 권장 높이값
- **소형 리스트**: `300px` ~ `400px`
- **중형 리스트**: `450px` ~ `600px` (플로우 위젯 기준)
- **대형 리스트**: `calc(100vh - 200px)` (화면 높이 기준)
### 동적 높이 계산
```tsx
// 화면 높이의 60%
style={{ height: "60vh" }}
// 화면 높이 - 헤더/푸터 제외
style={{ height: "calc(100vh - 250px)" }}
// 부모 요소 기준
className="h-full overflow-auto"
```
## 성능 최적화
### 1. 가상 스크롤 (대량 데이터)
데이터가 1000건 이상인 경우 `react-virtual` 사용 권장:
```tsx
import { useVirtualizer } from "@tanstack/react-virtual";
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 행 높이
});
```
### 2. 페이지네이션
대량 데이터는 페이지 단위로 렌더링:
```tsx
const paginatedData = data.slice((page - 1) * pageSize, page * pageSize);
```
## 접근성
### ARIA 레이블
```tsx
<div
className="relative overflow-auto"
style={{ height: "450px" }}
role="region"
aria-label="스크롤 가능한 데이터 테이블"
tabIndex={0}
>
<Table noWrapper aria-label="데이터 목록">
{/* 테이블 내용 */}
</Table>
</div>
```
### 키보드 네비게이션
```tsx
<TableRow
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleRowClick();
}
}}
>
{/* 행 내용 */}
</TableRow>
```
## 다크 모드 대응
### 배경색
```tsx
{
/* 라이트/다크 모드 모두 대응 */
}
className = "bg-background"; // ✅ 권장
{
/* 고정 색상 - 다크 모드 문제 */
}
className = "bg-white"; // ❌ 비권장
```
### 그림자
```tsx
{
/* 다크 모드에서도 보이는 그림자 */
}
className = "shadow-[0_1px_0_0_hsl(var(--border))]";
{
/* 또는 */
}
className = "shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
```
## 참조 파일
- **구현 예시**: `frontend/components/screen/widgets/FlowWidget.tsx` (line 760-820)
- **Table 컴포넌트**: `frontend/components/ui/table.tsx`
## 체크리스트
테이블 구현 시 다음을 확인하세요:
- [ ] 외부 div에 `relative overflow-auto` 적용
- [ ] 외부 div에 고정 높이 설정
- [ ] `<Table noWrapper>` 사용
- [ ] TableHead에 `bg-background sticky top-0 z-10` 적용
- [ ] TableHead에 `border-b shadow-[...]` 적용
- [ ] 왼쪽 고정 열은 `z-20` 사용
- [ ] 모바일 반응형 대응 (카드 뷰)
- [ ] 다크 모드 호환 색상 사용
+8
View File
@@ -0,0 +1,8 @@
{
"setup-worktree-unix": [
"cd backend-node && npm ci --prefer-offline --no-audit 2>/dev/null || npm install --prefer-offline --no-audit",
"cd frontend && npm ci --prefer-offline --no-audit 2>/dev/null || npm install --prefer-offline --no-audit",
"cp $ROOT_WORKTREE_PATH/backend-node/.env backend-node/.env 2>/dev/null || true",
"cp $ROOT_WORKTREE_PATH/frontend/.env.local frontend/.env.local 2>/dev/null || true"
]
}
+66
View File
@@ -1510,3 +1510,69 @@ const query = `
**company_code = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**
---
## DB 테이블 생성 필수 규칙
**상세 가이드**: [table-type-sql-guide.mdc](.cursor/rules/table-type-sql-guide.mdc)
### 핵심 원칙 (절대 위반 금지)
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`**: NUMERIC, INTEGER, SERIAL, TEXT 등 DB 타입 직접 지정 금지
2. **기본 5개 컬럼 자동 포함** (모든 테이블 필수):
```sql
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500)
```
3. **3개 메타데이터 테이블 등록 필수**:
- `table_labels`: 테이블 라벨/설명
- `table_type_columns`: 컬럼 input_type, detail_settings (company_code = '*')
- `column_labels`: 컬럼 한글 라벨 (레거시 호환)
4. **input_type으로 타입 구분**: text, number, date, code, entity, select, checkbox, radio, textarea
5. **ON CONFLICT 절 필수**: 중복 시 UPDATE 처리
### 금지 사항
- `SERIAL`, `INTEGER`, `NUMERIC`, `BOOLEAN`, `TEXT`, `DATE` 등 DB 타입 직접 사용 금지
- `VARCHAR` 길이 변경 금지 (반드시 500)
- 기본 5개 컬럼 누락 금지
- 메타데이터 테이블 미등록 금지
---
## 화면 개발 방식 필수 규칙 (사용자 메뉴 vs 관리자 메뉴)
**상세 가이드**: [pipeline-common-rules.md](.cursor/agents/pipeline-common-rules.md)
### 핵심 원칙 (절대 위반 금지)
1. **사용자 업무 화면은 React 코드(.tsx)로 직접 만들지 않는다!**
- 포장관리, 금형관리, BOM, 입출고, 품질 등 일반 업무 화면
- DB에 `screen_definitions` + `screen_layouts_v2` + `menu_info` 등록으로 구현
- 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재
- V2 컴포넌트(v2-split-panel-layout, v2-table-list, v2-repeater 등)로 레이아웃 구성
2. **관리자 메뉴만 React 코드로 작성 가능**
- 사용자 관리, 권한 관리, 시스템 설정 등
- `frontend/app/(main)/admin/{기능}/page.tsx`에 작성
- `menu_info` 테이블에 메뉴 등록 필수
### 사용자 메뉴 구현 순서
```
1. DB 테이블 생성 (비즈니스 데이터용)
2. screen_definitions INSERT (screen_code, table_name)
3. screen_layouts_v2 INSERT (V2 레이아웃 JSON)
4. menu_info INSERT (menu_url = '/screen/{screen_code}')
5. 필요하면 백엔드 전용 API 추가 (범용 API로 안 되는 경우만)
```
### 금지 사항
- `frontend/app/(main)/production/*/page.tsx` 같은 사용자 화면 하드코딩 금지
- `frontend/app/(main)/warehouse/*/page.tsx` 같은 사용자 화면 하드코딩 금지
- 사용자 메뉴의 UI를 React 컴포넌트로 직접 구현하는 것 금지
+4
View File
@@ -31,6 +31,10 @@ dist/
build/
build/Release
# Gradle
.gradle/
**/backend/.gradle/
# Cache
.npm
.eslintcache
+2
View File
@@ -947,6 +947,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -2184,6 +2185,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
+33
View File
@@ -0,0 +1,33 @@
=== Step 1: 로그인 (topseal_admin) ===
현재 URL: http://localhost:9771/screens/138
스크린샷: 01-after-login.png
OK: 로그인 완료
=== Step 2: 발주관리 화면 이동 ===
스크린샷: 02-po-screen.png
OK: 발주관리 화면 로드
=== Step 3: 그리드 컬럼 및 데이터 확인 ===
컬럼 헤더 (전체): ["결재상태","발주번호","품목코드","품목명","규격","발주수량","출하수량","단위","구분","유형","재질","규격","품명"]
첫 번째 컬럼: "결재상태"
결재상태(한글) 표시됨
데이터 행 수: 11
데이터 있음
첫 번째 컬럼 값(샘플): ["","","","",""]
발주번호 형식 데이터: ["PO-2026-0001","PO-2026-0001","PO-2026-0001","PO-2026-0045","PO-2026-0045"]
스크린샷: 03-grid-detail.png
OK: 그리드 상세 스크린샷 저장
=== Step 4: 결재 요청 버튼 확인 ===
OK: '결재 요청' 파란색 버튼 확인됨
스크린샷: 04-approval-button.png
=== Step 5: 행 선택 후 결재 요청 ===
OK: 행 선택 완료
스크린샷: 05-approval-modal.png
OK: 결재 모달 열림
스크린샷: 06-approver-search-results.png
결재자 검색 결과: 8명
결재자 목록: ["상신결재","다단 결재순차적으로 결재","동시 결재모든 결재자 동시 진행","TEST(Kim1542)김동현","김혜인(qwer0578)배달집행부 / 차장","김지수(area09)배달집행부 / 대리","김한길(qoznd123)배달집행부 / 과장","김하세(kaoe123)배달집행부 / 사원"]
스크린샷: 07-final.png
+29
View File
@@ -0,0 +1,29 @@
=== Step 1: 로그인 ===
스크린샷: 01-login-page.png
스크린샷: 02-after-login.png
OK: 로그인 완료, 대시보드 로드
=== Step 2: 구매관리 → 발주관리 메뉴 이동 ===
INFO: 메뉴에서 발주관리 미발견, 직접 URL로 이동
메뉴 목록: ["관리자 메뉴로 전환","회사 선택","관리자해외영업부"]
스크린샷: 04-po-screen-loaded.png
OK: /screen/COMPANY_7_064 직접 이동 완료
=== Step 3: 그리드 컬럼 확인 ===
스크린샷: 05-grid-columns.png
컬럼 목록: ["approval_status","발주번호","품목코드","품목명","규격","발주수량","출하","단위","구분","유형","재질","규격","품명"]
FAIL: '결재상태' 컬럼 없음
결재상태 값: 데이터 없음 또는 해당 값 없음
=== Step 4: 행 선택 및 결재 요청 버튼 클릭 ===
스크린샷: 06-row-selected.png
OK: 첫 번째 행 선택
스크린샷: 07-approval-modal-opened.png
OK: 결재 모달 열림
=== Step 5: 결재자 검색 테스트 ===
스크린샷: 08-approver-search-results.png
검색 결과 수: 12명
결재자 목록: ["상신결재","템플릿","다단 결재순차적으로 결재","동시 결재모든 결재자 동시 진행","김동열(drkim)-","김아름(qwe123)생산부 / 차장","TEST(Kim1542)김동현","김혜인(qwer0578)배달집행부 / 차장","김욱동(dnrehd0171)-","김지수(area09)배달집행부 / 대리"]
스크린샷: 09-final-state.png
+2 -12
View File
@@ -1046,7 +1046,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2374,7 +2373,6 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@@ -3487,7 +3485,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3724,7 +3721,6 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -3942,7 +3938,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4468,7 +4463,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -5679,7 +5673,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5958,7 +5951,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -7494,7 +7486,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -8464,6 +8455,7 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@@ -9351,7 +9343,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@@ -10207,6 +10198,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@@ -11014,7 +11006,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -11120,7 +11111,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
File diff suppressed because it is too large Load Diff
@@ -1,342 +0,0 @@
/**
* 기본 템플릿으로 예시 리포트 16건 생성 스크립트
* 실행: cd backend-node && node scripts/create-sample-reports.js
*
* - 한글 카테고리 사용
* - 작성자/작성일 다양하게 구성
* - 8가지 기본 템플릿을 활용하여 각 2건씩 총 16건
*/
const http = require("http");
function apiRequest(method, path, token, body = null) {
return new Promise((resolve, reject) => {
const bodyStr = body ? JSON.stringify(body) : null;
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
if (bodyStr) headers["Content-Length"] = Buffer.byteLength(bodyStr);
const req = http.request(
{ hostname: "localhost", port: 8080, path, method, headers },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); }
});
}
);
req.on("error", reject);
if (bodyStr) req.write(bodyStr);
req.end();
});
}
async function getToken() {
const body = JSON.stringify({ userId: "wace", password: "qlalfqjsgh11" });
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: "localhost", port: 8080, path: "/api/auth/login", method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
},
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
try { resolve(JSON.parse(data).data?.token); } catch (e) { reject(e); }
});
}
);
req.on("error", reject);
req.write(body);
req.end();
});
}
// ─── 작성자 풀 (user_id) ───────────────────────────────────────────────────────
const AUTHORS = ["wace", "drkim", "admin"];
// ─── 작성일 풀 (다양한 날짜) ────────────────────────────────────────────────────
const DATES = [
"2025-11-15", "2025-12-03", "2025-12-22",
"2026-01-08", "2026-01-19", "2026-01-28",
"2026-02-05", "2026-02-14", "2026-02-21",
"2026-02-28", "2026-03-01", "2026-03-03",
"2026-03-05", "2026-03-07", "2026-03-08", "2026-03-10",
];
// ─── 16건 예시 리포트 정의 ──────────────────────────────────────────────────────
const SAMPLE_REPORTS = [
// 견적서 x2
{
reportNameKor: "표준 견적서",
reportNameEng: "Standard Quotation",
reportType: "견적서",
description: "수신자/공급자 정보, 품목 테이블, 공급가액/세액/합계 자동 계산 포함",
templateType: "QUOTATION",
author: AUTHORS[0], date: DATES[15],
},
{
reportNameKor: "해외 견적서 (영문)",
reportNameEng: "Export Quotation",
reportType: "견적서",
description: "해외 거래처용 영문 견적서 양식 (FOB/CIF 조건 포함)",
templateType: "EXPORT_QUOTATION",
author: AUTHORS[1], date: DATES[10],
},
// 발주서 x2
{
reportNameKor: "자재 발주서",
reportNameEng: "Material Purchase Order",
reportType: "발주서",
description: "발주처/자사 정보, 품목 테이블, 발주조건 포함 (4단계 결재)",
templateType: "PURCHASE_ORDER",
author: AUTHORS[0], date: DATES[14],
},
{
reportNameKor: "외주 가공 발주서",
reportNameEng: "Outsourcing Purchase Order",
reportType: "발주서",
description: "외주 협력업체 가공 의뢰용 발주서 양식",
templateType: "PURCHASE_ORDER",
author: AUTHORS[2], date: DATES[7],
},
// 수주 확인서 x2
{
reportNameKor: "수주 확인서",
reportNameEng: "Sales Order Confirmation",
reportType: "수주확인서",
description: "발주처/자사 정보, 품목 테이블, 수주 합계금액, 납품조건 포함",
templateType: "SALES_ORDER",
author: AUTHORS[1], date: DATES[13],
},
{
reportNameKor: "긴급 수주 확인서",
reportNameEng: "Urgent Sales Order Confirmation",
reportType: "수주확인서",
description: "긴급 납기 대응용 수주 확인서 (단납기 조건 포함)",
templateType: "SALES_ORDER",
author: AUTHORS[0], date: DATES[5],
},
// 거래명세서 x2
{
reportNameKor: "거래 명세서",
reportNameEng: "Transaction Statement",
reportType: "거래명세서",
description: "공급자/공급받는자 정보, 품목 테이블, 합계금액, 인수 서명란 포함",
templateType: "DELIVERY_NOTE",
author: AUTHORS[0], date: DATES[12],
},
{
reportNameKor: "월별 거래 명세서",
reportNameEng: "Monthly Transaction Statement",
reportType: "거래명세서",
description: "월간 거래 내역 합산 명세서 양식",
templateType: "DELIVERY_NOTE",
author: AUTHORS[2], date: DATES[3],
},
// 작업지시서 x2
{
reportNameKor: "생산 작업지시서",
reportNameEng: "Production Work Order",
reportType: "작업지시서",
description: "작업지시/제품 정보, 자재 소요 내역, 작업 공정, 바코드/QR 포함",
templateType: "WORK_ORDER",
author: AUTHORS[1], date: DATES[11],
},
{
reportNameKor: "조립 작업지시서",
reportNameEng: "Assembly Work Order",
reportType: "작업지시서",
description: "조립 라인 전용 작업지시서 (공정순서/부품목록 포함)",
templateType: "WORK_ORDER",
author: AUTHORS[0], date: DATES[6],
},
// 검사 성적서 x2
{
reportNameKor: "품질 검사 성적서",
reportNameEng: "Quality Inspection Report",
reportType: "검사성적서",
description: "검사/제품 정보, 검사항목 테이블, 종합 판정(합격/불합격), 서명란 포함",
templateType: "INSPECTION_REPORT",
author: AUTHORS[0], date: DATES[9],
},
{
reportNameKor: "수입검사 성적서",
reportNameEng: "Incoming Inspection Report",
reportType: "검사성적서",
description: "수입 자재/부품 품질 검사 성적서 양식",
templateType: "INSPECTION_REPORT",
author: AUTHORS[2], date: DATES[1],
},
// 세금계산서 x2
{
reportNameKor: "전자 세금계산서",
reportNameEng: "Electronic Tax Invoice",
reportType: "세금계산서",
description: "승인번호/작성일자, 공급자/공급받는자 그리드, 품목 테이블, 결제방법 포함",
templateType: "TAX_INVOICE",
author: AUTHORS[1], date: DATES[8],
},
{
reportNameKor: "수정 세금계산서",
reportNameEng: "Amended Tax Invoice",
reportType: "세금계산서",
description: "기존 발행 세금계산서의 수정 발행용 양식",
templateType: "TAX_INVOICE",
author: AUTHORS[0], date: DATES[2],
},
// 생산계획 현황표 x2
{
reportNameKor: "월간 생산계획 현황표",
reportNameEng: "Monthly Production Plan Status",
reportType: "생산현황",
description: "계획/생산수량 요약 카드, 생산계획 테이블, 상태 범례, 비고 포함",
templateType: "PRODUCTION_PLAN",
author: AUTHORS[0], date: DATES[4],
},
{
reportNameKor: "주간 생산실적 현황표",
reportNameEng: "Weekly Production Performance",
reportType: "생산현황",
description: "주간 단위 생산실적 집계 및 달성률 현황표",
templateType: "PRODUCTION_PLAN",
author: AUTHORS[2], date: DATES[0],
},
];
// ─── 메인 실행 ─────────────────────────────────────────────────────────────────
async function main() {
console.log("로그인 중...");
let token;
try {
token = await getToken();
console.log("로그인 성공\n");
} catch (e) {
console.error("로그인 실패:", e.message);
process.exit(1);
}
// 1. 기존 리포트 모두 삭제
console.log("기존 리포트 삭제 중...");
let allReports = [];
let page = 1;
while (true) {
const resp = await apiRequest("GET", `/api/admin/reports?page=${page}&limit=50`, token);
const items = resp.data?.items || [];
if (items.length === 0) break;
allReports = allReports.concat(items);
if (allReports.length >= (resp.data?.total || 0)) break;
page++;
}
console.log(` ${allReports.length}건 발견`);
for (const rpt of allReports) {
await apiRequest("DELETE", `/api/admin/reports/${rpt.report_id}`, token);
console.log(` 삭제: ${rpt.report_name_kor}`);
}
// 2. 템플릿 매핑
console.log("\n템플릿 조회 중...");
const tplResp = await apiRequest("GET", "/api/admin/reports/templates", token);
const allTpls = [...(tplResp.data?.system || []), ...(tplResp.data?.custom || [])];
const tplMap = {};
for (const t of allTpls) {
if (!tplMap[t.template_type]) tplMap[t.template_type] = t;
}
console.log(` ${allTpls.length}건 발견\n`);
// 3. 16건 리포트 생성
console.log("예시 리포트 16건 생성 시작...");
const createdIds = [];
for (let i = 0; i < SAMPLE_REPORTS.length; i++) {
const s = SAMPLE_REPORTS[i];
const tpl = tplMap[s.templateType];
const reportData = {
reportNameKor: s.reportNameKor,
reportNameEng: s.reportNameEng,
reportType: s.reportType,
description: s.description,
templateId: tpl?.template_id || null,
};
const result = await apiRequest("POST", "/api/admin/reports", token, reportData);
if (!result.success) {
console.error(` [${i + 1}] 실패: ${s.reportNameKor} - ${result.message}`);
continue;
}
const reportId = result.data?.reportId;
createdIds.push({ reportId, author: s.author, date: s.date, name: s.reportNameKor });
// 레이아웃 저장
if (tpl) {
const raw = typeof tpl.layout_config === "string"
? JSON.parse(tpl.layout_config) : tpl.layout_config || {};
const components = raw.components || [];
const ps = raw.pageSettings || {};
await apiRequest("PUT", `/api/admin/reports/${reportId}/layout`, token, {
layoutConfig: {
pages: [{
page_id: `pg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
page_name: "페이지 1", page_order: 0,
width: ps.width || 210, height: ps.height || 297,
orientation: ps.orientation || "portrait",
margins: ps.margins || { top: 10, bottom: 10, left: 10, right: 10 },
background_color: "#ffffff",
components,
}],
},
queries: [], menuObjids: [],
});
}
console.log(` [${i + 1}] ${s.reportNameKor} (${s.reportType}) - ${s.author} / ${s.date}`);
}
// 4. DB 직접 업데이트 (작성자 / 작성일 변경)
console.log("\n작성자/작성일 DB 업데이트 중...");
for (const item of createdIds) {
await apiRequest("PUT", `/api/admin/reports/${item.reportId}`, token, {
// 이 API는 updateReport 이므로 직접 필드 업데이트 가능한지 확인 필요
// 그렇지 않으면 별도 SQL 필요
});
}
// updateReport API로 created_by/created_at 을 변경할 수 없으므로
// 직접 DB 업데이트 스크립트를 별도 실행
console.log("\nDB 업데이트 SQL 생성...");
const sqlStatements = createdIds.map((item) => {
return `UPDATE report_master SET created_by = '${item.author}', created_at = '${item.date} 09:00:00+09' WHERE report_id = '${item.reportId}';`;
});
// DB 직접 접근으로 업데이트
try {
const { Pool } = require("pg");
require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") });
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
for (const item of createdIds) {
await pool.query(
`UPDATE report_master SET created_by = $1, created_at = $2 WHERE report_id = $3`,
[item.author, `${item.date} 09:00:00+09`, item.reportId]
);
}
await pool.end();
console.log(` ${createdIds.length}건 업데이트 완료`);
} catch (e) {
console.warn(" DB 직접 연결 실패, SQL을 수동으로 실행하세요:");
sqlStatements.forEach((sql) => console.log(" " + sql));
}
console.log(`\n완료! ${createdIds.length}건 생성`);
}
main();
@@ -0,0 +1,35 @@
/**
* system_notice 테이블 생성 마이그레이션 실행
*/
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm',
ssl: false,
});
async function run() {
const client = await pool.connect();
try {
const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql');
const sql = fs.readFileSync(sqlPath, 'utf8');
await client.query(sql);
console.log('OK: system_notice 테이블 생성 완료');
// 검증
const result = await client.query(
"SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position"
);
console.log('컬럼:', result.rows.map(r => r.column_name).join(', '));
} catch (e) {
console.error('ERROR:', e.message);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
run();
@@ -0,0 +1,38 @@
/**
* system_notice 마이그레이션 실행 스크립트
* 사용법: node scripts/run-notice-migration.js
*/
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm',
ssl: false,
});
async function run() {
const client = await pool.connect();
try {
const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql');
const sql = fs.readFileSync(sqlPath, 'utf8');
console.log('마이그레이션 실행 중...');
await client.query(sql);
console.log('마이그레이션 완료');
// 컬럼 확인
const check = await client.query(
"SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position"
);
console.log('테이블 컬럼:', check.rows.map(r => r.column_name).join(', '));
} catch (e) {
console.error('오류:', e.message);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
run();
+2
View File
@@ -131,6 +131,7 @@ import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
import packagingRoutes from "./routes/packagingRoutes"; // 포장/적재정보 관리
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
@@ -321,6 +322,7 @@ app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
app.use("/api/packaging", packagingRoutes); // 포장/적재정보 관리
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
@@ -108,6 +108,46 @@ export async function getUserMenus(
}
}
/**
* POP 메뉴 목록 조회
* [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환
*/
export async function getPopMenus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userCompanyCode = req.user?.companyCode || "ILSHIN";
const userType = req.user?.userType;
const result = await AdminService.getPopMenuList({
userCompanyCode,
userType,
});
const response: ApiResponse<any> = {
success: true,
message: "POP 메뉴 목록 조회 성공",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("POP 메뉴 목록 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.",
error: {
code: "POP_MENU_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 메뉴 정보 조회
*/
@@ -1814,7 +1854,7 @@ export async function toggleMenuStatus(
// 현재 상태 및 회사 코드 조회
const currentMenu = await queryOne<any>(
`SELECT objid, status, company_code FROM menu_info WHERE objid = $1`,
`SELECT objid, status, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
[Number(menuId)]
);
@@ -3574,7 +3614,7 @@ export async function getTableSchema(
ic.character_maximum_length,
ic.numeric_precision,
ic.numeric_scale,
COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label,
COALESCE(NULLIF(ttc_company.column_label, ic.column_name), ttc_common.column_label) AS column_label,
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order,
COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable,
COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
// ============================================================
// 대결 위임 설정 (Approval Proxy Settings) CRUD
// ============================================================
export class ApprovalProxyController {
// 대결 위임 목록 조회 (user_info JOIN으로 이름/부서 포함)
static async getProxySettings(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const rows = await query<any>(
`SELECT ps.*,
u1.user_name AS original_user_name, u1.dept_name AS original_dept_name,
u2.user_name AS proxy_user_name, u2.dept_name AS proxy_dept_name
FROM approval_proxy_settings ps
LEFT JOIN user_info u1 ON ps.original_user_id = u1.user_id AND ps.company_code = u1.company_code
LEFT JOIN user_info u2 ON ps.proxy_user_id = u2.user_id AND ps.company_code = u2.company_code
WHERE ps.company_code = $1
ORDER BY ps.created_at DESC`,
[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 : "알 수 없는 오류",
});
}
}
// 대결 위임 생성 (기간 중복 체크 포함)
static async createProxySetting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { original_user_id, proxy_user_id, start_date, end_date, reason, is_active = "Y" } = req.body;
if (!original_user_id || !proxy_user_id) {
return res.status(400).json({ success: false, message: "위임자와 대결자는 필수입니다." });
}
if (!start_date || !end_date) {
return res.status(400).json({ success: false, message: "시작일과 종료일은 필수입니다." });
}
if (original_user_id === proxy_user_id) {
return res.status(400).json({ success: false, message: "위임자와 대결자가 동일할 수 없습니다." });
}
// 같은 기간 중복 체크 (daterange 오버랩)
const overlap = await queryOne<any>(
`SELECT COUNT(*) AS cnt FROM approval_proxy_settings
WHERE original_user_id = $1 AND is_active = 'Y'
AND daterange(start_date, end_date, '[]') && daterange($2::date, $3::date, '[]')
AND company_code = $4`,
[original_user_id, start_date, end_date, companyCode]
);
if (overlap && parseInt(overlap.cnt) > 0) {
return res.status(400).json({ success: false, message: "해당 기간에 이미 대결 설정이 존재합니다." });
}
const [row] = await query<any>(
`INSERT INTO approval_proxy_settings
(original_user_id, proxy_user_id, start_date, end_date, reason, is_active, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[original_user_id, proxy_user_id, start_date, end_date, reason || null, is_active, companyCode]
);
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 updateProxySetting(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 id FROM approval_proxy_settings WHERE id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." });
}
const { proxy_user_id, start_date, end_date, reason, is_active } = req.body;
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (proxy_user_id !== undefined) { fields.push(`proxy_user_id = $${idx++}`); params.push(proxy_user_id); }
if (start_date !== undefined) { fields.push(`start_date = $${idx++}`); params.push(start_date); }
if (end_date !== undefined) { fields.push(`end_date = $${idx++}`); params.push(end_date); }
if (reason !== undefined) { fields.push(`reason = $${idx++}`); params.push(reason); }
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
if (fields.length === 0) {
return res.status(400).json({ success: false, message: "수정할 필드가 없습니다." });
}
fields.push(`updated_at = NOW()`);
params.push(id, companyCode);
const [row] = await query<any>(
`UPDATE approval_proxy_settings SET ${fields.join(", ")}
WHERE 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 deleteProxySetting(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 result = await query<any>(
"DELETE FROM approval_proxy_settings WHERE id = $1 AND company_code = $2 RETURNING id",
[id, companyCode]
);
if (result.length === 0) {
return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." });
}
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 : "알 수 없는 오류",
});
}
}
// 특정 사용자의 현재 활성 대결자 조회
static async checkActiveProxy(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { userId } = req.params;
if (!userId) {
return res.status(400).json({ success: false, message: "userId 파라미터는 필수입니다." });
}
const rows = await query<any>(
`SELECT ps.*, u.user_name AS proxy_user_name
FROM approval_proxy_settings ps
LEFT JOIN user_info u ON ps.proxy_user_id = u.user_id AND ps.company_code = u.company_code
WHERE ps.original_user_id = $1 AND ps.is_active = 'Y'
AND ps.start_date <= CURRENT_DATE AND ps.end_date >= CURRENT_DATE
AND ps.company_code = $2`,
[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 : "알 수 없는 오류",
});
}
}
}
@@ -1,6 +1,6 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { auditLogService } from "../services/auditLogService";
import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService";
import { query } from "../database/db";
import logger from "../utils/logger";
@@ -137,3 +137,40 @@ export const getAuditLogUsers = async (
});
}
};
/**
* 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용)
*/
export const createAuditLog = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body;
if (!action || !resourceType) {
res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." });
return;
}
await auditLogService.log({
companyCode: req.user?.companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: action as AuditAction,
resourceType: resourceType as AuditResourceType,
resourceId: resourceId || undefined,
resourceName: resourceName || undefined,
tableName: tableName || undefined,
summary: summary || undefined,
changes: changes || undefined,
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({ success: true });
} catch (error: any) {
logger.error("감사 로그 기록 실패", { error: error.message });
res.status(500).json({ success: false, message: "감사 로그 기록 실패" });
}
};
+30 -10
View File
@@ -6,6 +6,7 @@ import { AuthService } from "../services/authService";
import { JwtUtils } from "../utils/jwtUtils";
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
import { logger } from "../utils/logger";
import { sendSmartFactoryLog } from "../utils/smartFactoryLog";
export class AuthController {
/**
@@ -50,9 +51,7 @@ export class AuthController {
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
// 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null;
try {
// 메뉴 조회를 위한 공통 파라미터
const { AdminService } = await import("../services/adminService");
const paramMap = {
userId: loginResult.userInfo.userId,
@@ -61,18 +60,15 @@ export class AuthController {
userLang: "ko",
};
// 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null;
try {
const menuList = await AdminService.getUserMenuList(paramMap);
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
// 접근 가능한 첫 번째 메뉴 찾기
// 조건:
// 1. LEV (레벨)이 2 이상 (최상위 폴더 제외)
// 2. MENU_URL이 있고 비어있지 않음
// 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴
const firstMenu = menuList.find((menu: any) => {
const level = menu.lev || menu.level;
const url = menu.menu_url || menu.url;
return level >= 2 && url && url.trim() !== "" && url !== "#";
});
@@ -86,13 +82,37 @@ export class AuthController {
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
}
// 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함)
sendSmartFactoryLog({
userId: userInfo.userId,
remoteAddr,
useType: "접속",
}).catch(() => {});
// POP 랜딩 경로 조회
let popLandingPath: string | null = null;
try {
const popResult = await AdminService.getPopMenuList(paramMap);
if (popResult.landingMenu?.menu_url) {
popLandingPath = popResult.landingMenu.menu_url;
} else if (popResult.childMenus.length === 1) {
popLandingPath = popResult.childMenus[0].menu_url;
} else if (popResult.childMenus.length > 1) {
popLandingPath = "/pop";
}
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);
} catch (popError) {
logger.warn("POP 메뉴 조회 중 오류 (무시):", popError);
}
res.status(200).json({
success: true,
message: "로그인 성공",
data: {
userInfo,
token: loginResult.token,
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
firstMenuPath,
popLandingPath,
},
});
} else {
@@ -6,6 +6,7 @@ import { Router, Request, Response } from "express";
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
import { logger } from "../utils/logger";
import { authenticateToken } from "../middleware/authMiddleware";
import { auditLogService, getClientIp } from "../services/auditLogService";
const router = Router();
@@ -16,6 +17,7 @@ router.use(authenticateToken);
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
userName: string;
companyCode: string;
};
}
@@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
auditLogService.log({
companyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "CREATE",
resourceType: "CODE_CATEGORY",
resourceId: String(value.valueId),
resourceName: input.valueLabel,
tableName: "category_values",
summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`,
changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } },
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
data: value,
@@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
const companyCode = req.user?.companyCode || "*";
const updatedBy = req.user?.userId;
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
if (!value) {
@@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
});
}
auditLogService.log({
companyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "UPDATE",
resourceType: "CODE_CATEGORY",
resourceId: valueId,
resourceName: value.valueLabel,
tableName: "category_values",
summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`,
changes: {
before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined,
after: input,
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
data: value,
@@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
const { valueId } = req.params;
const companyCode = req.user?.companyCode || "*";
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
if (!success) {
@@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
});
}
auditLogService.log({
companyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "DELETE",
resourceType: "CODE_CATEGORY",
resourceId: valueId,
resourceName: beforeValue?.valueLabel || valueId,
tableName: "category_values",
summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`,
changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined,
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
message: "삭제되었습니다",
@@ -396,6 +396,20 @@ export class CommonCodeController {
companyCode
);
auditLogService.log({
companyCode: companyCode || "",
userId: userId || "",
action: "UPDATE",
resourceType: "CODE",
resourceId: codeValue,
resourceName: codeData.codeName || codeValue,
tableName: "code_info",
summary: `코드 "${categoryCode}.${codeValue}" 수정`,
changes: { after: codeData },
ipAddress: getClientIp(req as any),
requestPath: req.originalUrl,
});
return res.json({
success: true,
data: code,
@@ -440,6 +454,19 @@ export class CommonCodeController {
companyCode
);
auditLogService.log({
companyCode: companyCode || "",
userId: req.user?.userId || "",
action: "DELETE",
resourceType: "CODE",
resourceId: codeValue,
tableName: "code_info",
summary: `코드 "${categoryCode}.${codeValue}" 삭제`,
changes: { before: { categoryCode, codeValue } },
ipAddress: getClientIp(req as any),
requestPath: req.originalUrl,
});
return res.json({
success: true,
message: "코드 삭제 성공",
@@ -438,6 +438,19 @@ export class DDLController {
);
if (result.success) {
auditLogService.log({
companyCode: userCompanyCode || "",
userId,
action: "DELETE",
resourceType: "TABLE",
resourceId: tableName,
resourceName: tableName,
tableName,
summary: `테이블 "${tableName}" 삭제`,
ipAddress: getClientIp(req as any),
requestPath: req.originalUrl,
});
res.status(200).json({
success: true,
message: result.message,
@@ -266,7 +266,6 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
logger.info("컬럼 DISTINCT 값 조회 성공", {
tableName,
columnName,
columnInputType: columnInputType || "none",
labelColumn: effectiveLabelColumn,
companyCode,
hasFilters: !!filtersParam,
@@ -193,6 +193,7 @@ router.post(
auditLogService.log({
companyCode,
userId,
userName: req.user?.userName,
action: "CREATE",
resourceType: "NUMBERING_RULE",
resourceId: String(newRule.ruleId),
@@ -243,6 +244,7 @@ router.put(
auditLogService.log({
companyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "UPDATE",
resourceType: "NUMBERING_RULE",
resourceId: ruleId,
@@ -285,6 +287,7 @@ router.delete(
auditLogService.log({
companyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "DELETE",
resourceType: "NUMBERING_RULE",
resourceId: ruleId,
@@ -521,6 +524,56 @@ router.post(
companyCode,
userId
);
const isUpdate = !!ruleConfig.ruleId;
const resetPeriodLabel: Record<string, string> = {
none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별",
};
const partTypeLabel: Record<string, string> = {
sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조",
};
const partsDescription = (ruleConfig.parts || [])
.sort((a: any, b: any) => (a.order || 0) - (b.order || 0))
.map((p: any) => {
const type = partTypeLabel[p.partType] || p.partType;
if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`;
if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`;
if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`;
if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`;
if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`;
return type;
})
.join(` ${ruleConfig.separator || "-"} `);
auditLogService.log({
companyCode,
userId,
userName: req.user?.userName,
action: isUpdate ? "UPDATE" : "CREATE",
resourceType: "NUMBERING_RULE",
resourceId: String(savedRule.ruleId),
resourceName: ruleConfig.ruleName,
tableName: "numbering_rules",
summary: isUpdate
? `채번 규칙 "${ruleConfig.ruleName}" 수정`
: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
changes: {
after: {
규칙명: ruleConfig.ruleName,
적용테이블: ruleConfig.tableName || "(미지정)",
적용컬럼: ruleConfig.columnName || "(미지정)",
구분자: ruleConfig.separator || "-",
리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함",
적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역",
코드구성: partsDescription || "(파트 없음)",
: (ruleConfig.parts || []).length,
},
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
return res.json({ success: true, data: savedRule });
} catch (error: any) {
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
@@ -535,10 +588,25 @@ router.delete(
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { ruleId } = req.params;
try {
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
auditLogService.log({
companyCode,
userId,
userName: req.user?.userName,
action: "DELETE",
resourceType: "NUMBERING_RULE",
resourceId: ruleId,
tableName: "numbering_rules",
summary: `채번 규칙(ID:${ruleId}) 삭제`,
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
return res.json({
success: true,
message: "테스트 채번 규칙이 삭제되었습니다",
@@ -0,0 +1,478 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
import { getPool } from "../database/db";
// ──────────────────────────────────────────────
// 포장단위 (pkg_unit) CRUD
// ──────────────────────────────────────────────
export async function getPkgUnits(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `SELECT * FROM pkg_unit ORDER BY company_code, created_date DESC`;
params = [];
} else {
sql = `SELECT * FROM pkg_unit WHERE company_code = $1 ORDER BY created_date DESC`;
params = [companyCode];
}
const result = await pool.query(sql, params);
logger.info("포장단위 목록 조회", { companyCode, count: result.rowCount });
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("포장단위 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createPkgUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const {
pkg_code, pkg_name, pkg_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, volume_l, remarks,
} = req.body;
if (!pkg_code || !pkg_name) {
res.status(400).json({ success: false, message: "포장코드와 포장명은 필수입니다." });
return;
}
const dup = await pool.query(
`SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`,
[pkg_code, companyCode]
);
if (dup.rowCount && dup.rowCount > 0) {
res.status(409).json({ success: false, message: "이미 존재하는 포장코드입니다." });
return;
}
const result = await pool.query(
`INSERT INTO pkg_unit
(company_code, pkg_code, pkg_name, pkg_type, status,
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *`,
[companyCode, pkg_code, pkg_name, pkg_type, status || "ACTIVE",
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks,
req.user!.userId]
);
logger.info("포장단위 등록", { companyCode, pkg_code });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("포장단위 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function updatePkgUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const {
pkg_name, pkg_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, volume_l, remarks,
} = req.body;
const result = await pool.query(
`UPDATE pkg_unit SET
pkg_name=$1, pkg_type=$2, status=$3,
width_mm=$4, length_mm=$5, height_mm=$6,
self_weight_kg=$7, max_load_kg=$8, volume_l=$9, remarks=$10,
updated_date=NOW(), writer=$11
WHERE id=$12 AND company_code=$13
RETURNING *`,
[pkg_name, pkg_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, volume_l, remarks,
req.user!.userId, id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("포장단위 수정", { companyCode, id });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("포장단위 수정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deletePkgUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await client.query("BEGIN");
await client.query(
`DELETE FROM pkg_unit_item WHERE pkg_code = (SELECT pkg_code FROM pkg_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`,
[id, companyCode]
);
const result = await client.query(
`DELETE FROM pkg_unit WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
await client.query("COMMIT");
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("포장단위 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("포장단위 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// ──────────────────────────────────────────────
// 포장단위 매칭품목 (pkg_unit_item) CRUD
// ──────────────────────────────────────────────
export async function getPkgUnitItems(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { pkgCode } = req.params;
const pool = getPool();
const result = await pool.query(
`SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
[pkgCode, companyCode]
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("매칭품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createPkgUnitItem(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const { pkg_code, item_number, pkg_qty } = req.body;
if (!pkg_code || !item_number) {
res.status(400).json({ success: false, message: "포장코드와 품번은 필수입니다." });
return;
}
const result = await pool.query(
`INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer)
VALUES ($1,$2,$3,$4,$5)
RETURNING *`,
[companyCode, pkg_code, item_number, pkg_qty, req.user!.userId]
);
logger.info("매칭품목 추가", { companyCode, pkg_code, item_number });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("매칭품목 추가 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deletePkgUnitItem(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("매칭품목 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
logger.error("매칭품목 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ──────────────────────────────────────────────
// 적재함 (loading_unit) CRUD
// ──────────────────────────────────────────────
export async function getLoadingUnits(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `SELECT * FROM loading_unit ORDER BY company_code, created_date DESC`;
params = [];
} else {
sql = `SELECT * FROM loading_unit WHERE company_code = $1 ORDER BY created_date DESC`;
params = [companyCode];
}
const result = await pool.query(sql, params);
logger.info("적재함 목록 조회", { companyCode, count: result.rowCount });
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("적재함 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createLoadingUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const {
loading_code, loading_name, loading_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, max_stack, remarks,
} = req.body;
if (!loading_code || !loading_name) {
res.status(400).json({ success: false, message: "적재함코드와 적재함명은 필수입니다." });
return;
}
const dup = await pool.query(
`SELECT id FROM loading_unit WHERE loading_code=$1 AND company_code=$2`,
[loading_code, companyCode]
);
if (dup.rowCount && dup.rowCount > 0) {
res.status(409).json({ success: false, message: "이미 존재하는 적재함코드입니다." });
return;
}
const result = await pool.query(
`INSERT INTO loading_unit
(company_code, loading_code, loading_name, loading_type, status,
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *`,
[companyCode, loading_code, loading_name, loading_type, status || "ACTIVE",
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks,
req.user!.userId]
);
logger.info("적재함 등록", { companyCode, loading_code });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("적재함 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateLoadingUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const {
loading_name, loading_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, max_stack, remarks,
} = req.body;
const result = await pool.query(
`UPDATE loading_unit SET
loading_name=$1, loading_type=$2, status=$3,
width_mm=$4, length_mm=$5, height_mm=$6,
self_weight_kg=$7, max_load_kg=$8, max_stack=$9, remarks=$10,
updated_date=NOW(), writer=$11
WHERE id=$12 AND company_code=$13
RETURNING *`,
[loading_name, loading_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, max_stack, remarks,
req.user!.userId, id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("적재함 수정", { companyCode, id });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("적재함 수정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteLoadingUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await client.query("BEGIN");
await client.query(
`DELETE FROM loading_unit_pkg WHERE loading_code = (SELECT loading_code FROM loading_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`,
[id, companyCode]
);
const result = await client.query(
`DELETE FROM loading_unit WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
await client.query("COMMIT");
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("적재함 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("적재함 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// ──────────────────────────────────────────────
// 적재함 포장구성 (loading_unit_pkg) CRUD
// ──────────────────────────────────────────────
export async function getLoadingUnitPkgs(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { loadingCode } = req.params;
const pool = getPool();
const result = await pool.query(
`SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
[loadingCode, companyCode]
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("적재구성 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createLoadingUnitPkg(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const { loading_code, pkg_code, max_load_qty, load_method } = req.body;
if (!loading_code || !pkg_code) {
res.status(400).json({ success: false, message: "적재함코드와 포장코드는 필수입니다." });
return;
}
const result = await pool.query(
`INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING *`,
[companyCode, loading_code, pkg_code, max_load_qty, load_method, req.user!.userId]
);
logger.info("적재구성 추가", { companyCode, loading_code, pkg_code });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("적재구성 추가 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteLoadingUnitPkg(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("적재구성 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
logger.error("적재구성 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
+302 -182
View File
@@ -1,14 +1,15 @@
import { Response, NextFunction } from "express";
/**
*
*/
import { Request, Response, NextFunction } from "express";
import reportService from "../services/reportService";
import {
CreateReportRequest,
UpdateReportRequest,
SaveLayoutRequest,
CreateTemplateRequest,
GetReportsParams,
} from "../types/report";
import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
import path from "path";
import fs from "fs";
import {
@@ -34,91 +35,92 @@ import {
import { WatermarkConfig } from "../types/report";
import bwipjs from "bwip-js";
function getUserInfo(req: AuthenticatedRequest) {
return {
userId: req.user?.userId || "SYSTEM",
companyCode: req.user?.companyCode || "*",
};
}
export class ReportController {
async getReports(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
*
* GET /api/admin/reports
*/
async getReports(req: Request, res: Response, next: NextFunction) {
try {
const { companyCode } = getUserInfo(req);
const {
page = "1", limit = "20", searchText = "", searchField,
startDate, endDate, reportType = "", useYn = "Y",
sortBy = "created_at", sortOrder = "DESC",
page = "1",
limit = "20",
searchText = "",
reportType = "",
useYn = "Y",
sortBy = "created_at",
sortOrder = "DESC",
} = req.query;
const result = await reportService.getReports({
page: parseInt(page as string, 10),
limit: parseInt(limit as string, 10),
searchText: searchText as string,
searchField: searchField as GetReportsParams["searchField"],
startDate: startDate as string | undefined,
endDate: endDate as string | undefined,
reportType: reportType as string,
useYn: useYn as string,
sortBy: sortBy as string,
sortOrder: sortOrder as "ASC" | "DESC",
}, companyCode);
});
return res.json({ success: true, data: result });
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
}
async getReportById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
*
* GET /api/admin/reports/:reportId
*/
async getReportById(req: Request, res: Response, next: NextFunction) {
try {
const { companyCode } = getUserInfo(req);
const { reportId } = req.params;
const report = await reportService.getReportById(reportId, companyCode);
const report = await reportService.getReportById(reportId);
if (!report) {
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: report });
return res.json({
success: true,
data: report,
});
} catch (error) {
return next(error);
}
}
async getReportsByMenuObjid(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
*
* POST /api/admin/reports
*/
async createReport(req: Request, res: Response, next: NextFunction) {
try {
const { companyCode } = getUserInfo(req);
const { menuObjid } = req.params;
const menuObjidNum = parseInt(menuObjid, 10);
if (isNaN(menuObjidNum)) {
return res.status(400).json({ success: false, message: "menuObjid는 숫자여야 합니다." });
}
const result = await reportService.getReportsByMenuObjid(menuObjidNum, companyCode);
return res.json({ success: true, data: result });
} catch (error) {
return next(error);
}
}
async createReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { userId, companyCode } = getUserInfo(req);
const data: CreateReportRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!data.reportNameKor || !data.reportType) {
return res.status(400).json({ success: false, message: "리포트명과 리포트 타입은 필수입니다." });
return res.status(400).json({
success: false,
message: "리포트명과 리포트 타입은 필수입니다.",
});
}
data.companyCode = companyCode;
const reportId = await reportService.createReport(data, userId);
return res.status(201).json({
success: true,
data: { reportId },
data: {
reportId,
},
message: "리포트가 생성되었습니다.",
});
} catch (error) {
@@ -126,56 +128,83 @@ export class ReportController {
}
}
async updateReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
*
* PUT /api/admin/reports/:reportId
*/
async updateReport(req: Request, res: Response, next: NextFunction) {
try {
const { userId, companyCode } = getUserInfo(req);
const { reportId } = req.params;
const data: UpdateReportRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
const success = await reportService.updateReport(reportId, data, userId, companyCode);
const success = await reportService.updateReport(reportId, data, userId);
if (!success) {
return res.status(400).json({ success: false, message: "수정할 내용이 없습니다." });
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
return res.json({ success: true, message: "리포트가 수정되었습니다." });
return res.json({
success: true,
message: "리포트가 수정되었습니다.",
});
} catch (error) {
return next(error);
}
}
async deleteReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
*
* DELETE /api/admin/reports/:reportId
*/
async deleteReport(req: Request, res: Response, next: NextFunction) {
try {
const { companyCode } = getUserInfo(req);
const { reportId } = req.params;
const success = await reportService.deleteReport(reportId, companyCode);
const success = await reportService.deleteReport(reportId);
if (!success) {
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
}
return res.json({ success: true, message: "리포트가 삭제되었습니다." });
return res.json({
success: true,
message: "리포트가 삭제되었습니다.",
});
} catch (error) {
return next(error);
}
}
async copyReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
*
* POST /api/admin/reports/:reportId/copy
*/
async copyReport(req: Request, res: Response, next: NextFunction) {
try {
const { userId, companyCode } = getUserInfo(req);
const { reportId } = req.params;
const { newName } = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
const newReportId = await reportService.copyReport(reportId, userId, companyCode, newName);
const newReportId = await reportService.copyReport(reportId, userId);
if (!newReportId) {
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
}
return res.status(201).json({
success: true,
data: { reportId: newReportId },
data: {
reportId: newReportId,
},
message: "리포트가 복사되었습니다.",
});
} catch (error) {
@@ -183,92 +212,132 @@ export class ReportController {
}
}
async getLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
*
* GET /api/admin/reports/:reportId/layout
*/
async getLayout(req: Request, res: Response, next: NextFunction) {
try {
const { companyCode } = getUserInfo(req);
const { reportId } = req.params;
const layout = await reportService.getLayout(reportId, companyCode);
const layout = await reportService.getLayout(reportId);
if (!layout) {
return res.status(404).json({ success: false, message: "레이아웃을 찾을 수 없습니다." });
return res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
}
const storedData = layout.components;
let layoutData;
// components 컬럼에서 JSON 파싱
const parsedComponents = layout.components
? JSON.parse(layout.components)
: null;
let layoutData;
// 새 구조 (layoutConfig.pages)인지 확인
if (
storedData &&
typeof storedData === "object" &&
!Array.isArray(storedData) &&
Array.isArray((storedData as Record<string, unknown>).pages)
parsedComponents &&
parsedComponents.pages &&
Array.isArray(parsedComponents.pages)
) {
const parsed = storedData as Record<string, unknown>;
// pages 배열을 직접 포함하여 반환
layoutData = {
...layout,
pages: parsed.pages,
watermark: parsed.watermark,
components: storedData,
pages: parsedComponents.pages,
components: [], // 호환성을 위해 빈 배열
};
} else {
layoutData = { ...layout, components: storedData || [] };
// 기존 구조: components 배열
layoutData = {
...layout,
components: parsedComponents || [],
};
}
return res.json({ success: true, data: layoutData });
return res.json({
success: true,
data: layoutData,
});
} catch (error) {
return next(error);
}
}
async saveLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
*
* PUT /api/admin/reports/:reportId/layout
*/
async saveLayout(req: Request, res: Response, next: NextFunction) {
try {
const { userId, companyCode } = getUserInfo(req);
const { reportId } = req.params;
const data: SaveLayoutRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
if (!data.layoutConfig?.pages?.length) {
return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다." });
// 필수 필드 검증 (페이지 기반 구조)
if (
!data.layoutConfig ||
!data.layoutConfig.pages ||
data.layoutConfig.pages.length === 0
) {
return res.status(400).json({
success: false,
message: "레이아웃 설정이 필요합니다.",
});
}
await reportService.saveLayout(reportId, data, userId, companyCode);
return res.json({ success: true, message: "레이아웃이 저장되었습니다." });
await reportService.saveLayout(reportId, data, userId);
return res.json({
success: true,
message: "레이아웃이 저장되었습니다.",
});
} catch (error) {
return next(error);
}
}
async getTemplates(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
* 릿
* GET /api/admin/reports/templates
*/
async getTemplates(req: Request, res: Response, next: NextFunction) {
try {
const templates = await reportService.getTemplates();
return res.json({ success: true, data: templates });
return res.json({
success: true,
data: templates,
});
} catch (error) {
return next(error);
}
}
async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
* 릿
* POST /api/admin/reports/templates
*/
async createTemplate(req: Request, res: Response, next: NextFunction) {
try {
const categories = await reportService.getCategories();
return res.json({ success: true, data: categories });
} catch (error) {
return next(error);
}
}
async createTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { userId } = getUserInfo(req);
const data: CreateTemplateRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!data.templateNameKor || !data.templateType) {
return res.status(400).json({ success: false, message: "템플릿명과 템플릿 타입은 필수입니다." });
return res.status(400).json({
success: false,
message: "템플릿명과 템플릿 타입은 필수입니다.",
});
}
const templateId = await reportService.createTemplate(data, userId);
return res.status(201).json({
success: true,
data: { templateId },
data: {
templateId,
},
message: "템플릿이 생성되었습니다.",
});
} catch (error) {
@@ -276,23 +345,37 @@ export class ReportController {
}
}
async saveAsTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
* 릿
* POST /api/admin/reports/:reportId/save-as-template
*/
async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
try {
const { userId } = getUserInfo(req);
const { reportId } = req.params;
const { templateNameKor, templateNameEng, description } = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) {
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
return res.status(400).json({
success: false,
message: "템플릿명은 필수입니다.",
});
}
const templateId = await reportService.saveAsTemplate(
reportId, templateNameKor, templateNameEng, description, userId
reportId,
templateNameKor,
templateNameEng,
description,
userId
);
return res.status(201).json({
success: true,
data: { templateId },
data: {
templateId,
},
message: "템플릿이 저장되었습니다.",
});
} catch (error) {
@@ -300,20 +383,39 @@ export class ReportController {
}
}
async createTemplateFromLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
* 릿 ( )
* POST /api/admin/reports/templates/create-from-layout
*/
async createTemplateFromLayout(
req: Request,
res: Response,
next: NextFunction
) {
try {
const { userId } = getUserInfo(req);
const {
templateNameKor, templateNameEng, templateType,
description, layoutConfig, defaultQueries = [],
templateNameKor,
templateNameEng,
templateType,
description,
layoutConfig,
defaultQueries = [],
} = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) {
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
return res.status(400).json({
success: false,
message: "템플릿명은 필수입니다.",
});
}
if (!layoutConfig) {
return res.status(400).json({ success: false, message: "레이아웃 설정은 필수입니다." });
return res.status(400).json({
success: false,
message: "레이아웃 설정은 필수입니다.",
});
}
const templateId = await reportService.createTemplateFromLayout(
@@ -338,47 +440,78 @@ export class ReportController {
}
}
async deleteTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
* 릿
* DELETE /api/admin/reports/templates/:templateId
*/
async deleteTemplate(req: Request, res: Response, next: NextFunction) {
try {
const { templateId } = req.params;
const success = await reportService.deleteTemplate(templateId);
if (!success) {
return res.status(404).json({ success: false, message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다." });
return res.status(404).json({
success: false,
message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
});
}
return res.json({ success: true, message: "템플릿이 삭제되었습니다." });
return res.json({
success: true,
message: "템플릿이 삭제되었습니다.",
});
} catch (error) {
return next(error);
}
}
async executeQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
*
* POST /api/admin/reports/:reportId/queries/:queryId/execute
*/
async executeQuery(req: Request, res: Response, next: NextFunction) {
try {
const { reportId, queryId } = req.params;
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
const result = await reportService.executeQuery(
reportId, queryId, parameters, sqlQuery, externalConnectionId
reportId,
queryId,
parameters,
sqlQuery,
externalConnectionId
);
return res.json({ success: true, data: result });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
return res.status(400).json({ success: false, message });
return res.json({
success: true,
data: result,
});
} catch (error: any) {
return res.status(400).json({
success: false,
message: error.message || "쿼리 실행에 실패했습니다.",
});
}
}
async getExternalConnections(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
* DB ( )
* GET /api/admin/reports/external-connections
*/
async getExternalConnections(
req: Request,
res: Response,
next: NextFunction
) {
try {
const { companyCode } = getUserInfo(req);
const { ExternalDbConnectionService } = await import(
"../services/externalDbConnectionService"
);
const result = await ExternalDbConnectionService.getConnections({
is_active: "Y",
company_code: companyCode,
company_code: req.body.companyCode || "",
});
return res.json(result);
@@ -387,34 +520,52 @@ export class ReportController {
}
}
async uploadImage(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
*
* POST /api/admin/reports/upload-image
*/
async uploadImage(req: Request, res: Response, next: NextFunction) {
try {
if (!req.file) {
return res.status(400).json({ success: false, message: "이미지 파일이 필요합니다." });
return res.status(400).json({
success: false,
message: "이미지 파일이 필요합니다.",
});
}
const { companyCode } = getUserInfo(req);
const companyCode = req.body.companyCode || "SYSTEM";
const file = req.file;
const uploadDir = path.join(process.cwd(), "uploads", `company_${companyCode}`, "reports");
// 파일 저장 경로 생성
const uploadDir = path.join(
process.cwd(),
"uploads",
`company_${companyCode}`,
"reports"
);
// 디렉토리가 없으면 생성
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 고유한 파일명 생성 (타임스탬프 + 원본 파일명)
const timestamp = Date.now();
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
const fileName = `${timestamp}_${safeFileName}`;
const filePath = path.join(uploadDir, fileName);
// 파일 저장
fs.writeFileSync(filePath, file.buffer);
// 웹에서 접근 가능한 URL 반환
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
return res.json({
success: true,
data: {
fileName, fileUrl,
fileName,
fileUrl,
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
@@ -425,7 +576,11 @@ export class ReportController {
}
}
async exportToWord(req: AuthenticatedRequest, res: Response, next: NextFunction) {
/**
* WORD(DOCX)
* POST /api/admin/reports/export-word
*/
async exportToWord(req: Request, res: Response, next: NextFunction) {
try {
const { layoutConfig, queryResults, fileName = "리포트" } = req.body;
@@ -436,15 +591,22 @@ export class ReportController {
});
}
// mm를 twip으로 변환
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
const MM_TO_PX = 4; // 프론트엔드와 동일, 1mm = 56.692913386 twip (docx)
// 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값)
const MM_TO_PX = 4;
// 1mm = 56.692913386 twip (docx 라이브러리 기준)
// px를 twip으로 변환: px -> mm -> twip
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
// 쿼리 결과 맵
const queryResultsMap: Record<
string,
{ fields: string[]; rows: Record<string, unknown>[] }
> = queryResults || {};
// 컴포넌트 값 가져오기
const getComponentValue = (component: any): string => {
if (component.queryId && component.fieldName) {
const queryResult = queryResultsMap[component.queryId];
@@ -459,9 +621,11 @@ export class ReportController {
return component.defaultValue || "";
};
// px → half-point (1px = 0.75pt, px * 1.5)
// px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용)
// px * 0.75 * 2 = px * 1.5
const pxToHalfPt = (px: number) => Math.round(px * 1.5);
// 셀 내용 생성 헬퍼 함수 (가로 배치용)
const createCellContent = (
component: any,
displayValue: string,
@@ -1393,7 +1557,7 @@ export class ReportController {
const base64 = png.toString("base64");
return `data:image/png;base64,${base64}`;
} catch (error) {
logger.error("바코드 생성 오류:", error);
console.error("바코드 생성 오류:", error);
return null;
}
};
@@ -1727,7 +1891,7 @@ export class ReportController {
children.push(paragraph);
lastBottomY = adjustedY + component.height;
} catch (imgError) {
logger.error("이미지 처리 오류:", imgError);
console.error("이미지 처리 오류:", imgError);
}
}
@@ -1841,7 +2005,7 @@ export class ReportController {
});
children.push(paragraph);
} catch (imgError) {
logger.error("서명 이미지 오류:", imgError);
console.error("서명 이미지 오류:", imgError);
textRuns.push(
new TextRun({
text: "_".repeat(20),
@@ -1919,7 +2083,7 @@ export class ReportController {
});
children.push(paragraph);
} catch (imgError) {
logger.error("도장 이미지 오류:", imgError);
console.error("도장 이미지 오류:", imgError);
textRuns.push(
new TextRun({
text: "(인)",
@@ -2722,7 +2886,7 @@ export class ReportController {
})
);
} catch (imgError) {
logger.error("바코드 이미지 오류:", imgError);
console.error("바코드 이미지 오류:", imgError);
// 바코드 이미지 생성 실패 시 텍스트로 대체
const barcodeValue = component.barcodeValue || "BARCODE";
children.push(
@@ -3000,57 +3164,13 @@ export class ReportController {
return res.send(docxBuffer);
} catch (error: any) {
logger.error("WORD 변환 오류:", error);
console.error("WORD 변환 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "WORD 변환에 실패했습니다.",
});
}
}
// ─── 비주얼 쿼리 빌더 API ─────────────────────────────────────────────────────
async getSchemaTables(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tables = await reportService.getSchemaTables();
return res.json({ success: true, data: tables });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "테이블 목록 조회에 실패했습니다.";
logger.error("스키마 테이블 조회 오류:", { error: message });
return res.status(500).json({ success: false, message });
}
}
async getSchemaTableColumns(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { tableName } = req.params;
if (!tableName) {
return res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
}
const columns = await reportService.getSchemaTableColumns(tableName);
return res.json({ success: true, data: columns });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "컬럼 목록 조회에 실패했습니다.";
logger.error("테이블 컬럼 조회 오류:", { error: message });
return res.status(500).json({ success: false, message });
}
}
async previewVisualQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { visualQuery } = req.body;
if (!visualQuery || !visualQuery.tableName) {
return res.status(400).json({ success: false, message: "visualQuery 정보가 필요합니다." });
}
const result = await reportService.executeVisualQuery(visualQuery);
const generatedSql = reportService.buildVisualQuerySql(visualQuery);
return res.json({ success: true, data: { ...result, sql: generatedSql } });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
logger.error("비주얼 쿼리 미리보기 오류:", { error: message });
return res.status(500).json({ success: false, message });
}
}
}
export default new ReportController();
@@ -614,20 +614,6 @@ export const copyScreenWithModals = async (
modalScreens: modalScreens || [],
});
auditLogService.log({
companyCode: targetCompanyCode || companyCode,
userId: userId || "",
userName: (req.user as any)?.userName || "",
action: "COPY",
resourceType: "SCREEN",
resourceId: id,
resourceName: mainScreen?.screenName,
summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`,
changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } },
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
data: result,
@@ -663,20 +649,6 @@ export const copyScreen = async (
}
);
auditLogService.log({
companyCode,
userId: userId || "",
userName: (req.user as any)?.userName || "",
action: "COPY",
resourceType: "SCREEN",
resourceId: String(copiedScreen?.screenId || ""),
resourceName: screenName,
summary: `화면 "${screenName}" 복사 (원본 ID:${id})`,
changes: { after: { sourceScreenId: id, screenName, screenCode } },
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
data: copiedScreen,
@@ -0,0 +1,275 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
import { query } from "../database/db";
/**
* GET /api/system-notices
*
* - (*):
* - 회사: 자신의 company_code
* - is_active
*/
export const getSystemNotices = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { is_active } = req.query;
logger.info("공지사항 목록 조회 요청", { companyCode, is_active });
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 최고 관리자가 아닌 경우 company_code 필터링
if (companyCode !== "*") {
conditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// is_active 필터 (true/false 문자열 처리)
if (is_active !== undefined && is_active !== "") {
const activeValue = is_active === "true" || is_active === "1";
conditions.push(`is_active = $${paramIndex}`);
params.push(activeValue);
paramIndex++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const rows = await query<any>(
`SELECT
id,
company_code,
title,
content,
is_active,
created_by,
created_at,
updated_at
FROM system_notice
${whereClause}
ORDER BY created_at DESC`,
params
);
logger.info("공지사항 목록 조회 성공", {
companyCode,
count: rows.length,
});
res.status(200).json({
success: true,
data: rows,
total: rows.length,
});
} catch (error) {
logger.error("공지사항 목록 조회 실패", { error });
res.status(500).json({
success: false,
message: "공지사항 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* POST /api/system-notices
*
* - company_code는 req.user.companyCode에서 ( )
*/
export const createSystemNotice = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { title, content, is_active = true } = req.body;
logger.info("공지사항 등록 요청", { companyCode, userId, title });
if (!title || !title.trim()) {
res.status(400).json({
success: false,
message: "제목을 입력해주세요.",
});
return;
}
if (!content || !content.trim()) {
res.status(400).json({
success: false,
message: "내용을 입력해주세요.",
});
return;
}
const [created] = await query<any>(
`INSERT INTO system_notice (company_code, title, content, is_active, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[companyCode, title.trim(), content.trim(), is_active, userId]
);
logger.info("공지사항 등록 성공", {
id: created.id,
companyCode,
title: created.title,
});
res.status(201).json({
success: true,
data: created,
message: "공지사항이 등록되었습니다.",
});
} catch (error) {
logger.error("공지사항 등록 실패", { error });
res.status(500).json({
success: false,
message: "공지사항 등록 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* PUT /api/system-notices/:id
*
* - WHERE id=$1 AND company_code=$2
* - company_code id만으로
*/
export const updateSystemNotice = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const { title, content, is_active } = req.body;
logger.info("공지사항 수정 요청", { id, companyCode });
if (!title || !title.trim()) {
res.status(400).json({
success: false,
message: "제목을 입력해주세요.",
});
return;
}
if (!content || !content.trim()) {
res.status(400).json({
success: false,
message: "내용을 입력해주세요.",
});
return;
}
let result: any[];
if (companyCode === "*") {
// 최고 관리자: id만으로 수정
result = await query<any>(
`UPDATE system_notice
SET title = $1, content = $2, is_active = $3, updated_at = NOW()
WHERE id = $4
RETURNING *`,
[title.trim(), content.trim(), is_active ?? true, id]
);
} else {
// 일반 회사: company_code 추가 조건으로 타 회사 데이터 수정 차단
result = await query<any>(
`UPDATE system_notice
SET title = $1, content = $2, is_active = $3, updated_at = NOW()
WHERE id = $4 AND company_code = $5
RETURNING *`,
[title.trim(), content.trim(), is_active ?? true, id, companyCode]
);
}
if (!result || result.length === 0) {
res.status(404).json({
success: false,
message: "공지사항을 찾을 수 없거나 수정 권한이 없습니다.",
});
return;
}
logger.info("공지사항 수정 성공", { id, companyCode });
res.status(200).json({
success: true,
data: result[0],
message: "공지사항이 수정되었습니다.",
});
} catch (error) {
logger.error("공지사항 수정 실패", { error, id: req.params.id });
res.status(500).json({
success: false,
message: "공지사항 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* DELETE /api/system-notices/:id
*
* - WHERE id=$1 AND company_code=$2
* - company_code id만으로
*/
export const deleteSystemNotice = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
logger.info("공지사항 삭제 요청", { id, companyCode });
let result: any[];
if (companyCode === "*") {
// 최고 관리자: id만으로 삭제
result = await query<any>(
`DELETE FROM system_notice WHERE id = $1 RETURNING id`,
[id]
);
} else {
// 일반 회사: company_code 추가 조건으로 타 회사 데이터 삭제 차단
result = await query<any>(
`DELETE FROM system_notice WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
}
if (!result || result.length === 0) {
res.status(404).json({
success: false,
message: "공지사항을 찾을 수 없거나 삭제 권한이 없습니다.",
});
return;
}
logger.info("공지사항 삭제 성공", { id, companyCode });
res.status(200).json({
success: true,
message: "공지사항이 삭제되었습니다.",
});
} catch (error) {
logger.error("공지사항 삭제 실패", { error, id: req.params.id });
res.status(500).json({
success: false,
message: "공지사항 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
@@ -62,24 +62,31 @@ export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Resp
*/
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userCompanyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
const includeInactive = req.query.includeInactive === "true";
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
const filterCompanyCode = req.query.filterCompanyCode as string | undefined;
// 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용
const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode)
? filterCompanyCode
: userCompanyCode;
logger.info("카테고리 값 조회 요청", {
tableName,
columnName,
menuObjid,
companyCode,
companyCode: effectiveCompanyCode,
filterCompanyCode,
});
const values = await tableCategoryValueService.getCategoryValues(
tableName,
columnName,
companyCode,
effectiveCompanyCode,
includeInactive,
menuObjid // ← menuObjid 전달
menuObjid
);
return res.json({
@@ -963,6 +963,15 @@ export async function addTableData(
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
const systemFields = new Set([
"id", "created_date", "updated_date", "writer", "company_code",
"createdDate", "updatedDate", "companyCode",
]);
const auditData: Record<string, any> = {};
for (const [k, v] of Object.entries(data)) {
if (!systemFields.has(k)) auditData[k] = v;
}
auditLogService.log({
companyCode: req.user?.companyCode || "",
userId: req.user?.userId || "",
@@ -973,7 +982,7 @@ export async function addTableData(
resourceName: tableName,
tableName,
summary: `${tableName} 데이터 추가`,
changes: { after: data },
changes: { after: auditData },
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
@@ -1096,10 +1105,14 @@ export async function editTableData(
return;
}
// 변경된 필드만 추출
const systemFieldsForEdit = new Set([
"id", "created_date", "updated_date", "writer", "company_code",
"createdDate", "updatedDate", "companyCode",
]);
const changedBefore: Record<string, any> = {};
const changedAfter: Record<string, any> = {};
for (const key of Object.keys(updatedData)) {
if (systemFieldsForEdit.has(key)) continue;
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
changedBefore[key] = originalData[key];
changedAfter[key] = updatedData[key];
@@ -3105,3 +3118,153 @@ export async function getNumberingColumnsByCompany(
});
}
}
/**
*
* POST /api/table-management/validate-excel
* Body: { tableName, data: Record<string,any>[] }
*/
export async function validateExcelData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, data } = req.body as {
tableName: string;
data: Record<string, any>[];
};
const companyCode = req.user?.companyCode || "*";
if (!tableName || !Array.isArray(data) || data.length === 0) {
res.status(400).json({ success: false, message: "tableName과 data 배열이 필요합니다." });
return;
}
const effectiveCompanyCode =
companyCode === "*" && data[0]?.company_code && data[0].company_code !== "*"
? data[0].company_code
: companyCode;
let constraintCols = await query<{
column_name: string;
column_label: string;
is_nullable: string;
is_unique: string;
}>(
`SELECT column_name,
COALESCE(column_label, column_name) as column_label,
COALESCE(is_nullable, 'Y') as is_nullable,
COALESCE(is_unique, 'N') as is_unique
FROM table_type_columns
WHERE table_name = $1 AND company_code = $2`,
[tableName, effectiveCompanyCode]
);
if (constraintCols.length === 0 && effectiveCompanyCode !== "*") {
constraintCols = await query(
`SELECT column_name,
COALESCE(column_label, column_name) as column_label,
COALESCE(is_nullable, 'Y') as is_nullable,
COALESCE(is_unique, 'N') as is_unique
FROM table_type_columns
WHERE table_name = $1 AND company_code = '*'`,
[tableName]
);
}
const autoGenCols = ["id", "created_date", "updated_date", "writer", "company_code"];
const notNullCols = constraintCols.filter((c) => c.is_nullable === "N" && !autoGenCols.includes(c.column_name));
const uniqueCols = constraintCols.filter((c) => c.is_unique === "Y" && !autoGenCols.includes(c.column_name));
const notNullErrors: { row: number; column: string; label: string }[] = [];
const uniqueInExcelErrors: { rows: number[]; column: string; label: string; value: string }[] = [];
const uniqueInDbErrors: { row: number; column: string; label: string; value: string }[] = [];
// NOT NULL 검증
for (const col of notNullCols) {
for (let i = 0; i < data.length; i++) {
const val = data[i][col.column_name];
if (val === null || val === undefined || String(val).trim() === "") {
notNullErrors.push({ row: i + 1, column: col.column_name, label: col.column_label });
}
}
}
// UNIQUE: 엑셀 내부 중복
for (const col of uniqueCols) {
const seen = new Map<string, number[]>();
for (let i = 0; i < data.length; i++) {
const val = data[i][col.column_name];
if (val === null || val === undefined || String(val).trim() === "") continue;
const key = String(val).trim();
if (!seen.has(key)) seen.set(key, []);
seen.get(key)!.push(i + 1);
}
for (const [value, rows] of seen) {
if (rows.length > 1) {
uniqueInExcelErrors.push({ rows, column: col.column_name, label: col.column_label, value });
}
}
}
// UNIQUE: DB 기존 데이터와 중복
const hasCompanyCode = await query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
for (const col of uniqueCols) {
const values = [...new Set(
data
.map((row) => row[col.column_name])
.filter((v) => v !== null && v !== undefined && String(v).trim() !== "")
.map((v) => String(v).trim())
)];
if (values.length === 0) continue;
let dupQuery: string;
let dupParams: any[];
const targetCompany = data[0]?.company_code || (effectiveCompanyCode !== "*" ? effectiveCompanyCode : null);
if (hasCompanyCode.length > 0 && targetCompany) {
dupQuery = `SELECT "${col.column_name}" FROM "${tableName}" WHERE "${col.column_name}" = ANY($1) AND company_code = $2`;
dupParams = [values, targetCompany];
} else {
dupQuery = `SELECT "${col.column_name}" FROM "${tableName}" WHERE "${col.column_name}" = ANY($1)`;
dupParams = [values];
}
const existingRows = await query<Record<string, any>>(dupQuery, dupParams);
const existingSet = new Set(existingRows.map((r) => String(r[col.column_name]).trim()));
for (let i = 0; i < data.length; i++) {
const val = data[i][col.column_name];
if (val === null || val === undefined || String(val).trim() === "") continue;
if (existingSet.has(String(val).trim())) {
uniqueInDbErrors.push({ row: i + 1, column: col.column_name, label: col.column_label, value: String(val) });
}
}
}
const isValid = notNullErrors.length === 0 && uniqueInExcelErrors.length === 0 && uniqueInDbErrors.length === 0;
res.json({
success: true,
data: {
isValid,
notNullErrors,
uniqueInExcelErrors,
uniqueInDbErrors,
summary: {
notNull: notNullErrors.length,
uniqueInExcel: uniqueInExcelErrors.length,
uniqueInDb: uniqueInDbErrors.length,
},
},
});
} catch (error: any) {
logger.error("엑셀 데이터 검증 오류:", error);
res.status(500).json({ success: false, message: "데이터 검증 중 오류가 발생했습니다." });
}
}
+2
View File
@@ -2,6 +2,7 @@ import { Router } from "express";
import {
getAdminMenus,
getUserMenus,
getPopMenus,
getMenuInfo,
saveMenu, // 메뉴 추가
updateMenu, // 메뉴 수정
@@ -40,6 +41,7 @@ router.use(authenticateToken);
// 메뉴 관련 API
router.get("/menus", getAdminMenus);
router.get("/user-menus", getUserMenus);
router.get("/pop-menus", getPopMenus);
router.get("/menus/:menuId", getMenuInfo);
router.post("/menus", saveMenu); // 메뉴 추가
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
@@ -5,6 +5,7 @@ import {
ApprovalRequestController,
ApprovalLineController,
} from "../controllers/approvalController";
import { ApprovalProxyController } from "../controllers/approvalProxyController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
@@ -30,9 +31,17 @@ router.get("/requests", ApprovalRequestController.getRequests);
router.get("/requests/:id", ApprovalRequestController.getRequest);
router.post("/requests", ApprovalRequestController.createRequest);
router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest);
router.post("/requests/:id/post-approve", ApprovalRequestController.postApprove);
// ==================== 결재 라인 처리 (Lines) ====================
router.get("/my-pending", ApprovalLineController.getMyPendingLines);
router.post("/lines/:lineId/process", ApprovalLineController.processApproval);
// ==================== 대결 위임 설정 (Proxy Settings) ====================
router.get("/proxy-settings", ApprovalProxyController.getProxySettings);
router.post("/proxy-settings", ApprovalProxyController.createProxySetting);
router.put("/proxy-settings/:id", ApprovalProxyController.updateProxySetting);
router.delete("/proxy-settings/:id", ApprovalProxyController.deleteProxySetting);
router.get("/proxy-settings/check/:userId", ApprovalProxyController.checkActiveProxy);
export default router;
+2 -1
View File
@@ -1,11 +1,12 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController";
import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController";
const router = Router();
router.get("/", authenticateToken, getAuditLogs);
router.get("/stats", authenticateToken, getAuditLogStats);
router.get("/users", authenticateToken, getAuditLogUsers);
router.post("/", authenticateToken, createAuditLog);
export default router;
+66 -2
View File
@@ -8,6 +8,7 @@ import { logger } from "../../utils/logger";
import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService";
import { AuthenticatedRequest } from "../../types/auth";
import { authenticateToken } from "../../middleware/authMiddleware";
import { auditLogService, getClientIp } from "../../services/auditLogService";
const router = Router();
@@ -124,6 +125,21 @@ router.post("/", async (req: AuthenticatedRequest, res: Response) => {
`플로우 저장 성공: ${result.flowId} (회사: ${userCompanyCode})`
);
auditLogService.log({
companyCode: userCompanyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "CREATE",
resourceType: "NODE_FLOW",
resourceId: String(result.flowId),
resourceName: flowName,
tableName: "node_flows",
summary: `노드 플로우 "${flowName}" 생성`,
changes: { after: { flowName, flowDescription } },
ipAddress: getClientIp(req as any),
requestPath: req.originalUrl,
});
return res.json({
success: true,
message: "플로우가 저장되었습니다.",
@@ -143,7 +159,7 @@ router.post("/", async (req: AuthenticatedRequest, res: Response) => {
/**
*
*/
router.put("/", async (req: Request, res: Response) => {
router.put("/", async (req: AuthenticatedRequest, res: Response) => {
try {
const { flowId, flowName, flowDescription, flowData } = req.body;
@@ -154,6 +170,11 @@ router.put("/", async (req: Request, res: Response) => {
});
}
const oldFlow = await queryOne(
`SELECT flow_name, flow_description FROM node_flows WHERE flow_id = $1`,
[flowId]
);
await query(
`
UPDATE node_flows
@@ -168,6 +189,25 @@ router.put("/", async (req: Request, res: Response) => {
logger.info(`플로우 수정 성공: ${flowId}`);
const userCompanyCode = req.user?.companyCode || "*";
auditLogService.log({
companyCode: userCompanyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "UPDATE",
resourceType: "NODE_FLOW",
resourceId: String(flowId),
resourceName: flowName,
tableName: "node_flows",
summary: `노드 플로우 "${flowName}" 수정`,
changes: {
before: oldFlow ? { flowName: (oldFlow as any).flow_name, flowDescription: (oldFlow as any).flow_description } : undefined,
after: { flowName, flowDescription },
},
ipAddress: getClientIp(req as any),
requestPath: req.originalUrl,
});
return res.json({
success: true,
message: "플로우가 수정되었습니다.",
@@ -187,10 +227,15 @@ router.put("/", async (req: Request, res: Response) => {
/**
*
*/
router.delete("/:flowId", async (req: Request, res: Response) => {
router.delete("/:flowId", async (req: AuthenticatedRequest, res: Response) => {
try {
const { flowId } = req.params;
const oldFlow = await queryOne(
`SELECT flow_name, flow_description, company_code FROM node_flows WHERE flow_id = $1`,
[flowId]
);
await query(
`
DELETE FROM node_flows
@@ -201,6 +246,25 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
logger.info(`플로우 삭제 성공: ${flowId}`);
const userCompanyCode = req.user?.companyCode || "*";
const flowName = (oldFlow as any)?.flow_name || `ID:${flowId}`;
auditLogService.log({
companyCode: userCompanyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "DELETE",
resourceType: "NODE_FLOW",
resourceId: String(flowId),
resourceName: flowName,
tableName: "node_flows",
summary: `노드 플로우 "${flowName}" 삭제`,
changes: {
before: oldFlow ? { flowName: (oldFlow as any).flow_name, flowDescription: (oldFlow as any).flow_description } : undefined,
},
ipAddress: getClientIp(req as any),
requestPath: req.originalUrl,
});
return res.json({
success: true,
message: "플로우가 삭제되었습니다.",
@@ -0,0 +1,36 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
} from "../controllers/packagingController";
const router = Router();
router.use(authenticateToken);
// 포장단위
router.get("/pkg-units", getPkgUnits);
router.post("/pkg-units", createPkgUnit);
router.put("/pkg-units/:id", updatePkgUnit);
router.delete("/pkg-units/:id", deletePkgUnit);
// 포장단위 매칭품목
router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems);
router.post("/pkg-unit-items", createPkgUnitItem);
router.delete("/pkg-unit-items/:id", deletePkgUnitItem);
// 적재함
router.get("/loading-units", getLoadingUnits);
router.post("/loading-units", createLoadingUnit);
router.put("/loading-units/:id", updateLoadingUnit);
router.delete("/loading-units/:id", deleteLoadingUnit);
// 적재함 포장구성
router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
export default router;
+123 -26
View File
@@ -17,6 +17,7 @@ interface AutoGenMappingInfo {
numberingRuleId: string;
targetColumn: string;
showResultModal?: boolean;
shareAcrossItems?: boolean;
}
interface HiddenMappingInfo {
@@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
const sharedCodes: Record<string, string> = {};
for (const ag of allAutoGen) {
if (!ag.shareAcrossItems) continue;
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
try {
const code = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
);
sharedCodes[ag.targetColumn] = code;
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 일괄 채번 완료", {
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
});
} catch (err: any) {
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
@@ -225,14 +251,15 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(value);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
columns.push(`"${ag.targetColumn}"`);
values.push(sharedCodes[ag.targetColumn]);
} else if (!ag.shareAcrossItems) {
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
@@ -244,6 +271,20 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
}
if (!columns.includes('"created_date"')) {
columns.push('"created_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"updated_date"')) {
columns.push('"updated_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"writer"') && userId) {
columns.push('"writer"');
values.push(userId);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
@@ -292,8 +333,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
for (let i = 0; i < lookupValues.length; i++) {
const item = items[i] ?? {};
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolved, companyCode, lookupValues[i]],
);
processedCount++;
@@ -311,9 +353,10 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
const autoUpdatedDb = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
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})`,
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql}${autoUpdatedDb} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
[thenVal, elseVal, companyCode, ...lookupValues],
);
processedCount += lookupValues.length;
@@ -325,7 +368,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
if (valSource === "linked") {
value = item[task.sourceField ?? ""] ?? null;
} else {
value = task.fixedValue ?? "";
const raw = task.fixedValue ?? "";
if (raw === "__CURRENT_USER__") {
value = userId;
} else if (raw === "__CURRENT_TIME__") {
value = new Date().toISOString();
} else {
value = raw;
}
}
let setSql: string;
@@ -341,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
setSql = `"${task.targetColumn}" = $1`;
}
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`,
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[value, companyCode, lookupValues[i]],
);
processedCount++;
@@ -448,6 +499,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
const sharedCodes: Record<string, string> = {};
for (const ag of allAutoGen) {
if (!ag.shareAcrossItems) continue;
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
try {
const code = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
);
sharedCodes[ag.targetColumn] = code;
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 일괄 채번 완료", {
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
});
} catch (err: any) {
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
@@ -467,7 +543,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
}
}
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
const allHidden = [
...(fieldMapping?.hiddenMappings ?? []),
...(cardMapping?.hiddenMappings ?? []),
@@ -494,36 +569,43 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(value);
}
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
columns.push(`"${ag.targetColumn}"`);
values.push(sharedCodes[ag.targetColumn]);
} else if (!ag.shareAcrossItems) {
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId,
companyCode,
{ ...fieldValues, ...item },
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 채번 완료", {
ruleId: ag.numberingRuleId,
targetColumn: ag.targetColumn,
generatedCode,
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode,
});
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", {
ruleId: ag.numberingRuleId,
error: err.message,
});
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
}
if (!columns.includes('"created_date"')) {
columns.push('"created_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"updated_date"')) {
columns.push('"updated_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"writer"') && userId) {
columns.push('"writer"');
values.push(userId);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
@@ -558,6 +640,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(fieldValues[sourceField] ?? null);
}
if (!columns.includes('"created_date"')) {
columns.push('"created_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"updated_date"')) {
columns.push('"updated_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"writer"') && userId) {
columns.push('"writer"');
values.push(userId);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
@@ -609,16 +704,18 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
}
if (valueType === "fixed") {
const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd} WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
processedCount += lookupValues.length;
} else {
for (let i = 0; i < lookupValues.length; i++) {
const item = items[i] ?? {};
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd2} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolvedValue, companyCode, lookupValues[i]]
);
processedCount++;
-21
View File
@@ -43,11 +43,6 @@ router.get("/templates", (req, res, next) =>
router.post("/templates", (req, res, next) =>
reportController.createTemplate(req, res, next)
);
// 카테고리(report_type) 목록 조회
router.get("/categories", (req, res, next) =>
reportController.getCategories(req, res, next)
);
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
router.post("/templates/create-from-layout", (req, res, next) =>
reportController.createTemplateFromLayout(req, res, next)
@@ -66,17 +61,6 @@ router.post("/export-word", (req, res, next) =>
reportController.exportToWord(req, res, next)
);
// 비주얼 쿼리 빌더 — 스키마 조회 (/:reportId 패턴보다 반드시 먼저 등록)
router.get("/schema/tables", (req, res, next) =>
reportController.getSchemaTables(req, res, next)
);
router.get("/schema/tables/:tableName/columns", (req, res, next) =>
reportController.getSchemaTableColumns(req, res, next)
);
router.post("/schema/preview", (req, res, next) =>
reportController.previewVisualQuery(req, res, next)
);
// 리포트 목록
router.get("/", (req, res, next) =>
reportController.getReports(req, res, next)
@@ -87,11 +71,6 @@ router.post("/", (req, res, next) =>
reportController.createReport(req, res, next)
);
// 메뉴별 리포트 목록 (/:reportId 보다 반드시 먼저 등록)
router.get("/by-menu/:menuObjid", (req, res, next) =>
reportController.getReportsByMenuObjid(req, res, next)
);
// 리포트 복사 (구체적인 경로를 먼저 배치)
router.post("/:reportId/copy", (req, res, next) =>
reportController.copyReport(req, res, next)
@@ -0,0 +1,27 @@
import { Router } from "express";
import {
getSystemNotices,
createSystemNotice,
updateSystemNotice,
deleteSystemNotice,
} from "../controllers/systemNoticeController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 공지사항 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 공지사항 목록 조회 (is_active 필터 쿼리 파라미터 지원)
router.get("/", getSystemNotices);
// 공지사항 등록
router.post("/", createSystemNotice);
// 공지사항 수정
router.put("/:id", updateSystemNotice);
// 공지사항 삭제
router.delete("/:id", deleteSystemNotice);
export default router;
@@ -27,6 +27,7 @@ import {
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장
validateExcelData, // 엑셀 업로드 전 데이터 검증
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
getTableConstraints, // 🆕 PK/인덱스 상태 조회
@@ -280,4 +281,9 @@ router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
*/
router.post("/multi-table-save", multiTableSave);
/**
*
*/
router.post("/validate-excel", validateExcelData);
export default router;
+68
View File
@@ -621,6 +621,74 @@ export class AdminService {
}
}
/**
* POP
* menu_name_kor에 'POP' menu_desc에 [POP] L1 active
* [POP_LANDING] landingMenu로
*/
static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> {
try {
const { userCompanyCode, userType } = paramMap;
logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType });
let queryParams: any[] = [];
let paramIndex = 1;
let companyFilter = "";
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
companyFilter = `AND COMPANY_CODE = '*'`;
} else {
companyFilter = `AND COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
}
// POP L1 메뉴 조회
const parentMenus = await query<any>(
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
FROM MENU_INFO
WHERE PARENT_OBJ_ID = 0
AND MENU_TYPE = 1
AND (
MENU_DESC LIKE '%[POP]%'
OR UPPER(MENU_NAME_KOR) LIKE '%POP%'
)
${companyFilter}
ORDER BY SEQ
LIMIT 1`,
queryParams
);
if (parentMenus.length === 0) {
logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)");
return { parentMenu: null, childMenus: [], landingMenu: null };
}
const parentMenu = parentMenus[0];
// 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링)
const childMenus = await query<any>(
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
FROM MENU_INFO
WHERE PARENT_OBJ_ID = $1
AND STATUS = 'active'
AND COMPANY_CODE = $2
ORDER BY SEQ`,
[parentMenu.objid, parentMenu.company_code]
);
// [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정
const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null;
logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`);
return { parentMenu, childMenus, landingMenu };
} catch (error) {
logger.error("AdminService.getPopMenuList 오류:", error);
throw error;
}
}
/**
*
*/
+22 -15
View File
@@ -41,7 +41,8 @@ export type AuditResourceType =
| "DATA"
| "TABLE"
| "NUMBERING_RULE"
| "BATCH";
| "BATCH"
| "NODE_FLOW";
export interface AuditLogParams {
companyCode: string;
@@ -65,6 +66,7 @@ export interface AuditLogParams {
export interface AuditLogEntry {
id: number;
company_code: string;
company_name: string | null;
user_id: string;
user_name: string | null;
action: string;
@@ -106,6 +108,7 @@ class AuditLogService {
*/
async log(params: AuditLogParams): Promise<void> {
try {
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
await query(
`INSERT INTO system_audit_log
(company_code, user_id, user_name, action, resource_type,
@@ -127,8 +130,9 @@ class AuditLogService {
params.requestPath || null,
]
);
} catch (error) {
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
} catch (error: any) {
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params });
}
}
@@ -185,40 +189,40 @@ class AuditLogService {
let paramIndex = 1;
if (!isSuperAdmin && filters.companyCode) {
conditions.push(`company_code = $${paramIndex++}`);
conditions.push(`sal.company_code = $${paramIndex++}`);
params.push(filters.companyCode);
} else if (isSuperAdmin && filters.companyCode) {
conditions.push(`company_code = $${paramIndex++}`);
conditions.push(`sal.company_code = $${paramIndex++}`);
params.push(filters.companyCode);
}
if (filters.userId) {
conditions.push(`user_id = $${paramIndex++}`);
conditions.push(`sal.user_id = $${paramIndex++}`);
params.push(filters.userId);
}
if (filters.resourceType) {
conditions.push(`resource_type = $${paramIndex++}`);
conditions.push(`sal.resource_type = $${paramIndex++}`);
params.push(filters.resourceType);
}
if (filters.action) {
conditions.push(`action = $${paramIndex++}`);
conditions.push(`sal.action = $${paramIndex++}`);
params.push(filters.action);
}
if (filters.tableName) {
conditions.push(`table_name = $${paramIndex++}`);
conditions.push(`sal.table_name = $${paramIndex++}`);
params.push(filters.tableName);
}
if (filters.dateFrom) {
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
params.push(filters.dateFrom);
}
if (filters.dateTo) {
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
params.push(filters.dateTo);
}
if (filters.search) {
conditions.push(
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
`(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})`
);
params.push(`%${filters.search}%`);
paramIndex++;
@@ -232,14 +236,17 @@ class AuditLogService {
const offset = (page - 1) * limit;
const countResult = await query<{ count: string }>(
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
`SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`,
params
);
const total = parseInt(countResult[0].count, 10);
const data = await query<AuditLogEntry>(
`SELECT * FROM system_audit_log ${whereClause}
ORDER BY created_at DESC
`SELECT sal.*, ci.company_name
FROM system_audit_log sal
LEFT JOIN company_mng ci ON sal.company_code = ci.company_code
${whereClause}
ORDER BY sal.created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
[...params, limit, offset]
);
@@ -1715,8 +1715,8 @@ export class DynamicFormService {
`SELECT component_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = $2`,
[screenId, "component"]
AND component_type IN ('component', 'v2-button-primary')`,
[screenId]
);
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
@@ -1747,8 +1747,12 @@ export class DynamicFormService {
(triggerType === "delete" && buttonActionType === "delete") ||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
const isButtonComponent =
properties?.componentType === "button-primary" ||
properties?.componentType === "v2-button-primary";
if (
properties?.componentType === "button-primary" &&
isButtonComponent &&
isMatchingAction &&
properties?.webTypeConfig?.enableDataflowControl === true
) {
@@ -1877,7 +1881,7 @@ export class DynamicFormService {
{
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
buttonId: `${triggerType}-button`,
screenId: screenId,
userId: userId,
companyCode: companyCode,
@@ -972,7 +972,7 @@ class MultiTableExcelService {
c.column_name,
c.is_nullable AS db_is_nullable,
c.column_default,
COALESCE(ttc.column_label, cl.column_label) AS column_label,
COALESCE(NULLIF(ttc.column_label, c.column_name), cl.column_label) AS column_label,
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table,
COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable
FROM information_schema.columns c
File diff suppressed because it is too large Load Diff
@@ -2346,19 +2346,24 @@ export class ScreenManagementService {
}
/**
* ( Raw Query )
*
* company_code 매칭: 본인 + SUPER_ADMIN ('*')
* ,
*/
async getScreensByMenu(
menuObjid: number,
companyCode: string,
): Promise<ScreenDefinition[]> {
const screens = await query<any>(
`SELECT sd.* FROM screen_menu_assignments sma
`SELECT sd.*
FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = $1
AND sma.company_code = $2
AND (sma.company_code = $2 OR sma.company_code = '*')
AND sma.is_active = 'Y'
ORDER BY sma.display_order ASC`,
ORDER BY
CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END,
sma.display_order ASC`,
[menuObjid, companyCode],
);
@@ -217,12 +217,12 @@ class TableCategoryValueService {
AND column_name = $2
`;
// category_values 테이블 사용 (menu_objid 없음)
// company_code 기반 필터링
if (companyCode === "*") {
// 최고 관리자: 모든 값 조회
query = baseSelect;
// 최고 관리자: 공통(*) 카테고리만 조회 (모든 회사 카테고리 혼합 방지)
query = baseSelect + ` AND company_code = '*'`;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 조회 (category_values)");
logger.info("최고 관리자: 공통 카테고리 조회 (category_values)");
} else {
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
@@ -190,7 +190,7 @@ export class TableManagementService {
? await query<any>(
`SELECT
c.column_name as "columnName",
COALESCE(ttc.column_label, cl.column_label, c.column_name) as "displayName",
COALESCE(NULLIF(ttc.column_label, c.column_name), cl.column_label, c.column_name) as "displayName",
c.data_type as "dataType",
c.data_type as "dbType",
COALESCE(ttc.input_type, cl.input_type, 'text') as "webType",
@@ -3367,22 +3367,26 @@ export class TableManagementService {
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
);
break;
case "in":
if (Array.isArray(value) && value.length > 0) {
const values = value
case "in": {
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (inArr.length > 0) {
const values = inArr
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
.join(", ");
filterConditions.push(`${safeColumn} IN (${values})`);
}
break;
case "not_in":
if (Array.isArray(value) && value.length > 0) {
const values = value
}
case "not_in": {
const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (notInArr.length > 0) {
const values = notInArr
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
.join(", ");
filterConditions.push(`${safeColumn} NOT IN (${values})`);
}
break;
}
case "contains":
filterConditions.push(
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
@@ -4500,26 +4504,30 @@ export class TableManagementService {
const rawColumns = await query<any>(
`SELECT
column_name as "columnName",
column_name as "displayName",
data_type as "dataType",
udt_name as "dbType",
is_nullable as "isNullable",
column_default as "defaultValue",
character_maximum_length as "maxLength",
numeric_precision as "numericPrecision",
numeric_scale as "numericScale",
c.column_name as "columnName",
c.column_name as "displayName",
c.data_type as "dataType",
c.udt_name as "dbType",
c.is_nullable as "isNullable",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
c.numeric_precision as "numericPrecision",
c.numeric_scale as "numericScale",
CASE
WHEN column_name IN (
SELECT column_name FROM information_schema.key_column_usage
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
WHEN c.column_name IN (
SELECT kcu.column_name FROM information_schema.key_column_usage kcu
WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey'
) THEN true
ELSE false
END as "isPrimaryKey"
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
END as "isPrimaryKey",
col_description(
(SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')),
c.ordinal_position
) as "columnComment"
FROM information_schema.columns c
WHERE c.table_name = $1
AND c.table_schema = 'public'
ORDER BY c.ordinal_position`,
[tableName]
);
@@ -4529,10 +4537,10 @@ export class TableManagementService {
displayName: col.displayName,
dataType: col.dataType,
dbType: col.dbType,
webType: "text", // 기본값
webType: "text",
inputType: "direct",
detailSettings: "{}",
description: "", // 필수 필드 추가
description: col.columnComment || "",
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
@@ -4543,6 +4551,7 @@ export class TableManagementService {
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
displayOrder: 0,
isVisible: true,
columnComment: col.columnComment || "",
}));
logger.info(
+56 -132
View File
@@ -1,3 +1,8 @@
/**
*
*/
// 리포트 템플릿
export interface ReportTemplate {
template_id: string;
template_name_kor: string;
@@ -16,12 +21,12 @@ export interface ReportTemplate {
updated_by: string | null;
}
// 리포트 마스터
export interface ReportMaster {
report_id: string;
report_name_kor: string;
report_name_eng: string | null;
template_id: string | null;
template_name: string | null;
report_type: string;
company_code: string | null;
description: string | null;
@@ -32,6 +37,7 @@ export interface ReportMaster {
updated_by: string | null;
}
// 리포트 레이아웃
export interface ReportLayout {
layout_id: string;
report_id: string;
@@ -49,6 +55,7 @@ export interface ReportLayout {
updated_by: string | null;
}
// 리포트 쿼리
export interface ReportQuery {
query_id: string;
report_id: string;
@@ -56,7 +63,7 @@ export interface ReportQuery {
query_type: "MASTER" | "DETAIL";
sql_query: string;
parameters: string[] | null;
external_connection_id: number | null;
external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB)
display_order: number;
created_at: Date;
created_by: string | null;
@@ -64,37 +71,34 @@ export interface ReportQuery {
updated_by: string | null;
}
// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴)
export interface ReportDetail {
report: ReportMaster;
layout: ReportLayout | null;
queries: ReportQuery[];
menuObjids?: number[];
menuObjids?: number[]; // 연결된 메뉴 ID 목록
}
// 리포트 목록 조회 파라미터
export interface GetReportsParams {
page?: number;
limit?: number;
searchText?: string;
searchField?: "report_name" | "created_by" | "report_type" | "updated_at" | "created_at";
startDate?: string;
endDate?: string;
reportType?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
// 리포트 목록 응답
export interface GetReportsResponse {
items: ReportMaster[];
total: number;
page: number;
limit: number;
typeSummary: Array<{ type: string; count: number }>;
allTypes: string[];
recentActivity: Array<{ date: string; count: number }>;
recentTotal: number;
}
// 리포트 생성 요청
export interface CreateReportRequest {
reportNameKor: string;
reportNameEng?: string;
@@ -104,6 +108,7 @@ export interface CreateReportRequest {
companyCode?: string;
}
// 리포트 수정 요청
export interface UpdateReportRequest {
reportNameKor?: string;
reportNameEng?: string;
@@ -112,18 +117,23 @@ export interface UpdateReportRequest {
useYn?: string;
}
// 워터마크 설정
export interface WatermarkConfig {
enabled: boolean;
type: "text" | "image";
// 텍스트 워터마크
text?: string;
fontSize?: number;
fontColor?: string;
// 이미지 워터마크
imageUrl?: string;
opacity: number;
// 공통 설정
opacity: number; // 0~1
style: "diagonal" | "center" | "tile";
rotation?: number;
rotation?: number; // 대각선일 때 각도 (기본 -45)
}
// 페이지 설정
export interface PageConfig {
page_id: string;
page_name: string;
@@ -137,29 +147,30 @@ export interface PageConfig {
left: number;
right: number;
};
components: Record<string, unknown>[];
components: any[];
}
// 레이아웃 설정
export interface ReportLayoutConfig {
pages: PageConfig[];
watermark?: WatermarkConfig;
watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크
}
export interface SaveLayoutQueryItem {
// 레이아웃 저장 요청
export interface SaveLayoutRequest {
layoutConfig: ReportLayoutConfig;
queries?: Array<{
id: string;
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
externalConnectionId?: number | null;
}
export interface SaveLayoutRequest {
layoutConfig: ReportLayoutConfig;
queries?: SaveLayoutQueryItem[];
menuObjids?: number[];
externalConnectionId?: number;
}>;
menuObjids?: number[]; // 연결할 메뉴 ID 목록
}
// 리포트-메뉴 매핑
export interface ReportMenuMapping {
mapping_id: number;
report_id: string;
@@ -169,20 +180,23 @@ export interface ReportMenuMapping {
created_by: string | null;
}
// 템플릿 목록 응답
export interface GetTemplatesResponse {
system: ReportTemplate[];
custom: ReportTemplate[];
}
// 템플릿 생성 요청
export interface CreateTemplateRequest {
templateNameKor: string;
templateNameEng?: string;
templateType: string;
description?: string;
layoutConfig?: Record<string, unknown>;
defaultQueries?: Array<Record<string, unknown>>;
layoutConfig?: any;
defaultQueries?: any;
}
// 컴포넌트 설정 (프론트엔드와 동기화)
export interface ComponentConfig {
id: string;
type: string;
@@ -210,16 +224,21 @@ export interface ComponentConfig {
conditional?: string;
locked?: boolean;
groupId?: string;
// 이미지 전용
imageUrl?: string;
objectFit?: "contain" | "cover" | "fill" | "none";
// 구분선 전용
orientation?: "horizontal" | "vertical";
lineStyle?: "solid" | "dashed" | "dotted" | "double";
lineWidth?: number;
lineColor?: string;
// 서명/도장 전용
showLabel?: boolean;
labelText?: string;
labelPosition?: "top" | "left" | "bottom" | "right";
showUnderline?: boolean;
personName?: string;
// 테이블 전용
tableColumns?: Array<{
field: string;
header: string;
@@ -230,7 +249,9 @@ export interface ComponentConfig {
headerTextColor?: string;
showBorder?: boolean;
rowHeight?: number;
// 페이지 번호 전용
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
// 카드 컴포넌트 전용
cardTitle?: string;
cardItems?: Array<{
label: string;
@@ -246,6 +267,7 @@ export interface ComponentConfig {
titleColor?: string;
labelColor?: string;
valueColor?: string;
// 계산 컴포넌트 전용
calcItems?: Array<{
label: string;
value: number | string;
@@ -258,6 +280,7 @@ export interface ComponentConfig {
showCalcBorder?: boolean;
numberFormat?: "none" | "comma" | "currency";
currencySuffix?: string;
// 바코드 컴포넌트 전용
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
barcodeValue?: string;
barcodeFieldName?: string;
@@ -266,118 +289,19 @@ export interface ComponentConfig {
barcodeBackground?: string;
barcodeMargin?: number;
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
// QR코드 다중 필드 (JSON 형식)
qrDataFields?: Array<{
fieldName: string;
label: string;
}>;
qrUseMultiField?: boolean;
qrIncludeAllRows?: boolean;
checkboxChecked?: boolean;
checkboxFieldName?: string;
checkboxLabel?: string;
checkboxSize?: number;
checkboxColor?: string;
checkboxBorderColor?: string;
checkboxLabelPosition?: "left" | "right";
visualQuery?: VisualQuery;
// 카드 레이아웃 설정 (card 컴포넌트 전용 - v3)
cardLayoutConfig?: CardLayoutConfig;
}
export interface VisualQueryFormulaColumn {
alias: string;
header: string;
expression: string;
}
export interface VisualQuery {
tableName: string;
limit?: number;
columns: string[];
formulaColumns: VisualQueryFormulaColumn[];
}
// ─────────────────────────────────────────────────────────────────────────────
// 카드 레이아웃 v3 타입 정의
// ─────────────────────────────────────────────────────────────────────────────
export type CardElementType = "header" | "dataCell" | "divider" | "badge";
export type CellDirection = "vertical" | "horizontal";
export interface CardElementBase {
id: string;
type: CardElementType;
colspan?: number;
rowspan?: number;
}
export interface CardHeaderElement extends CardElementBase {
type: "header";
icon?: string;
iconColor?: string;
title: string;
titleColor?: string;
titleFontSize?: number;
}
export interface CardDataCellElement extends CardElementBase {
type: "dataCell";
direction: CellDirection;
label: string;
columnName?: string;
inputType?: "text" | "date" | "number" | "select" | "readonly";
required?: boolean;
placeholder?: string;
selectOptions?: string[];
labelWidth?: number;
labelFontSize?: number;
labelColor?: string;
valueFontSize?: number;
valueColor?: string;
}
export interface CardDividerElement extends CardElementBase {
type: "divider";
style?: "solid" | "dashed" | "dotted";
color?: string;
thickness?: number;
}
export interface CardBadgeElement extends CardElementBase {
type: "badge";
label?: string;
columnName?: string;
colorMap?: Record<string, string>;
}
export type CardElement =
| CardHeaderElement
| CardDataCellElement
| CardDividerElement
| CardBadgeElement;
export interface CardLayoutRow {
id: string;
gridColumns: number;
elements: CardElement[];
height?: string;
}
export interface CardLayoutConfig {
tableName?: string;
primaryKey?: string;
rows: CardLayoutRow[];
padding?: string;
gap?: string;
borderStyle?: string;
borderColor?: string;
backgroundColor?: string;
headerTitleFontSize?: number;
headerTitleColor?: string;
labelFontSize?: number;
labelColor?: string;
valueFontSize?: number;
valueColor?: string;
dividerThickness?: number;
dividerColor?: string;
// 체크박스 컴포넌트 전용
checkboxChecked?: boolean; // 체크 상태 (고정값)
checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값)
checkboxLabel?: string; // 체크박스 옆 레이블 텍스트
checkboxSize?: number; // 체크박스 크기 (px)
checkboxColor?: string; // 체크 색상
checkboxBorderColor?: string; // 테두리 색상
checkboxLabelPosition?: "left" | "right"; // 레이블 위치
}
+14 -10
View File
@@ -98,23 +98,27 @@ export function buildDataFilterWhereClause(
paramIndex++;
break;
case "in":
if (Array.isArray(value) && value.length > 0) {
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
case "in": {
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (inArr.length > 0) {
const placeholders = inArr.map((_, idx) => `$${paramIndex + idx}`).join(", ");
conditions.push(`${columnRef} IN (${placeholders})`);
params.push(...value);
paramIndex += value.length;
params.push(...inArr);
paramIndex += inArr.length;
}
break;
}
case "not_in":
if (Array.isArray(value) && value.length > 0) {
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
case "not_in": {
const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (notInArr.length > 0) {
const placeholders = notInArr.map((_, idx) => `$${paramIndex + idx}`).join(", ");
conditions.push(`${columnRef} NOT IN (${placeholders})`);
params.push(...value);
paramIndex += value.length;
params.push(...notInArr);
paramIndex += notInArr.length;
}
break;
}
case "contains":
conditions.push(`${columnRef} LIKE $${paramIndex}`);
+71
View File
@@ -0,0 +1,71 @@
// 스마트공장 활용 로그 전송 유틸리티
// https://log.smart-factory.kr 에 사용자 접속 로그를 전송
import axios from "axios";
import { logger } from "./logger";
const SMART_FACTORY_LOG_URL =
"https://log.smart-factory.kr/apisvc/sendLogDataJSON.do";
/**
*
*
*/
export async function sendSmartFactoryLog(params: {
userId: string;
remoteAddr: string;
useType?: string;
}): Promise<void> {
const apiKey = process.env.SMART_FACTORY_API_KEY;
if (!apiKey) {
logger.warn(
"SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다."
);
return;
}
try {
const now = new Date();
const logDt = formatDateTime(now);
const logData = {
crtfcKey: apiKey,
logDt,
useSe: params.useType || "접속",
sysUser: params.userId,
conectIp: params.remoteAddr,
dataUsgqty: "",
};
const encodedLogData = encodeURIComponent(JSON.stringify(logData));
const response = await axios.get(SMART_FACTORY_LOG_URL, {
params: { logData: encodedLogData },
timeout: 5000,
});
logger.info("스마트공장 로그 전송 완료", {
userId: params.userId,
status: response.status,
});
} catch (error) {
// 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록
logger.error("스마트공장 로그 전송 실패", {
userId: params.userId,
error: error instanceof Error ? error.message : error,
});
}
}
/** yyyy-MM-dd HH:mm:ss.SSS 형식 */
function formatDateTime(date: Date): string {
const y = date.getFullYear();
const M = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const H = String(date.getHours()).padStart(2, "0");
const m = String(date.getMinutes()).padStart(2, "0");
const s = String(date.getSeconds()).padStart(2, "0");
const ms = String(date.getMilliseconds()).padStart(3, "0");
return `${y}-${M}-${d} ${H}:${m}:${s}.${ms}`;
}
@@ -0,0 +1,591 @@
# 리포트 디자이너 그리드 시스템 구현 계획
## 개요
현재 자유 배치 방식의 리포트 디자이너를 **그리드 기반 스냅 시스템**으로 전환합니다.
안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다.
## 목표
1. **정렬된 레이아웃**: 그리드 기반으로 요소들이 자동 정렬
2. **Word/PDF 변환 개선**: 그리드 정보를 활용하여 정확한 문서 변환
3. **직관적인 UI**: 그리드 시각화를 통한 명확한 배치 가이드
4. **사용자 제어**: 그리드 크기, 가시성 등 사용자 설정 가능
## 핵심 개념
### 그리드 시스템
```typescript
interface GridConfig {
// 그리드 설정
cellWidth: number; // 그리드 셀 너비 (px)
cellHeight: number; // 그리드 셀 높이 (px)
rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
columns: number; // 가로 그리드 수 (계산값: pageWidth / cellWidth)
// 표시 설정
visible: boolean; // 그리드 표시 여부
snapToGrid: boolean; // 그리드 스냅 활성화 여부
// 시각적 설정
gridColor: string; // 그리드 선 색상
gridOpacity: number; // 그리드 투명도 (0-1)
}
```
### 컴포넌트 위치/크기 (그리드 기반)
```typescript
interface ComponentPosition {
// 그리드 좌표 (셀 단위)
gridX: number; // 시작 열 (0부터 시작)
gridY: number; // 시작 행 (0부터 시작)
gridWidth: number; // 차지하는 열 수
gridHeight: number; // 차지하는 행 수
// 실제 픽셀 좌표 (계산값)
x: number; // gridX * cellWidth
y: number; // gridY * cellHeight
width: number; // gridWidth * cellWidth
height: number; // gridHeight * cellHeight
}
```
## 구현 단계
### Phase 1: 그리드 시스템 기반 구조
#### 1.1 타입 정의
- **파일**: `frontend/types/report.ts`
- **내용**:
- `GridConfig` 인터페이스 추가
- `ComponentConfig``gridX`, `gridY`, `gridWidth`, `gridHeight` 추가
- `ReportPage``gridConfig` 추가
#### 1.2 Context 확장
- **파일**: `frontend/contexts/ReportDesignerContext.tsx`
- **내용**:
- `gridConfig` 상태 추가
- `updateGridConfig()` 함수 추가
- `snapToGrid()` 유틸리티 함수 추가
- 컴포넌트 추가/이동/리사이즈 시 그리드 스냅 적용
#### 1.3 그리드 계산 유틸리티
- **파일**: `frontend/lib/utils/gridUtils.ts` (신규)
- **내용**:
```typescript
// 픽셀 좌표 → 그리드 좌표 변환
export function pixelToGrid(pixel: number, cellSize: number): number;
// 그리드 좌표 → 픽셀 좌표 변환
export function gridToPixel(grid: number, cellSize: number): number;
// 컴포넌트 위치/크기를 그리드에 스냅
export function snapComponentToGrid(
component: ComponentConfig,
gridConfig: GridConfig
): ComponentConfig;
// 그리드 충돌 감지
export function detectGridCollision(
component: ComponentConfig,
otherComponents: ComponentConfig[]
): boolean;
```
### Phase 2: 그리드 시각화
#### 2.1 그리드 레이어 컴포넌트
- **파일**: `frontend/components/report/designer/GridLayer.tsx` (신규)
- **내용**:
- Canvas 위에 그리드 선 렌더링
- SVG 또는 Canvas API 사용
- 그리드 크기/색상/투명도 적용
- 줌/스크롤 시에도 정확한 위치 유지
```tsx
interface GridLayerProps {
gridConfig: GridConfig;
pageWidth: number;
pageHeight: number;
}
export function GridLayer({
gridConfig,
pageWidth,
pageHeight,
}: GridLayerProps) {
if (!gridConfig.visible) return null;
// SVG로 그리드 선 렌더링
return (
<svg className="absolute inset-0 pointer-events-none">
{/* 세로 선 */}
{Array.from({ length: gridConfig.columns + 1 }).map((_, i) => (
<line
key={`v-${i}`}
x1={i * gridConfig.cellWidth}
y1={0}
x2={i * gridConfig.cellWidth}
y2={pageHeight}
stroke={gridConfig.gridColor}
strokeOpacity={gridConfig.opacity}
/>
))}
{/* 가로 선 */}
{Array.from({ length: gridConfig.rows + 1 }).map((_, i) => (
<line
key={`h-${i}`}
x1={0}
y1={i * gridConfig.cellHeight}
x2={pageWidth}
y2={i * gridConfig.cellHeight}
stroke={gridConfig.gridColor}
strokeOpacity={gridConfig.opacity}
/>
))}
</svg>
);
}
```
#### 2.2 Canvas 통합
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- **내용**:
- `<GridLayer />` 추가
- 컴포넌트 렌더링 시 그리드 기반 위치 사용
### Phase 3: 드래그 앤 드롭 스냅
#### 3.1 드래그 시 그리드 스냅
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- **내용**:
- `useDrop` 훅 수정
- 드롭 위치를 그리드에 스냅
- 실시간 스냅 가이드 표시
```typescript
const [, drop] = useDrop({
accept: ["TEXT", "LABEL", "TABLE", "SIGNATURE", "STAMP"],
drop: (item: any, monitor) => {
const offset = monitor.getClientOffset();
if (!offset) return;
// 캔버스 상대 좌표 계산
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
let x = offset.x - canvasRect.left;
let y = offset.y - canvasRect.top;
// 그리드 스냅 적용
if (gridConfig.snapToGrid) {
const gridX = Math.round(x / gridConfig.cellWidth);
const gridY = Math.round(y / gridConfig.cellHeight);
x = gridX * gridConfig.cellWidth;
y = gridY * gridConfig.cellHeight;
}
// 컴포넌트 추가
addComponent({ type: item.type, x, y });
},
});
```
#### 3.2 리사이즈 시 그리드 스냅
- **파일**: `frontend/components/report/designer/ComponentWrapper.tsx`
- **내용**:
- `react-resizable` 또는 `react-rnd``snap` 설정 활용
- 리사이즈 핸들 드래그 시 그리드 단위로만 크기 조절
```typescript
<Rnd
position={{ x: component.x, y: component.y }}
size={{ width: component.width, height: component.height }}
onDragStop={(e, d) => {
let newX = d.x;
let newY = d.y;
if (gridConfig.snapToGrid) {
const gridX = Math.round(newX / gridConfig.cellWidth);
const gridY = Math.round(newY / gridConfig.cellHeight);
newX = gridX * gridConfig.cellWidth;
newY = gridY * gridConfig.cellHeight;
}
updateComponent(component.id, { x: newX, y: newY });
}}
onResizeStop={(e, direction, ref, delta, position) => {
let newWidth = parseInt(ref.style.width);
let newHeight = parseInt(ref.style.height);
if (gridConfig.snapToGrid) {
const gridWidth = Math.round(newWidth / gridConfig.cellWidth);
const gridHeight = Math.round(newHeight / gridConfig.cellHeight);
newWidth = gridWidth * gridConfig.cellWidth;
newHeight = gridHeight * gridConfig.cellHeight;
}
updateComponent(component.id, {
width: newWidth,
height: newHeight,
...position,
});
}}
grid={
gridConfig.snapToGrid
? [gridConfig.cellWidth, gridConfig.cellHeight]
: undefined
}
/>
```
### Phase 4: 그리드 설정 UI
#### 4.1 그리드 설정 패널
- **파일**: `frontend/components/report/designer/GridSettingsPanel.tsx` (신규)
- **내용**:
- 그리드 크기 조절 (cellWidth, cellHeight)
- 그리드 표시/숨김 토글
- 스냅 활성화/비활성화 토글
- 그리드 색상/투명도 조절
```tsx
export function GridSettingsPanel() {
const { gridConfig, updateGridConfig } = useReportDesigner();
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">그리드 설정</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 그리드 표시 */}
<div className="flex items-center justify-between">
<Label>그리드 표시</Label>
<Switch
checked={gridConfig.visible}
onCheckedChange={(visible) => updateGridConfig({ visible })}
/>
</div>
{/* 스냅 활성화 */}
<div className="flex items-center justify-between">
<Label>그리드 스냅</Label>
<Switch
checked={gridConfig.snapToGrid}
onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })}
/>
</div>
{/* 셀 크기 */}
<div className="space-y-2">
<Label>셀 너비 (px)</Label>
<Input
type="number"
value={gridConfig.cellWidth}
onChange={(e) =>
updateGridConfig({ cellWidth: parseInt(e.target.value) })
}
min={10}
max={100}
/>
</div>
<div className="space-y-2">
<Label>셀 높이 (px)</Label>
<Input
type="number"
value={gridConfig.cellHeight}
onChange={(e) =>
updateGridConfig({ cellHeight: parseInt(e.target.value) })
}
min={10}
max={100}
/>
</div>
{/* 프리셋 */}
<div className="space-y-2">
<Label>프리셋</Label>
<Select
onValueChange={(value) => {
const presets: Record<
string,
{ cellWidth: number; cellHeight: number }
> = {
fine: { cellWidth: 10, cellHeight: 10 },
medium: { cellWidth: 20, cellHeight: 20 },
coarse: { cellWidth: 50, cellHeight: 50 },
};
updateGridConfig(presets[value]);
}}
>
<SelectTrigger>
<SelectValue placeholder="그리드 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fine">세밀 (10x10)</SelectItem>
<SelectItem value="medium">중간 (20x20)</SelectItem>
<SelectItem value="coarse">넓음 (50x50)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
);
}
```
#### 4.2 툴바에 그리드 토글 추가
- **파일**: `frontend/components/report/designer/ReportDesignerToolbar.tsx`
- **내용**:
- 그리드 표시/숨김 버튼
- 그리드 설정 모달 열기 버튼
- 키보드 단축키 (`G` 키로 그리드 토글)
### Phase 5: Word 변환 개선
#### 5.1 그리드 기반 레이아웃 변환
- **파일**: `frontend/components/report/designer/ReportPreviewModal.tsx`
- **내용**:
- 그리드 정보를 활용하여 더 정확한 테이블 레이아웃 생성
- 그리드 행/열을 Word 테이블의 행/열로 매핑
```typescript
const handleDownloadWord = async () => {
// 그리드 기반으로 컴포넌트 배치 맵 생성
const gridMap: (ComponentConfig | null)[][] = Array(gridConfig.rows)
.fill(null)
.map(() => Array(gridConfig.columns).fill(null));
// 각 컴포넌트를 그리드 맵에 배치
for (const component of components) {
const gridX = Math.round(component.x / gridConfig.cellWidth);
const gridY = Math.round(component.y / gridConfig.cellHeight);
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
// 컴포넌트가 차지하는 모든 셀에 참조 저장
for (let y = gridY; y < gridY + gridHeight; y++) {
for (let x = gridX; x < gridX + gridWidth; x++) {
if (y < gridConfig.rows && x < gridConfig.columns) {
gridMap[y][x] = component;
}
}
}
}
// 그리드 맵을 Word 테이블로 변환
const tableRows: TableRow[] = [];
for (let y = 0; y < gridConfig.rows; y++) {
const cells: TableCell[] = [];
let x = 0;
while (x < gridConfig.columns) {
const component = gridMap[y][x];
if (!component) {
// 빈 셀
cells.push(new TableCell({ children: [new Paragraph("")] }));
x++;
} else {
// 컴포넌트 셀
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
const cell = createTableCell(component, gridWidth, gridHeight);
if (cell) cells.push(cell);
x += gridWidth;
}
}
if (cells.length > 0) {
tableRows.push(new TableRow({ children: cells }));
}
}
// ... Word 문서 생성
};
```
### Phase 6: 데이터 마이그레이션
#### 6.1 기존 레이아웃 자동 변환
- **파일**: `frontend/lib/utils/layoutMigration.ts` (신규)
- **내용**:
- 기존 절대 위치 데이터를 그리드 기반으로 변환
- 가장 가까운 그리드 셀에 스냅
- 마이그레이션 로그 생성
```typescript
export function migrateLayoutToGrid(
layout: ReportLayoutConfig,
gridConfig: GridConfig
): ReportLayoutConfig {
return {
...layout,
pages: layout.pages.map((page) => ({
...page,
gridConfig,
components: page.components.map((component) => {
// 픽셀 좌표를 그리드 좌표로 변환
const gridX = Math.round(component.x / gridConfig.cellWidth);
const gridY = Math.round(component.y / gridConfig.cellHeight);
const gridWidth = Math.max(
1,
Math.round(component.width / gridConfig.cellWidth)
);
const gridHeight = Math.max(
1,
Math.round(component.height / gridConfig.cellHeight)
);
return {
...component,
gridX,
gridY,
gridWidth,
gridHeight,
x: gridX * gridConfig.cellWidth,
y: gridY * gridConfig.cellHeight,
width: gridWidth * gridConfig.cellWidth,
height: gridHeight * gridConfig.cellHeight,
};
}),
})),
};
}
```
#### 6.2 마이그레이션 UI
- **파일**: `frontend/components/report/designer/MigrationModal.tsx` (신규)
- **내용**:
- 기존 리포트 로드 시 마이그레이션 필요 여부 체크
- 마이그레이션 전/후 미리보기
- 사용자 확인 후 적용
## 데이터베이스 스키마 변경
### report_layout_pages 테이블
```sql
ALTER TABLE report_layout_pages
ADD COLUMN grid_cell_width INTEGER DEFAULT 20,
ADD COLUMN grid_cell_height INTEGER DEFAULT 20,
ADD COLUMN grid_visible BOOLEAN DEFAULT true,
ADD COLUMN grid_snap_enabled BOOLEAN DEFAULT true,
ADD COLUMN grid_color VARCHAR(7) DEFAULT '#e5e7eb',
ADD COLUMN grid_opacity DECIMAL(3,2) DEFAULT 0.5;
```
### report_layout_components 테이블
```sql
ALTER TABLE report_layout_components
ADD COLUMN grid_x INTEGER,
ADD COLUMN grid_y INTEGER,
ADD COLUMN grid_width INTEGER,
ADD COLUMN grid_height INTEGER;
-- 기존 데이터 마이그레이션
UPDATE report_layout_components
SET
grid_x = ROUND(position_x / 20.0),
grid_y = ROUND(position_y / 20.0),
grid_width = GREATEST(1, ROUND(width / 20.0)),
grid_height = GREATEST(1, ROUND(height / 20.0))
WHERE grid_x IS NULL;
```
## 테스트 계획
### 단위 테스트
- `gridUtils.ts`의 모든 함수 테스트
- 그리드 좌표 ↔ 픽셀 좌표 변환 정확성
- 충돌 감지 로직
### 통합 테스트
- 드래그 앤 드롭 시 그리드 스냅 동작
- 리사이즈 시 그리드 스냅 동작
- 그리드 크기 변경 시 컴포넌트 재배치
### E2E 테스트
- 새 리포트 생성 및 그리드 설정
- 기존 리포트 마이그레이션
- Word 다운로드 시 레이아웃 정확성
## 예상 개발 일정
- **Phase 1**: 그리드 시스템 기반 구조 (2일)
- **Phase 2**: 그리드 시각화 (1일)
- **Phase 3**: 드래그 앤 드롭 스냅 (2일)
- **Phase 4**: 그리드 설정 UI (1일)
- **Phase 5**: Word 변환 개선 (2일)
- **Phase 6**: 데이터 마이그레이션 (1일)
- **테스트 및 디버깅**: (2일)
**총 예상 기간**: 11일
## 기술적 고려사항
### 성능 최적화
- 그리드 렌더링: SVG 대신 Canvas API 고려 (많은 셀의 경우)
- 메모이제이션: 그리드 계산 결과 캐싱
- 가상화: 큰 페이지에서 보이는 영역만 렌더링
### 사용자 경험
- 실시간 스냅 가이드: 드래그 중 스냅될 위치 미리 표시
- 키보드 단축키: 방향키로 그리드 단위 이동, Shift+방향키로 픽셀 단위 미세 조정
- 언두/리두: 그리드 스냅 적용 전/후 상태 저장
### 하위 호환성
- 기존 리포트는 자동 마이그레이션 제공
- 마이그레이션 옵션: 자동 / 수동 선택 가능
- 레거시 모드: 그리드 없이 자유 배치 가능 (옵션)
## 추가 기능 (향후 확장)
### 스마트 가이드
- 다른 컴포넌트와 정렬 시 가이드 라인 표시
- 균등 간격 가이드
### 그리드 템플릿
- 자주 사용하는 그리드 레이아웃 템플릿 제공
- 문서 종류별 프리셋 (계약서, 보고서, 송장 등)
### 그리드 병합
- 여러 그리드 셀을 하나로 병합
- 복잡한 레이아웃 지원
## 참고 자료
- Android Home Screen Widget System
- Microsoft Word Table Layout
- CSS Grid Layout
- Figma Auto Layout
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,358 @@
# 리포트 관리 시스템 구현 진행 상황
## 프로젝트 개요
동적 리포트 디자이너 시스템 구현
- 사용자가 드래그 앤 드롭으로 리포트 레이아웃 설계
- SQL 쿼리 연동으로 실시간 데이터 표시
- 미리보기 및 인쇄 기능
---
## 완료된 작업 ✅
### 1. 데이터베이스 설계 및 구축
- [x] `report_template` 테이블 생성 (18개 초기 템플릿)
- [x] `report_master` 테이블 생성 (리포트 메타 정보)
- [x] `report_layout` 테이블 생성 (레이아웃 JSON)
- [x] `report_query` 테이블 생성 (쿼리 정의)
**파일**: `db/report_schema.sql`, `db/report_query_schema.sql`
### 2. 백엔드 API 구현
- [x] 리포트 CRUD API (생성, 조회, 수정, 삭제)
- [x] 템플릿 조회 API
- [x] 레이아웃 저장/조회 API
- [x] 쿼리 실행 API (파라미터 지원)
- [x] 리포트 복사 API
- [x] Raw SQL 기반 구현 (Prisma 대신 pg 사용)
**파일**:
- `backend-node/src/types/report.ts`
- `backend-node/src/services/reportService.ts`
- `backend-node/src/controllers/reportController.ts`
- `backend-node/src/routes/reportRoutes.ts`
### 3. 프론트엔드 - 리포트 목록 페이지
- [x] 리포트 리스트 조회 및 표시
- [x] 검색 기능
- [x] 페이지네이션
- [x] 새 리포트 생성 (디자이너로 이동)
- [x] 수정/복사/삭제 액션 버튼
**파일**:
- `frontend/app/(main)/admin/report/page.tsx`
- `frontend/components/report/ReportListTable.tsx`
- `frontend/hooks/useReportList.ts`
### 4. 프론트엔드 - 리포트 디자이너 기본 구조
- [x] Context 기반 상태 관리 (`ReportDesignerContext`)
- [x] 툴바 (저장, 미리보기, 초기화, 뒤로가기)
- [x] 3단 레이아웃 (좌측 팔레트 / 중앙 캔버스 / 우측 속성)
- [x] "new" 리포트 처리 (저장 시 생성)
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx`
- `frontend/app/(main)/admin/report/designer/[reportId]/page.tsx`
- `frontend/components/report/designer/ReportDesignerToolbar.tsx`
### 5. 컴포넌트 팔레트 및 캔버스
- [x] 드래그 가능한 컴포넌트 목록 (텍스트, 레이블, 테이블)
- [x] 드래그 앤 드롭으로 캔버스에 컴포넌트 배치
- [x] 컴포넌트 이동 (드래그)
- [x] 컴포넌트 크기 조절 (리사이즈 핸들)
- [x] 컴포넌트 선택 및 삭제
**파일**:
- `frontend/components/report/designer/ComponentPalette.tsx`
- `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 6. 쿼리 관리 시스템
- [x] 쿼리 추가/수정/삭제 (마스터/디테일)
- [x] SQL 파라미터 자동 감지 ($1, $2 등)
- [x] 파라미터 타입 선택 (text, number, date)
- [x] 파라미터 입력값 검증
- [x] 쿼리 실행 및 결과 표시
- [x] "new" 리포트에서도 쿼리 실행 가능
- [x] 실행 결과를 Context에 저장
**파일**:
- `frontend/components/report/designer/QueryManager.tsx`
- `frontend/contexts/ReportDesignerContext.tsx` (QueryResult 관리)
### 7. 데이터 바인딩 시스템
- [x] 속성 패널에서 컴포넌트-쿼리 연결
- [x] 텍스트/레이블: 쿼리 + 필드 선택
- [x] 테이블: 쿼리 선택 (모든 필드 자동 표시)
- [x] 캔버스에서 실제 데이터 표시 (바인딩된 필드의 값)
- [x] 실행 결과가 없으면 `{필드명}` 표시
**파일**:
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 8. 미리보기 및 내보내기
- [x] 미리보기 모달
- [x] 실제 쿼리 데이터로 렌더링
- [x] 편집용 UI 제거 (순수 데이터만 표시)
- [x] 브라우저 인쇄 기능
- [x] PDF 다운로드 (브라우저 네이티브 인쇄 기능)
- [x] WORD 다운로드 (docx 라이브러리)
- [x] 파일명 자동 생성 (리포트명\_날짜)
**파일**:
- `frontend/components/report/designer/ReportPreviewModal.tsx`
**사용 라이브러리**:
- `docx`: WORD 문서 생성 (PDF는 브라우저 기본 기능 사용)
### 9. 템플릿 시스템
- [x] 시스템 템플릿 적용 (발주서, 청구서, 기본)
- [x] 템플릿별 기본 컴포넌트 자동 배치
- [x] 템플릿별 기본 쿼리 자동 생성
- [x] 사용자 정의 템플릿 저장 기능
- [x] 사용자 정의 템플릿 목록 조회
- [x] 사용자 정의 템플릿 삭제
- [x] 사용자 정의 템플릿 적용 (백엔드 연동)
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx` (템플릿 적용 로직)
- `frontend/components/report/designer/TemplatePalette.tsx`
- `frontend/components/report/designer/SaveAsTemplateModal.tsx`
- `backend-node/src/services/reportService.ts` (createTemplateFromLayout)
### 10. 외부 DB 연동
- [x] 쿼리별 외부 DB 연결 선택
- [x] 외부 DB 연결 목록 조회 API
- [x] 쿼리 실행 시 외부 DB 지원
- [x] 내부/외부 DB 선택 UI
**파일**:
- `frontend/components/report/designer/QueryManager.tsx`
- `backend-node/src/services/reportService.ts` (executeQuery with external DB)
### 11. 컴포넌트 스타일링
- [x] 폰트 크기 설정
- [x] 폰트 색상 설정 (컬러피커)
- [x] 폰트 굵기 (보통/굵게)
- [x] 텍스트 정렬 (좌/중/우)
- [x] 배경색 설정 (투명 옵션 포함)
- [x] 테두리 설정 (두께, 색상)
- [x] 캔버스 및 미리보기에 스타일 반영
**파일**:
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 12. 레이아웃 도구 (완료!)
- [x] **Grid Snap**: 10px 단위 그리드에 자동 정렬
- [x] **정렬 가이드라인**: 드래그 시 빨간색 가이드라인 표시
- [x] **복사/붙여넣기**: Ctrl+C/V로 컴포넌트 복사 (20px 오프셋)
- [x] **Undo/Redo**: 히스토리 관리 (Ctrl+Z / Ctrl+Shift+Z)
- [x] **컴포넌트 정렬**: 좌/우/상/하/가로중앙/세로중앙 정렬
- [x] **컴포넌트 배치**: 가로/세로 균등 배치 (3개 이상)
- [x] **크기 조정**: 같은 너비/높이/크기로 조정 (2개 이상)
- [x] **화살표 키 이동**: 1px 이동, Shift+화살표 10px 이동
- [x] **레이어 관리**: 맨 앞/뒤, 한 단계 앞/뒤 (Z-Index 조정)
- [x] **컴포넌트 잠금**: 편집/이동/삭제 방지, 🔒 표시
- [x] **눈금자 표시**: 가로/세로 mm 단위 눈금자
- [x] **컴포넌트 그룹화**: 여러 컴포넌트를 그룹으로 묶어 함께 이동, 👥 표시
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx` (레이아웃 도구 로직)
- `frontend/components/report/designer/ReportDesignerToolbar.tsx` (버튼 UI)
- `frontend/components/report/designer/ReportDesignerCanvas.tsx` (Grid, 가이드라인)
- `frontend/components/report/designer/CanvasComponent.tsx` (잠금, 그룹)
- `frontend/components/report/designer/Ruler.tsx` (눈금자 컴포넌트)
---
## 진행 중인 작업 🚧
없음 (모든 레이아웃 도구 구현 완료!)
---
## 남은 작업 (우선순위순) 📋
### Phase 1: 추가 컴포넌트 ✅ 완료!
1. **이미지 컴포넌트**
- [x] 파일 업로드 (multer, 10MB 제한)
- [x] 회사별 디렉토리 분리 저장
- [x] 맞춤 방식 (contain/cover/fill/none)
- [x] CORS 설정으로 이미지 로딩
- [x] 캔버스 및 미리보기 렌더링
- 로고, 서명, 도장 등에 활용
2. **구분선 컴포넌트 (Divider)**
- [x] 가로/세로 방향 선택
- [x] 선 두께 (lineWidth) 독립 속성
- [x] 선 색상 (lineColor) 독립 속성
- [x] 선 스타일 (solid/dashed/dotted/double)
- [x] 캔버스 및 미리보기 렌더링
**파일**:
- `backend-node/src/controllers/reportController.ts` (uploadImage)
- `backend-node/src/routes/reportRoutes.ts` (multer 설정)
- `frontend/types/report.ts` (이미지/구분선 속성)
- `frontend/components/report/designer/ComponentPalette.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/ReportPreviewModal.tsx`
- `frontend/lib/api/client.ts` (getFullImageUrl)
3. **차트 컴포넌트** (선택사항) ⬅️ 다음 권장 작업
- 막대 차트
- 선 차트
- 원형 차트
- 쿼리 데이터 연동
### Phase 2: 고급 기능
4. **조건부 서식**
- 특정 조건에 따른 스타일 변경
- 값 범위에 따른 색상 표시
- 수식 기반 표시/숨김
5. **쿼리 관리 개선**
- 쿼리 미리보기 개선 (테이블 형태)
- 쿼리 저장/불러오기
- 쿼리 템플릿
### Phase 3: 성능 및 보안
6. **성능 최적화**
- 쿼리 결과 캐싱
- 대용량 데이터 페이징
- 렌더링 최적화
- 이미지 레이지 로딩
7. **권한 관리**
- 리포트별 접근 권한
- 수정 권한 분리
- 템플릿 공유
- 사용자별 리포트 목록 필터링
---
## 기술 스택
### 백엔드
- Node.js + TypeScript
- Express.js
- PostgreSQL (raw SQL)
- pg (node-postgres)
### 프론트엔드
- Next.js 14 (App Router)
- React 18
- TypeScript
- Tailwind CSS
- Shadcn UI
- react-dnd (드래그 앤 드롭)
---
## 주요 아키텍처 결정
### 1. Context API 사용
- 리포트 디자이너의 복잡한 상태를 Context로 중앙 관리
- 컴포넌트 간 prop drilling 방지
### 2. Raw SQL 사용
- Prisma 대신 직접 SQL 작성
- 복잡한 쿼리와 트랜잭션 처리에 유리
- 데이터베이스 제어 수준 향상
### 3. JSON 기반 레이아웃 저장
- 레이아웃을 JSONB로 DB에 저장
- 버전 관리 용이
- 유연한 스키마
### 4. 쿼리 실행 결과 메모리 관리
- Context에 쿼리 결과 저장
- 컴포넌트에서 실시간 참조
- 불필요한 API 호출 방지
---
## 참고 문서
- [리포트*관리*시스템\_설계.md](./리포트_관리_시스템_설계.md) - 초기 설계 문서
- [레포트드자이너.html](../레포트드자이너.html) - 참조 프로토타입
---
## 다음 작업: 리포트 복사/삭제 테스트 및 검증
### 테스트 항목
1. **복사 기능 테스트**
- 리포트 복사 버튼 클릭
- 복사된 리포트명 확인 (원본명 + "\_copy")
- 복사된 리포트의 레이아웃 확인
- 복사된 리포트의 쿼리 확인
- 목록 자동 새로고침 확인
2. **삭제 기능 테스트**
- 삭제 버튼 클릭 시 확인 다이얼로그 표시
- 취소 버튼 동작 확인
- 삭제 실행 후 목록에서 제거 확인
- Toast 메시지 표시 확인
3. **에러 처리 테스트**
- 존재하지 않는 리포트 삭제 시도
- 네트워크 오류 시 Toast 메시지
- 로딩 중 버튼 비활성화 확인
### 추가 개선 사항
- [ ] 컴포넌트 복사 기능 (Ctrl+C/Ctrl+V)
- [ ] 다중 선택 및 정렬 기능
- [ ] 실행 취소/다시 실행 (Undo/Redo)
- [ ] 사용자 정의 템플릿 저장
---
**최종 업데이트**: 2025-10-01
**작성자**: AI Assistant
**상태**: 이미지 & 구분선 컴포넌트 완료 (기본 컴포넌트 완료, 약 99% 완료)
@@ -0,0 +1,679 @@
# 리포트 관리 시스템 설계
## 1. 프로젝트 개요
### 1.1 목적
ERP 시스템에서 다양한 업무 문서(발주서, 청구서, 거래명세서 등)를 동적으로 디자인하고 관리할 수 있는 리포트 관리 시스템을 구축합니다.
### 1.2 주요 기능
- 리포트 목록 조회 및 관리
- 드래그 앤 드롭 기반 리포트 디자이너
- 템플릿 관리 (기본 템플릿 + 사용자 정의 템플릿)
- 쿼리 관리 (마스터/디테일)
- 외부 DB 연동
- 인쇄 및 내보내기 (PDF, WORD)
- 미리보기 기능
## 2. 화면 구성
### 2.1 리포트 목록 화면 (`/admin/report`)
```
┌──────────────────────────────────────────────────────────────────┐
│ 리포트 관리 [+ 새 리포트] │
├──────────────────────────────────────────────────────────────────┤
│ 검색: [____________________] [검색] [초기화] │
├──────────────────────────────────────────────────────────────────┤
│ No │ 리포트명 │ 작성자 │ 수정일 │ 액션 │
├────┼──────────────┼────────┼───────────┼────────────────────────┤
│ 1 │ 발주서 양식 │ 홍길동 │ 2025-10-01 │ 수정 │ 복사 │ 삭제 │
│ 2 │ 청구서 기본 │ 김철수 │ 2025-09-28 │ 수정 │ 복사 │ 삭제 │
│ 3 │ 거래명세서 │ 이영희 │ 2025-09-25 │ 수정 │ 복사 │ 삭제 │
└──────────────────────────────────────────────────────────────────┘
```
**기능**
- 리포트 목록 조회 (페이징, 정렬, 검색)
- 새 리포트 생성
- 기존 리포트 수정
- 리포트 복사
- 리포트 삭제
- 리포트 미리보기
### 2.2 리포트 디자이너 화면
```
┌──────────────────────────────────────────────────────────────────┐
│ 리포트 디자이너 [저장] [미리보기] [초기화] [목록으로] │
├──────┬────────────────────────────────────────────────┬──────────┤
│ │ │ │
│ 템플릿│ 작업 영역 (캔버스) │ 속성 패널 │
│ │ │ │
│ 컴포넌트│ [드래그 앤 드롭] │ 쿼리 관리 │
│ │ │ │
│ │ │ DB 연동 │
└──────┴────────────────────────────────────────────────┴──────────┘
```
### 2.3 미리보기 모달
```
┌──────────────────────────────────────────────────────────────────┐
│ 미리보기 [닫기] │
├──────────────────────────────────────────────────────────────────┤
│ │
│ [리포트 내용 미리보기] │
│ │
├──────────────────────────────────────────────────────────────────┤
│ [인쇄] [PDF] [WORD] │
└──────────────────────────────────────────────────────────────────┘
```
## 3. 데이터베이스 설계
### 3.1 테이블 구조
#### REPORT_TEMPLATE (리포트 템플릿)
```sql
CREATE TABLE report_template (
template_id VARCHAR(50) PRIMARY KEY, -- 템플릿 ID
template_name_kor VARCHAR(100) NOT NULL, -- 템플릿명 (한국어)
template_name_eng VARCHAR(100), -- 템플릿명 (영어)
template_type VARCHAR(30) NOT NULL, -- 템플릿 타입 (ORDER, INVOICE, STATEMENT, etc)
is_system CHAR(1) DEFAULT 'N', -- 시스템 기본 템플릿 여부 (Y/N)
thumbnail_url VARCHAR(500), -- 썸네일 이미지 경로
description TEXT, -- 템플릿 설명
layout_config TEXT, -- 레이아웃 설정 (JSON)
default_queries TEXT, -- 기본 쿼리 (JSON)
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
sort_order INTEGER DEFAULT 0, -- 정렬 순서
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50)
);
```
#### REPORT_MASTER (리포트 마스터)
```sql
CREATE TABLE report_master (
report_id VARCHAR(50) PRIMARY KEY, -- 리포트 ID
report_name_kor VARCHAR(100) NOT NULL, -- 리포트명 (한국어)
report_name_eng VARCHAR(100), -- 리포트명 (영어)
template_id VARCHAR(50), -- 템플릿 ID (FK)
report_type VARCHAR(30) NOT NULL, -- 리포트 타입
company_code VARCHAR(20), -- 회사 코드
description TEXT, -- 설명
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50),
FOREIGN KEY (template_id) REFERENCES report_template(template_id)
);
```
#### REPORT_LAYOUT (리포트 레이아웃)
```sql
CREATE TABLE report_layout (
layout_id VARCHAR(50) PRIMARY KEY, -- 레이아웃 ID
report_id VARCHAR(50) NOT NULL, -- 리포트 ID (FK)
canvas_width INTEGER DEFAULT 210, -- 캔버스 너비 (mm)
canvas_height INTEGER DEFAULT 297, -- 캔버스 높이 (mm)
page_orientation VARCHAR(10) DEFAULT 'portrait', -- 페이지 방향 (portrait/landscape)
margin_top INTEGER DEFAULT 20, -- 상단 여백 (mm)
margin_bottom INTEGER DEFAULT 20, -- 하단 여백 (mm)
margin_left INTEGER DEFAULT 20, -- 좌측 여백 (mm)
margin_right INTEGER DEFAULT 20, -- 우측 여백 (mm)
components TEXT, -- 컴포넌트 배치 정보 (JSON)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50),
FOREIGN KEY (report_id) REFERENCES report_master(report_id)
);
```
## 4. 컴포넌트 목록
### 4.1 기본 컴포넌트
#### 텍스트 관련
- **Text Field**: 단일 라인 텍스트 입력/표시
- **Text Area**: 여러 줄 텍스트 입력/표시
- **Label**: 고정 라벨 텍스트
- **Rich Text**: 서식이 있는 텍스트 (굵게, 기울임, 색상)
#### 숫자/날짜 관련
- **Number**: 숫자 표시 (통화 형식 지원)
- **Date**: 날짜 표시 (형식 지정 가능)
- **Date Time**: 날짜 + 시간 표시
- **Calculate Field**: 계산 필드 (합계, 평균 등)
#### 테이블/그리드
- **Data Table**: 데이터 테이블 (디테일 쿼리 바인딩)
- **Summary Table**: 요약 테이블
- **Group Table**: 그룹핑 테이블
#### 이미지/그래픽
- **Image**: 이미지 표시 (로고, 서명 등)
- **Line**: 구분선
- **Rectangle**: 사각형 (테두리)
#### 특수 컴포넌트
- **Page Number**: 페이지 번호
- **Current Date**: 현재 날짜/시간
- **Company Info**: 회사 정보 (자동)
- **Signature**: 서명란
- **Stamp**: 도장란
### 4.2 컴포넌트 속성
각 컴포넌트는 다음 공통 속성을 가집니다:
```typescript
interface ComponentBase {
id: string; // 컴포넌트 ID
type: string; // 컴포넌트 타입
x: number; // X 좌표
y: number; // Y 좌표
width: number; // 너비
height: number; // 높이
zIndex: number; // Z-인덱스
// 스타일
fontSize?: number; // 글자 크기
fontFamily?: string; // 폰트
fontWeight?: string; // 글자 굵기
fontColor?: string; // 글자 색상
backgroundColor?: string; // 배경색
borderWidth?: number; // 테두리 두께
borderColor?: string; // 테두리 색상
borderRadius?: number; // 모서리 둥글기
textAlign?: string; // 텍스트 정렬
padding?: number; // 내부 여백
// 데이터 바인딩
queryId?: string; // 연결된 쿼리 ID
fieldName?: string; // 필드명
defaultValue?: string; // 기본값
format?: string; // 표시 형식
// 기타
visible?: boolean; // 표시 여부
printable?: boolean; // 인쇄 여부
conditional?: string; // 조건부 표시 (수식)
}
```
## 5. 템플릿 목록
### 5.1 기본 템플릿 (시스템)
#### 구매/발주 관련
- **발주서 (Purchase Order)**: 거래처에 발주하는 문서
- **구매요청서 (Purchase Request)**: 내부 구매 요청 문서
- **발주 확인서 (PO Confirmation)**: 발주 확인 문서
#### 판매/청구 관련
- **청구서 (Invoice)**: 고객에게 청구하는 문서
- **견적서 (Quotation)**: 견적 제공 문서
- **거래명세서 (Transaction Statement)**: 거래 내역 명세
- **세금계산서 (Tax Invoice)**: 세금 계산서
- **영수증 (Receipt)**: 영수 증빙 문서
#### 재고/입출고 관련
- **입고증 (Goods Receipt)**: 입고 증빙 문서
- **출고증 (Delivery Note)**: 출고 증빙 문서
- **재고 현황표 (Inventory Report)**: 재고 현황
- **이동 전표 (Transfer Note)**: 재고 이동 문서
#### 생산 관련
- **작업지시서 (Work Order)**: 생산 작업 지시
- **생산 일보 (Production Daily Report)**: 생산 일일 보고
- **품질 검사표 (Quality Inspection)**: 품질 검사 기록
- **불량 보고서 (Defect Report)**: 불량 보고
#### 회계/경영 관련
- **손익 계산서 (Income Statement)**: 손익 현황
- **대차대조표 (Balance Sheet)**: 재무 상태
- **현금 흐름표 (Cash Flow Statement)**: 현금 흐름
- **급여 명세서 (Payroll Slip)**: 급여 내역
#### 일반 문서
- **기본 양식 (Basic Template)**: 빈 캔버스
- **일반 보고서 (General Report)**: 일반 보고 양식
- **목록 양식 (List Template)**: 목록형 양식
### 5.2 사용자 정의 템플릿
- 사용자가 직접 생성한 템플릿
- 기본 템플릿을 복사하여 수정 가능
- 회사별로 관리 가능
## 6. API 설계
### 6.1 리포트 목록 API
#### GET `/api/admin/reports`
리포트 목록 조회
```typescript
// Request
interface GetReportsRequest {
page?: number;
limit?: number;
searchText?: string;
reportType?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
// Response
interface GetReportsResponse {
items: ReportMaster[];
total: number;
page: number;
limit: number;
}
```
#### GET `/api/admin/reports/:reportId`
리포트 상세 조회
```typescript
// Response
interface ReportDetail {
report: ReportMaster;
layout: ReportLayout;
queries: ReportQuery[];
components: Component[];
}
```
#### POST `/api/admin/reports`
리포트 생성
```typescript
// Request
interface CreateReportRequest {
reportNameKor: string;
reportNameEng?: string;
templateId?: string;
reportType: string;
description?: string;
}
// Response
interface CreateReportResponse {
reportId: string;
message: string;
}
```
#### PUT `/api/admin/reports/:reportId`
리포트 수정
```typescript
// Request
interface UpdateReportRequest {
reportNameKor?: string;
reportNameEng?: string;
reportType?: string;
description?: string;
useYn?: string;
}
```
#### DELETE `/api/admin/reports/:reportId`
리포트 삭제
#### POST `/api/admin/reports/:reportId/copy`
리포트 복사
### 6.2 템플릿 API
#### GET `/api/admin/reports/templates`
템플릿 목록 조회
```typescript
// Response
interface GetTemplatesResponse {
system: ReportTemplate[]; // 시스템 템플릿
custom: ReportTemplate[]; // 사용자 정의 템플릿
}
```
#### POST `/api/admin/reports/templates`
템플릿 생성 (사용자 정의)
```typescript
// Request
interface CreateTemplateRequest {
templateNameKor: string;
templateNameEng?: string;
templateType: string;
description?: string;
layoutConfig: any;
defaultQueries?: any;
}
```
#### PUT `/api/admin/reports/templates/:templateId`
템플릿 수정
#### DELETE `/api/admin/reports/templates/:templateId`
템플릿 삭제
### 6.3 레이아웃 API
#### GET `/api/admin/reports/:reportId/layout`
레이아웃 조회
#### PUT `/api/admin/reports/:reportId/layout`
레이아웃 저장
```typescript
// Request
interface SaveLayoutRequest {
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
components: Component[];
}
```
### 6.4 인쇄/내보내기 API
#### POST `/api/admin/reports/:reportId/preview`
미리보기 생성
```typescript
// Request
interface PreviewRequest {
parameters?: { [key: string]: any };
format?: "HTML" | "PDF";
}
// Response
interface PreviewResponse {
html?: string; // HTML 미리보기
pdfUrl?: string; // PDF URL
}
```
#### POST `/api/admin/reports/:reportId/print`
인쇄 (PDF 생성)
```typescript
// Request
interface PrintRequest {
parameters?: { [key: string]: any };
format: "PDF" | "WORD" | "EXCEL";
}
// Response
interface PrintResponse {
fileUrl: string;
fileName: string;
fileSize: number;
}
```
## 7. 프론트엔드 구조
### 7.1 페이지 구조
```
/admin/report
├── ReportListPage.tsx # 리포트 목록 페이지
├── ReportDesignerPage.tsx # 리포트 디자이너 페이지
└── components/
├── ReportList.tsx # 리포트 목록 테이블
├── ReportSearchForm.tsx # 검색 폼
├── TemplateSelector.tsx # 템플릿 선택기
├── ComponentPalette.tsx # 컴포넌트 팔레트
├── Canvas.tsx # 캔버스 영역
├── ComponentRenderer.tsx # 컴포넌트 렌더러
├── PropertyPanel.tsx # 속성 패널
├── QueryManager.tsx # 쿼리 관리
├── QueryCard.tsx # 쿼리 카드
├── ConnectionManager.tsx # 외부 DB 연결 관리
├── PreviewModal.tsx # 미리보기 모달
└── PrintOptionsModal.tsx # 인쇄 옵션 모달
```
### 7.2 상태 관리
```typescript
interface ReportDesignerState {
// 리포트 기본 정보
report: ReportMaster | null;
// 레이아웃
layout: ReportLayout | null;
components: Component[];
selectedComponentId: string | null;
// 쿼리
queries: ReportQuery[];
queryResults: { [queryId: string]: any[] };
// 외부 연결
connections: ReportExternalConnection[];
// UI 상태
isDragging: boolean;
isResizing: boolean;
showPreview: boolean;
showPrintOptions: boolean;
// 히스토리 (Undo/Redo)
history: {
past: Component[][];
present: Component[];
future: Component[][];
};
}
```
## 8. 구현 우선순위
### Phase 1: 기본 기능 (2주)
- [ ] 데이터베이스 테이블 생성
- [ ] 리포트 목록 화면
- [ ] 리포트 CRUD API
- [ ] 템플릿 목록 조회
- [ ] 기본 템플릿 데이터 생성
### Phase 2: 디자이너 기본 (2주)
- [ ] 캔버스 구현
- [ ] 컴포넌트 드래그 앤 드롭
- [ ] 컴포넌트 선택/이동/크기 조절
- [ ] 속성 패널 (기본)
- [ ] 저장/불러오기
### Phase 3: 쿼리 관리 (1주)
- [ ] 쿼리 추가/수정/삭제
- [ ] 파라미터 감지 및 입력
- [ ] 쿼리 실행 (내부 DB)
- [ ] 쿼리 결과를 컴포넌트에 바인딩
### Phase 4: 쿼리 관리 고급 (1주)
- [ ] 쿼리 필드 매핑
- [ ] 컴포넌트와 데이터 바인딩
- [ ] 파라미터 전달 및 처리
### Phase 5: 미리보기/인쇄 (1주)
- [ ] HTML 미리보기
- [ ] PDF 생성
- [ ] WORD 생성
- [ ] 브라우저 인쇄
### Phase 6: 고급 기능 (2주)
- [ ] 템플릿 생성 기능
- [ ] 컴포넌트 추가 (이미지, 서명, 도장)
- [ ] 계산 필드
- [ ] 조건부 표시
- [ ] Undo/Redo
- [ ] 다국어 지원
## 9. 기술 스택
### Backend
- **Node.js + TypeScript**: 백엔드 서버
- **PostgreSQL**: 데이터베이스
- **Prisma**: ORM
- **Puppeteer**: PDF 생성
- **docx**: WORD 생성
### Frontend
- **Next.js + React**: 프론트엔드 프레임워크
- **TypeScript**: 타입 안정성
- **TailwindCSS**: 스타일링
- **react-dnd**: 드래그 앤 드롭
- **react-grid-layout**: 레이아웃 관리
- **react-to-print**: 인쇄 기능
- **react-pdf**: PDF 미리보기
## 10. 보안 고려사항
### 10.1 쿼리 실행 보안
- SELECT 쿼리만 허용 (INSERT, UPDATE, DELETE 금지)
- 쿼리 결과 크기 제한 (최대 1000 rows)
- 실행 시간 제한 (30초)
- SQL 인젝션 방지 (파라미터 바인딩 강제)
- 위험한 함수 차단 (DROP, TRUNCATE 등)
### 10.2 파일 보안
- 생성된 PDF/WORD 파일은 임시 디렉토리에 저장
- 파일은 24시간 후 자동 삭제
- 파일 다운로드 시 토큰 검증
### 10.3 접근 권한
- 리포트 생성/수정/삭제 권한 체크
- 관리자만 템플릿 생성 가능
- 사용자별 리포트 접근 제어
## 11. 성능 최적화
### 11.1 PDF 생성 최적화
- 백그라운드 작업으로 처리
- 생성된 PDF는 CDN에 캐싱
### 11.2 프론트엔드 최적화
- 컴포넌트 가상화 (많은 컴포넌트 처리)
- 디바운싱/쓰로틀링 (드래그 앤 드롭)
- 이미지 레이지 로딩
### 11.3 데이터베이스 최적화
- 레이아웃 데이터는 JSON 형태로 저장
- 리포트 목록 조회 시 인덱스 활용
- 자주 사용하는 템플릿 캐싱
## 12. 테스트 계획
### 12.1 단위 테스트
- API 엔드포인트 테스트
- 쿼리 파싱 테스트
- PDF 생성 테스트
### 12.2 통합 테스트
- 리포트 생성 → 쿼리 실행 → PDF 생성 전체 플로우
- 템플릿 적용 → 데이터 바인딩 테스트
### 12.3 UI 테스트
- 드래그 앤 드롭 동작 테스트
- 컴포넌트 속성 변경 테스트
## 13. 향후 확장 계획
### 13.1 고급 기능
- 차트/그래프 컴포넌트
- 조건부 서식 (색상 변경 등)
- 그룹핑 및 집계 함수
- 마스터-디테일 관계 자동 설정
### 13.2 협업 기능
- 리포트 공유
- 버전 관리
- 댓글 기능
### 13.3 자동화
- 스케줄링 (정기적 리포트 생성)
- 이메일 자동 발송
- 알림 설정
## 14. 참고 자료
### 14.1 유사 솔루션
- Crystal Reports
- JasperReports
- BIRT (Business Intelligence and Reporting Tools)
- FastReport
### 14.2 라이브러리
- [react-grid-layout](https://github.com/react-grid-layout/react-grid-layout)
- [react-dnd](https://react-dnd.github.io/react-dnd/)
- [puppeteer](https://pptr.dev/)
- [docx](https://docx.js.org/)
@@ -0,0 +1,371 @@
# 리포트 문서 번호 자동 채번 시스템 설계
## 1. 개요
리포트 관리 시스템에 체계적인 문서 번호 자동 채번 시스템을 추가하여, 기업 환경에서 문서를 추적하고 관리할 수 있도록 합니다.
## 2. 문서 번호 형식
### 2.1 기본 형식
```
{PREFIX}-{YEAR}-{SEQUENCE}
예: RPT-2024-0001, INV-2024-0123
```
### 2.2 확장 형식 (선택 사항)
```
{PREFIX}-{DEPT_CODE}-{YEAR}-{SEQUENCE}
예: RPT-SALES-2024-0001, INV-FIN-2024-0123
```
### 2.3 구성 요소
- **PREFIX**: 문서 유형 접두사 (예: RPT, INV, PO, QT)
- **DEPT_CODE**: 부서 코드 (선택 사항)
- **YEAR**: 연도 (4자리)
- **SEQUENCE**: 순차 번호 (0001부터 시작, 자릿수 설정 가능)
## 3. 데이터베이스 스키마
### 3.1 문서 번호 규칙 테이블
```sql
-- 문서 번호 규칙 정의
CREATE TABLE report_number_rules (
rule_id SERIAL PRIMARY KEY,
rule_name VARCHAR(100) NOT NULL, -- 규칙 이름
prefix VARCHAR(20) NOT NULL, -- 접두사 (RPT, INV 등)
use_dept_code BOOLEAN DEFAULT FALSE, -- 부서 코드 사용 여부
use_year BOOLEAN DEFAULT TRUE, -- 연도 사용 여부
sequence_length INTEGER DEFAULT 4, -- 순차 번호 자릿수
reset_period VARCHAR(20) DEFAULT 'YEARLY', -- 초기화 주기 (YEARLY, MONTHLY, NEVER)
separator VARCHAR(5) DEFAULT '-', -- 구분자
description TEXT, -- 설명
is_active BOOLEAN DEFAULT TRUE, -- 활성화 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_by VARCHAR(50)
);
-- 기본 데이터 삽입
INSERT INTO report_number_rules (rule_name, prefix, description)
VALUES ('리포트 문서 번호', 'RPT', '일반 리포트 문서 번호 규칙');
```
### 3.2 문서 번호 시퀀스 테이블
```sql
-- 문서 번호 시퀀스 관리 (연도/부서별 현재 번호)
CREATE TABLE report_number_sequences (
sequence_id SERIAL PRIMARY KEY,
rule_id INTEGER NOT NULL REFERENCES report_number_rules(rule_id),
dept_code VARCHAR(20), -- 부서 코드 (NULL 가능)
year INTEGER NOT NULL, -- 연도
current_number INTEGER DEFAULT 0, -- 현재 번호
last_generated_at TIMESTAMP, -- 마지막 생성 시각
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (rule_id, dept_code, year) -- 규칙+부서+연도 조합 유니크
);
```
### 3.3 리포트 테이블 수정
```sql
-- 기존 report_layout 테이블에 컬럼 추가
ALTER TABLE report_layout
ADD COLUMN document_number VARCHAR(100), -- 생성된 문서 번호
ADD COLUMN number_rule_id INTEGER REFERENCES report_number_rules(rule_id), -- 사용된 규칙
ADD COLUMN number_generated_at TIMESTAMP; -- 번호 생성 시각
-- 문서 번호 인덱스 (검색 성능)
CREATE INDEX idx_report_layout_document_number ON report_layout(document_number);
```
### 3.4 문서 번호 이력 테이블 (감사용)
```sql
-- 문서 번호 생성 이력
CREATE TABLE report_number_history (
history_id SERIAL PRIMARY KEY,
report_id INTEGER REFERENCES report_layout(id),
document_number VARCHAR(100) NOT NULL,
rule_id INTEGER REFERENCES report_number_rules(rule_id),
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
generated_by VARCHAR(50),
is_voided BOOLEAN DEFAULT FALSE, -- 번호 무효화 여부
void_reason TEXT, -- 무효화 사유
voided_at TIMESTAMP,
voided_by VARCHAR(50)
);
-- 문서 번호로 검색 인덱스
CREATE INDEX idx_report_number_history_doc_number ON report_number_history(document_number);
```
## 4. 백엔드 구현
### 4.1 서비스 레이어 (`reportNumberService.ts`)
```typescript
export class ReportNumberService {
// 문서 번호 생성
static async generateNumber(
ruleId: number,
deptCode?: string
): Promise<string>;
// 문서 번호 형식 검증
static async validateNumber(documentNumber: string): Promise<boolean>;
// 문서 번호 중복 체크
static async isDuplicate(documentNumber: string): Promise<boolean>;
// 문서 번호 무효화
static async voidNumber(
documentNumber: string,
reason: string,
userId: string
): Promise<void>;
// 특정 규칙의 다음 번호 미리보기
static async previewNextNumber(
ruleId: number,
deptCode?: string
): Promise<string>;
}
```
### 4.2 컨트롤러 (`reportNumberController.ts`)
```typescript
// GET /api/report/number-rules - 규칙 목록
// GET /api/report/number-rules/:id - 규칙 상세
// POST /api/report/number-rules - 규칙 생성
// PUT /api/report/number-rules/:id - 규칙 수정
// DELETE /api/report/number-rules/:id - 규칙 삭제
// POST /api/report/:reportId/generate-number - 문서 번호 생성
// POST /api/report/number/preview - 다음 번호 미리보기
// POST /api/report/number/void - 문서 번호 무효화
// GET /api/report/number/history/:documentNumber - 문서 번호 이력
```
### 4.3 핵심 로직 (번호 생성)
```typescript
async generateNumber(ruleId: number, deptCode?: string): Promise<string> {
// 1. 트랜잭션 시작
const client = await pool.connect();
try {
await client.query('BEGIN');
// 2. 규칙 조회
const rule = await this.getRule(ruleId);
// 3. 현재 연도/월
const now = new Date();
const year = now.getFullYear();
// 4. 시퀀스 조회 또는 생성 (FOR UPDATE로 락)
let sequence = await this.getSequence(ruleId, deptCode, year, true);
if (!sequence) {
sequence = await this.createSequence(ruleId, deptCode, year);
}
// 5. 다음 번호 계산
const nextNumber = sequence.current_number + 1;
// 6. 문서 번호 생성
const documentNumber = this.formatNumber(rule, deptCode, year, nextNumber);
// 7. 시퀀스 업데이트
await this.updateSequence(sequence.sequence_id, nextNumber);
// 8. 커밋
await client.query('COMMIT');
return documentNumber;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// 번호 포맷팅
private formatNumber(
rule: NumberRule,
deptCode: string | undefined,
year: number,
sequence: number
): string {
const parts = [rule.prefix];
if (rule.use_dept_code && deptCode) {
parts.push(deptCode);
}
if (rule.use_year) {
parts.push(year.toString());
}
// 0 패딩
const paddedSequence = sequence.toString().padStart(rule.sequence_length, '0');
parts.push(paddedSequence);
return parts.join(rule.separator);
}
```
## 5. 프론트엔드 구현
### 5.1 문서 번호 규칙 관리 화면
**경로**: `/admin/report/number-rules`
**기능**:
- 규칙 목록 조회
- 규칙 생성/수정/삭제
- 규칙 미리보기 (다음 번호 확인)
- 규칙 활성화/비활성화
### 5.2 리포트 목록 화면 수정
**변경 사항**:
- 문서 번호 컬럼 추가
- 문서 번호로 검색 기능
### 5.3 리포트 저장 시 번호 생성
**위치**: `ReportDesignerContext.tsx` - `saveLayout` 함수
```typescript
const saveLayout = async () => {
// 1. 새 리포트인 경우 문서 번호 자동 생성
if (reportId === "new" && !documentNumber) {
const response = await fetch(`/api/report/generate-number`, {
method: "POST",
body: JSON.stringify({ ruleId: 1 }), // 기본 규칙
});
const { documentNumber: newNumber } = await response.json();
setDocumentNumber(newNumber);
}
// 2. 리포트 저장 (문서 번호 포함)
await saveReport({ ...reportData, documentNumber });
};
```
### 5.4 문서 번호 표시 UI
**위치**: 디자이너 헤더
```tsx
<div className="document-number">
<Label>문서 번호</Label>
<Badge variant="outline">{documentNumber || "저장 시 자동 생성"}</Badge>
</div>
```
## 6. 동시성 제어
### 6.1 문제점
여러 사용자가 동시에 문서 번호를 생성할 때 중복 발생 가능성
### 6.2 해결 방법
**PostgreSQL의 `FOR UPDATE` 사용**
```sql
-- 시퀀스 조회 시 행 락 걸기
SELECT * FROM report_number_sequences
WHERE rule_id = $1 AND year = $2
FOR UPDATE;
```
**트랜잭션 격리 수준**
```typescript
await client.query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
```
## 7. 테스트 시나리오
### 7.1 기본 기능 테스트
- [ ] 규칙 생성 → 문서 번호 생성 → 포맷 확인
- [ ] 연속 생성 시 순차 번호 증가 확인
- [ ] 연도 변경 시 시퀀스 초기화 확인
### 7.2 동시성 테스트
- [ ] 10명이 동시에 문서 번호 생성 → 중복 없음 확인
- [ ] 동일 규칙으로 100개 생성 → 순차 번호 연속성 확인
### 7.3 에러 처리
- [ ] 존재하지 않는 규칙 ID → 에러 메시지
- [ ] 비활성화된 규칙 사용 → 경고 메시지
- [ ] 시퀀스 최대값 초과 → 관리자 알림
## 8. 구현 순서
### Phase 1: 데이터베이스 (1단계)
1. 테이블 생성 SQL 작성
2. 마이그레이션 실행
3. 기본 데이터 삽입
### Phase 2: 백엔드 (2단계)
1. `reportNumberService.ts` 구현
2. `reportNumberController.ts` 구현
3. 라우트 추가
4. 단위 테스트
### Phase 3: 프론트엔드 (3단계)
1. 문서 번호 규칙 관리 화면
2. 리포트 목록 화면 수정
3. 디자이너 문서 번호 표시
4. 저장 시 자동 생성 연동
### Phase 4: 테스트 및 최적화 (4단계)
1. 통합 테스트
2. 동시성 테스트
3. 성능 최적화
4. 사용자 가이드 작성
## 9. 향후 확장
### 9.1 고급 기능
- 문서 번호 예약 기능
- 번호 건너뛰기 허용 설정
- 커스텀 포맷 지원 (정규식 기반)
- 연/월/일 단위 초기화 선택
### 9.2 통합
- 승인 완료 시점에 최종 번호 확정
- 외부 시스템과 번호 동기화
- 바코드/QR 코드 자동 생성
## 10. 보안 고려사항
- 문서 번호 생성 권한 제한
- 번호 무효화 감사 로그
- 시퀀스 직접 수정 방지
- API 호출 횟수 제한 (Rate Limiting)
@@ -0,0 +1,388 @@
# 리포트 페이지 관리 시스템 설계
## 1. 개요
리포트 디자이너에 다중 페이지 관리 기능을 추가하여 여러 페이지에 걸친 복잡한 문서를 작성할 수 있도록 합니다.
## 2. 주요 기능
### 2.1 페이지 관리
- 페이지 추가/삭제
- 페이지 복사
- 페이지 순서 변경 (드래그 앤 드롭)
- 페이지 이름 지정
### 2.2 페이지 네비게이션
- 좌측 페이지 썸네일 패널
- 페이지 간 전환 (클릭)
- 이전/다음 페이지 이동
- 페이지 번호 표시
### 2.3 페이지별 설정
- 페이지 크기 (A4, A3, Letter, 사용자 정의)
- 페이지 방향 (세로/가로)
- 여백 설정
- 배경색
### 2.4 컴포넌트 관리
- 컴포넌트는 특정 페이지에 속함
- 페이지 간 컴포넌트 복사/이동
- 현재 페이지의 컴포넌트만 표시
## 3. 데이터베이스 스키마
### 3.1 기존 구조 활용 (변경 없음)
**report_layout 테이블의 layout_config (JSONB) 활용**
기존:
```json
{
"width": 210,
"height": 297,
"orientation": "portrait",
"components": [...]
}
```
변경 후:
```json
{
"pages": [
{
"page_id": "page-uuid-1",
"page_name": "표지",
"page_order": 0,
"width": 210,
"height": 297,
"orientation": "portrait",
"margins": {
"top": 20,
"bottom": 20,
"left": 20,
"right": 20
},
"background_color": "#ffffff",
"components": [
{
"id": "comp-1",
"type": "text",
"x": 100,
"y": 50,
...
}
]
},
{
"page_id": "page-uuid-2",
"page_name": "본문",
"page_order": 1,
"width": 210,
"height": 297,
"orientation": "portrait",
"margins": { "top": 20, "bottom": 20, "left": 20, "right": 20 },
"background_color": "#ffffff",
"components": [...]
}
]
}
```
### 3.2 마이그레이션 전략
기존 단일 페이지 리포트 자동 변환:
```typescript
// 기존 구조 감지 시
if (layoutConfig.components && !layoutConfig.pages) {
// 자동으로 pages 구조로 변환
layoutConfig = {
pages: [
{
page_id: uuidv4(),
page_name: "페이지 1",
page_order: 0,
width: layoutConfig.width || 210,
height: layoutConfig.height || 297,
orientation: layoutConfig.orientation || "portrait",
margins: { top: 20, bottom: 20, left: 20, right: 20 },
background_color: "#ffffff",
components: layoutConfig.components,
},
],
};
}
```
## 4. 프론트엔드 구조
### 4.1 타입 정의 (types/report.ts)
```typescript
export interface ReportPage {
page_id: string;
report_id: string;
page_order: number;
page_name: string;
// 페이지 설정
width: number;
height: number;
orientation: 'portrait' | 'landscape';
// 여백
margin_top: number;
margin_bottom: number;
margin_left: number;
margin_right: number;
// 배경
background_color: string;
created_at?: string;
updated_at?: string;
}
export interface ComponentConfig {
id: string;
// page_id 불필요 (페이지의 components 배열에 포함됨)
type: 'text' | 'label' | 'image' | 'table' | ...;
x: number;
y: number;
width: number;
height: number;
// ... 기타 속성
}
export interface ReportLayoutConfig {
pages: ReportPage[];
}
```
### 4.2 Context 구조 변경
```typescript
interface ReportDesignerContextType {
// 페이지 관리
pages: ReportPage[];
currentPageId: string | null;
currentPage: ReportPage | null;
addPage: () => void;
deletePage: (pageId: string) => void;
duplicatePage: (pageId: string) => void;
reorderPages: (sourceIndex: number, targetIndex: number) => void;
selectPage: (pageId: string) => void;
updatePage: (pageId: string, updates: Partial<ReportPage>) => void;
// 컴포넌트 (현재 페이지만)
currentPageComponents: ComponentConfig[];
// ... 기존 기능들
}
```
### 4.3 UI 구조
```
┌─────────────────────────────────────────────────────────────┐
│ ReportDesignerToolbar (저장, 미리보기, 페이지 추가 등) │
├──────────┬────────────────────────────────────┬─────────────┤
│ │ │ │
│ PageList │ ReportDesignerCanvas │ Right │
│ (좌측) │ (현재 페이지만 표시) │ Panel │
│ │ │ (속성) │
│ - Page 1 │ ┌──────────────────────────┐ │ │
│ - Page 2 │ │ │ │ │
│ * Page 3 │ │ [컴포넌트들] │ │ │
│ (현재) │ │ │ │ │
│ │ └──────────────────────────┘ │ │
│ [+ 추가] │ │ │
│ │ 이전 | 다음 (페이지 네비게이션) │ │
└──────────┴────────────────────────────────────┴─────────────┘
```
## 5. 컴포넌트 구조
### 5.1 새 컴포넌트
#### PageListPanel.tsx
```typescript
- 좌측 페이지 목록 패널
- 페이지 썸네일 표시
- 드래그 앤 드롭으로 순서 변경
- 페이지 추가/삭제/복사 버튼
- 현재 페이지 하이라이트
```
#### PageNavigator.tsx
```typescript
- 캔버스 하단의 페이지 네비게이션
- 이전/다음 버튼
- 현재 페이지 번호 표시
- 페이지 점프 (1/5 형식)
```
#### PageSettingsPanel.tsx
```typescript
- 우측 패널 내 페이지 설정 섹션
- 페이지 크기, 방향
- 여백 설정
- 배경색
```
### 5.2 수정할 컴포넌트
#### ReportDesignerContext.tsx
- pages 상태 추가
- currentPageId 상태 추가
- 페이지 관리 함수들 추가
- components를 currentPageComponents로 필터링
#### ReportDesignerCanvas.tsx
- currentPageComponents만 렌더링
- 캔버스 크기를 currentPage 기준으로 설정
- 컴포넌트 추가 시 page_id 포함
#### ReportDesignerToolbar.tsx
- "페이지 추가" 버튼 추가
- 저장 시 pages도 함께 저장
#### ReportPreviewModal.tsx
- 모든 페이지 순서대로 미리보기
- 페이지 구분선 표시
- PDF 저장 시 모든 페이지 포함
## 6. API 엔드포인트
### 6.1 페이지 관리
```typescript
// 페이지 목록 조회
GET /api/report/:reportId/pages
Response: { pages: ReportPage[] }
// 페이지 생성
POST /api/report/:reportId/pages
Body: { page_name, width, height, orientation, margins }
Response: { page: ReportPage }
// 페이지 수정
PUT /api/report/pages/:pageId
Body: Partial<ReportPage>
Response: { page: ReportPage }
// 페이지 삭제
DELETE /api/report/pages/:pageId
Response: { success: boolean }
// 페이지 순서 변경
PUT /api/report/:reportId/pages/reorder
Body: { pageOrders: Array<{ page_id, page_order }> }
Response: { success: boolean }
// 페이지 복사
POST /api/report/pages/:pageId/duplicate
Response: { page: ReportPage }
```
### 6.2 레이아웃 (기존 수정)
```typescript
// 레이아웃 저장 (페이지별)
PUT /api/report/:reportId/layout
Body: {
pages: ReportPage[],
components: ComponentConfig[] // page_id 포함
}
```
## 7. 구현 단계
### Phase 1: DB 및 백엔드 (0.5일)
1. ✅ DB 스키마 생성
2. ✅ API 엔드포인트 구현
3. ✅ 기존 리포트 마이그레이션 (단일 페이지 생성)
### Phase 2: 타입 및 Context (0.5일)
1. ✅ 타입 정의 업데이트
2. ✅ Context에 페이지 상태/함수 추가
3. ✅ API 연동
### Phase 3: UI 컴포넌트 (1일)
1. ✅ PageListPanel 구현
2. ✅ PageNavigator 구현
3. ✅ PageSettingsPanel 구현
### Phase 4: 통합 및 수정 (1일)
1. ✅ Canvas에서 현재 페이지만 표시
2. ✅ 컴포넌트 추가/수정 시 page_id 처리
3. ✅ 미리보기에서 모든 페이지 표시
4. ✅ PDF/WORD 저장에서 모든 페이지 처리
### Phase 5: 테스트 및 최적화 (0.5일)
1. ✅ 페이지 전환 성능 확인
2. ✅ 썸네일 렌더링 최적화
3. ✅ 버그 수정
**총 예상 기간: 3-4일**
## 8. 주의사항
### 8.1 성능 최적화
- 페이지 썸네일은 저해상도로 렌더링
- 현재 페이지 컴포넌트만 DOM에 유지
- 페이지 전환 시 애니메이션 최소화
### 8.2 호환성
- 기존 리포트는 자동으로 단일 페이지로 마이그레이션
- 템플릿도 페이지 구조 포함
### 8.3 사용자 경험
- 페이지 삭제 시 확인 다이얼로그
- 컴포넌트가 있는 페이지 삭제 시 경고
- 페이지 순서 변경 시 즉시 반영
## 9. 추후 확장 기능
### 9.1 페이지 템플릿
- 자주 사용하는 페이지 레이아웃 저장
- 페이지 추가 시 템플릿 선택
### 9.2 마스터 페이지
- 모든 페이지에 공통으로 적용되는 헤더/푸터
- 페이지 번호 자동 삽입
### 9.3 페이지 연결
- 테이블 데이터가 여러 페이지에 자동 분할
- 페이지 오버플로우 처리
## 10. 참고 자료
- 오즈리포트 메뉴얼
- Crystal Reports 페이지 관리
- Adobe InDesign 페이지 시스템
+155
View File
@@ -0,0 +1,155 @@
# WACE 반응형 컴포넌트 전략
## 개요
WACE 프로젝트의 모든 반응형 UI는 **3개의 레이아웃 프리미티브 + 1개의 훅**으로 통일한다.
컴포넌트마다 새로 타입을 정의하거나 리사이저를 구현하지 않는다.
## 아키텍처
```
┌─────────────────────────────────────────────────┐
│ useResponsive() 훅 │
│ isMobile | isTablet | isDesktop | width │
└──────────┬──────────┬──────────┬────────────────┘
│ │ │
┌───────▼──┐ ┌────▼─────┐ ┌─▼──────────────┐
│ 데이터 │ │ 좌우분할 │ │ 캔버스(디자이너)│
│ 목록 │ │ 패널 │ │ 화면 │
└──────────┘ └──────────┘ └────────────────┘
ResponsiveDataView ResponsiveSplitPanel ResponsiveGridRenderer
```
## 1. useResponsive (훅)
**위치**: `frontend/lib/hooks/useResponsive.ts`
모든 반응형 판단의 기반. 직접 breakpoint 분기가 필요할 때만 사용.
가능하면 아래 레이아웃 컴포넌트를 쓰고, 훅 직접 사용은 최소화.
| 반환값 | 브레이크포인트 | 해상도 |
|--------|---------------|--------|
| isMobile | xs, sm | < 768px |
| isTablet | md | 768 ~ 1023px |
| isDesktop | lg, xl, 2xl | >= 1024px |
## 2. ResponsiveDataView (데이터 목록)
**위치**: `frontend/components/common/ResponsiveDataView.tsx`
**패턴**: 데스크톱 = 테이블, 모바일 = 카드 리스트
**적용 대상**: 모든 목록/리스트 화면
```tsx
<ResponsiveDataView<User>
data={users}
columns={columns}
keyExtractor={(u) => u.id}
cardTitle={(u) => u.name}
cardFields={[
{ label: "이메일", render: (u) => u.email },
{ label: "부서", render: (u) => u.dept },
]}
renderActions={(u) => <Button>편집</Button>}
/>
```
**적용 완료 (12개 화면)**:
- UserTable, CompanyTable, UserAuthTable
- DataFlowList, ScreenList
- system-notices, approvalTemplate, standards
- batch-management, mail/receive, flowMgmtList
- exconList, exCallConfList
## 3. ResponsiveSplitPanel (좌우 분할)
**위치**: `frontend/components/common/ResponsiveSplitPanel.tsx`
**패턴**: 데스크톱 = 좌우 분할(리사이저 포함), 모바일 = 세로 스택(접기/펼치기)
**적용 대상**: 카테고리관리, 메뉴관리, 부서관리, BOM 등 좌우 분할 레이아웃
```tsx
<ResponsiveSplitPanel
left={<TreeView />}
right={<DetailPanel />}
leftTitle="카테고리"
leftWidth={25}
minLeftWidth={10}
maxLeftWidth={40}
height="calc(100vh - 120px)"
/>
```
**Props**:
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| left | ReactNode | 필수 | 좌측 패널 콘텐츠 |
| right | ReactNode | 필수 | 우측 패널 콘텐츠 |
| leftTitle | string | "목록" | 모바일 접기 헤더 |
| leftWidth | number | 25 | 초기 좌측 너비(%) |
| minLeftWidth | number | 10 | 최소 좌측 너비(%) |
| maxLeftWidth | number | 50 | 최대 좌측 너비(%) |
| showResizer | boolean | true | 리사이저 표시 |
| collapsedOnMobile | boolean | true | 모바일 기본 접힘 |
| height | string | "100%" | 컨테이너 높이 |
**동작**:
- 데스크톱(>= 1024px): 좌우 분할 + 드래그 리사이저 + 좌측 접기 버튼
- 모바일(< 1024px): 세로 스택, 좌측 패널 40vh 제한, 접기/펼치기
**마이그레이션 후보**:
- `V2CategoryManagerComponent` (완료)
- `SplitPanelLayoutComponent` (v1, v2)
- `BomTreeComponent`
- `ScreenSplitPanel`
- menu/page.tsx (메뉴 관리)
- departments/page.tsx (부서 관리)
## 4. ResponsiveGridRenderer (디자이너 캔버스)
**위치**: `frontend/components/screen/ResponsiveGridRenderer.tsx`
**패턴**: 데스크톱(비전폭 컴포넌트) = 캔버스 스케일링, 그 외 = Flex 그리드
**적용 대상**: 화면 디자이너로 만든 동적 화면
이 컴포넌트는 화면 디자이너 시스템 전용. 일반 개발에서 직접 사용하지 않음.
## 사용 가이드
### 새 화면 만들 때
| 화면 유형 | 사용 컴포넌트 |
|-----------|--------------|
| 데이터 목록 (테이블) | `ResponsiveDataView` |
| 좌우 분할 (트리+상세) | `ResponsiveSplitPanel` |
| 디자이너 화면 | `ResponsiveGridRenderer` (자동) |
| 단순 레이아웃 | Tailwind 반응형 (`flex-col lg:flex-row`) |
### 금지 사항
1. 컴포넌트 내부에 `isDraggingRef`, `handleMouseDown/Move/Up` 직접 구현 금지
-> `ResponsiveSplitPanel` 사용
2. `hidden lg:block` / `lg:hidden` 패턴으로 테이블/카드 이중 렌더링 금지
-> `ResponsiveDataView` 사용
3. `window.innerWidth` 직접 체크 금지
-> `useResponsive()` 훅 사용
4. 반응형 분기를 위한 새로운 타입/인터페이스 정의 금지
-> 기존 프리미티브의 Props 사용
### 폐기 예정 컴포넌트
| 컴포넌트 | 대체 | 상태 |
|----------|------|------|
| `ResponsiveContainer` | Tailwind 또는 `useResponsive` | 미사용, 삭제 예정 |
| `ResponsiveGrid` | Tailwind `grid-cols-*` | 미사용, 삭제 예정 |
| `ResponsiveText` | Tailwind `text-sm lg:text-lg` | 미사용, 삭제 예정 |
## 파일 구조
```
frontend/
├── lib/hooks/
│ └── useResponsive.ts # 브레이크포인트 훅 (기반)
├── components/common/
│ ├── ResponsiveDataView.tsx # 테이블/카드 전환
│ └── ResponsiveSplitPanel.tsx # 좌우 분할 반응형
└── components/screen/
└── ResponsiveGridRenderer.tsx # 디자이너 캔버스 렌더러
```
@@ -0,0 +1,199 @@
# [계획서] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정
> 관련 문서: [맥락노트](./CCA[맥락]-카테고리-연속등록모드.md) | [체크리스트](./CCA[체크]-카테고리-연속등록모드.md)
## 개요
기준정보 - 옵션설정 화면에서 트리 구조 카테고리(예: 품목정보 > 재고단위)의 "대분류 추가" 모달이 저장 후 닫히지 않는 버그를 수정합니다.
평면 목록용 추가 모달(`CategoryValueAddDialog.tsx`)과 동일한 연속 입력 패턴을 적용합니다.
---
## 현재 동작
- 대분류 추가 모달에서 값 입력 후 "추가" 클릭 시 **값은 정상 저장됨**
- 저장 후 **모달이 닫히지 않고** 폼만 초기화됨 (항상 연속 입력 상태)
- "연속 입력" 체크박스 UI가 **없음** → 사용자가 모드를 끌 수 없음
- 모달을 닫으려면 "닫기" 버튼 또는 외부 클릭을 해야 함
### 현재 코드 (CategoryValueManagerTree.tsx - handleAdd, 512~530행)
```tsx
if (response.success) {
toast.success("카테고리가 추가되었습니다");
// 폼 초기화 (모달은 닫지 않고 연속 입력)
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
await loadTree(true);
if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
}
}
```
### 현재 DialogFooter (809~821행)
```tsx
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsAddModalOpen(false)} ...>
닫기
</Button>
<Button onClick={handleAdd} ...>
추가
</Button>
</DialogFooter>
```
---
## 변경 후 동작
### 1. 기본 동작: 저장 후 모달 닫힘
- "추가" 클릭 → 저장 성공 → 모달 닫힘 + 트리 새로고침
- `CategoryValueAddDialog.tsx`(평면 목록 추가 모달)와 동일한 기본 동작
### 2. 연속 입력 체크박스 추가
- DialogFooter 좌측에 "연속 입력" 체크박스 표시
- 기본값: 체크 해제 (OFF)
- 체크 시: 저장 후 폼만 초기화, 모달 유지, 이름 필드에 포커스
- 체크 해제 시: 저장 후 모달 닫힘
---
## 시각적 예시
| 상태 | 연속 입력 체크 | 추가 버튼 클릭 후 |
|------|---------------|-----------------|
| 기본 (체크 해제) | [ ] 연속 입력 | 저장 → 모달 닫힘 → 트리 갱신 |
| 연속 모드 (체크) | [x] 연속 입력 | 저장 → 폼 초기화 → 모달 유지 → 이름 필드 포커스 |
### 모달 하단 레이아웃 (ScreenModal.tsx 패턴)
```
┌─────────────────────────────────────────┐
│ [닫기] [추가] │ ← DialogFooter (버튼만)
├─────────────────────────────────────────┤
│ [x] 저장 후 계속 입력 (연속 등록 모드) │ ← border-t 구분선 아래 별도 영역
└─────────────────────────────────────────┘
```
---
## 아키텍처
```mermaid
flowchart TD
A["사용자: '추가' 클릭"] --> B["handleAdd()"]
B --> C{"API 호출 성공?"}
C -- 실패 --> D["toast.error → 모달 유지"]
C -- 성공 --> E["toast.success + loadTree"]
E --> F{"continuousAdd?"}
F -- true --> G["폼 초기화 + 이름 필드 포커스\n모달 유지"]
F -- false --> H["폼 초기화 + 모달 닫힘"]
```
---
## 변경 대상 파일
| 파일 | 역할 | 변경 내용 |
|------|------|----------|
| `frontend/components/table-category/CategoryValueManagerTree.tsx` | 트리형 카테고리 값 관리 | 상태 추가, handleAdd 분기, DialogFooter UI |
- **변경 규모**: 약 20줄 내외 소규모 변경
- **참고 파일**: `frontend/components/table-category/CategoryValueAddDialog.tsx` (동일 패턴)
---
## 코드 설계
### 1. 상태 추가 (286행 근처, 모달 상태 선언부)
```tsx
const [continuousAdd, setContinuousAdd] = useState(false);
```
### 2. handleAdd 성공 분기 수정 (512~530행 대체)
```tsx
if (response.success) {
toast.success("카테고리가 추가되었습니다");
await loadTree(true);
if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
}
if (continuousAdd) {
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
} else {
setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true });
setIsAddModalOpen(false);
}
}
```
### 3. DialogFooter + 연속 등록 체크박스 수정 (809~821행 대체)
DialogFooter는 버튼만 유지하고, 그 아래에 `border-t` 구분선과 체크박스를 별도 영역으로 배치합니다.
`ScreenModal.tsx` (1287~1303행) 패턴 그대로입니다.
```tsx
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsAddModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none"
>
닫기
</Button>
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
추가
</Button>
</DialogFooter>
{/* 연속 등록 모드 체크박스 - ScreenModal.tsx 패턴 */}
<div className="border-t px-4 py-3">
<div className="flex items-center gap-2">
<Checkbox
id="tree-continuous-add"
checked={continuousAdd}
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
/>
<Label htmlFor="tree-continuous-add" className="cursor-pointer text-sm font-normal select-none">
저장 후 계속 입력 (연속 등록 모드)
</Label>
</div>
</div>
```
---
## 예상 문제 및 대응
`CategoryValueAddDialog.tsx`와 동일한 패턴이므로 별도 예상 문제 없음.
---
## 설계 원칙
- `CategoryValueAddDialog.tsx`(같은 폴더, 같은 목적)의 패턴을 그대로 따름
- 기존 수정/삭제 모달 동작은 변경하지 않음
- 하위 추가(중분류/소분류) 모달도 동일한 `handleAdd`를 사용하므로 자동 적용
- `Checkbox` import는 이미 존재 (24행)하므로 추가 import 불필요
- `Label` import는 이미 존재 (53행)하므로 추가 import 불필요
- 체크박스 위치/라벨/className 모두 `ScreenModal.tsx` (1287~1303행)과 동일
@@ -0,0 +1,84 @@
# [맥락노트] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정
> 관련 문서: [계획서](./CCA[계획]-카테고리-연속등록모드.md) | [체크리스트](./CCA[체크]-카테고리-연속등록모드.md)
---
## 왜 이 작업을 하는가
- 기준정보 - 옵션설정에서 트리 구조 카테고리(품목정보 > 재고단위 등)의 "대분류 추가" 모달이 저장 후 닫히지 않음
- 연속 등록 모드가 하드코딩되어 항상 ON 상태이고, 끌 수 있는 UI가 없음
- 같은 폴더의 평면 목록 모달(`CategoryValueAddDialog.tsx`)은 이미 올바르게 구현되어 있음
- 동일 패턴을 적용하여 일관성 확보
---
## 핵심 결정 사항과 근거
### 1. 기본값: 연속 등록 OFF (모달 닫힘)
- **결정**: `continuousAdd` 초기값을 `false`로 설정
- **근거**: 대부분의 사용자는 한 건 추가 후 결과를 확인하려 함. 연속 입력은 선택적 기능
### 2. 체크박스 위치: DialogFooter 아래, border-t 구분선 별도 영역
- **결정**: `ScreenModal.tsx` (1287~1303행) 패턴 그대로 적용
- **근거**: "기준정보 - 부서관리" 추가 모달과 동일한 디자인. 프로젝트 관행 준수
- **대안 검토**: `CategoryValueAddDialog.tsx`는 DialogFooter 안에 체크박스 배치 → 부서 모달과 다른 디자인이므로 기각
### 3. 라벨: "저장 후 계속 입력 (연속 등록 모드)"
- **결정**: `ScreenModal.tsx`와 동일한 라벨 텍스트 사용
- **근거**: 부서 추가 모달과 동일한 문구로 사용자 혼란 방지
### 4. localStorage 미사용
- **결정**: 컴포넌트 state만 사용, localStorage 영속화 안 함
- **근거**: `CategoryValueAddDialog.tsx`(같은 폴더 형제 컴포넌트)가 localStorage를 쓰지 않음. `ScreenModal.tsx`는 사용하지만 동적 화면 모달 전용 기능이므로 범위가 다름
### 5. 수정 대상: handleAdd 함수만
- **결정**: 저장 성공 분기에서만 `continuousAdd` 체크
- **근거**: 실패 시에는 원래대로 모달 유지 + 에러 표시. 분기가 필요한 건 성공 시뿐
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 수정 대상 | `frontend/components/table-category/CategoryValueManagerTree.tsx` | 트리형 카테고리 값 관리 (대분류/중분류/소분류) |
| 참고 패턴 (로직) | `frontend/components/table-category/CategoryValueAddDialog.tsx` | 평면 목록 추가 모달 - continuousAdd 분기 로직 |
| 참고 패턴 (UI) | `frontend/components/common/ScreenModal.tsx` | 동적 화면 모달 - 체크박스 위치/라벨/스타일 |
---
## 기술 참고
### 현재 handleAdd 흐름
```
handleAdd() → API 호출 → 성공 시:
1. toast.success
2. 폼 초기화 (모달 유지 - 하드코딩)
3. addNameRef 포커스
4. loadTree(true) - 펼침 상태 유지
5. parentValue 있으면 해당 노드 펼침
```
### 변경 후 handleAdd 흐름
```
handleAdd() → API 호출 → 성공 시:
1. toast.success
2. loadTree(true) + parentValue 펼침
3. continuousAdd 체크:
- true: 폼 초기화 + addNameRef 포커스 (모달 유지)
- false: 폼 초기화 + setIsAddModalOpen(false) (모달 닫힘)
```
### import 현황
- `Checkbox`: 24행에서 이미 import (`@/components/ui/checkbox`)
- `Label`: 53행에서 이미 import (`@/components/ui/label`)
- 추가 import 불필요
@@ -0,0 +1,52 @@
# [체크리스트] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정
> 관련 문서: [계획서](./CCA[계획]-카테고리-연속등록모드.md) | [맥락노트](./CCA[맥락]-카테고리-연속등록모드.md)
---
## 공정 상태
- 전체 진행률: **100%** (구현 완료)
- 현재 단계: 완료
---
## 구현 체크리스트
### 1단계: 상태 추가
- [x] `CategoryValueManagerTree.tsx` 모달 상태 선언부(286행 근처)에 `continuousAdd` 상태 추가
### 2단계: handleAdd 분기 수정
- [x] `handleAdd` 성공 분기(512~530행)에서 `continuousAdd` 체크 분기 추가
- [x] `continuousAdd === true`: 폼 초기화 + addNameRef 포커스 (모달 유지)
- [x] `continuousAdd === false`: 폼 초기화 + `setIsAddModalOpen(false)` (모달 닫힘)
### 3단계: DialogFooter UI 수정
- [x] DialogFooter(809~821행)는 버튼만 유지
- [x] DialogFooter 아래에 `border-t px-4 py-3` 영역 추가
- [x] "저장 후 계속 입력 (연속 등록 모드)" 체크박스 배치
- [x] ScreenModal.tsx (1287~1303행) 패턴과 동일한 className/라벨 사용
### 4단계: 검증
- [ ] 대분류 추가: 체크 해제 상태에서 추가 → 모달 닫힘 확인
- [ ] 대분류 추가: 체크 상태에서 추가 → 모달 유지 + 폼 초기화 + 포커스 확인
- [ ] 하위 추가(중분류/소분류): 동일하게 동작하는지 확인
- [ ] 수정/삭제 모달: 기존 동작 변화 없음 확인
### 5단계: 정리
- [x] 린트 에러 없음 확인
- [x] 이 체크리스트 완료 표시 업데이트
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-11 | 구현 완료 (1~3단계, 5단계 정리). 4단계 검증은 수동 테스트 필요 |
@@ -0,0 +1,122 @@
# [계획서] 카테고리 드롭다운 - 3단계 깊이 구분 표시
> 관련 문서: [맥락노트](./CTI[맥락]-카테고리-깊이구분.md) | [체크리스트](./CTI[체크]-카테고리-깊이구분.md)
>
> 상태: **완료** (2026-03-11)
## 개요
카테고리 타입(`source="category"`) 드롭다운에서 3단계 계층(대분류 > 중분류 > 소분류)의 들여쓰기가 시각적으로 구분되지 않는 문제를 수정합니다.
---
## 변경 전 동작
- `category_values` 테이블은 `parent_value_id`, `depth` 컬럼으로 3단계 계층 구조를 지원
- 백엔드 `buildHierarchy()`가 트리 구조를 정상적으로 반환
- 프론트엔드 `flattenTree()`가 트리를 평탄화하면서 **일반 ASCII 공백(`" "`)** 으로 들여쓰기 생성
- HTML이 연속 공백을 하나로 축소(collapse)하여 depth 1과 depth 2가 동일하게 렌더링됨
### 변경 전 코드 (flattenTree)
```tsx
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
```
### 변경 전 렌더링 결과
```
신예철
└ 신2
└ 신22 ← depth 2인데 depth 1과 구분 불가
└ 신3
└ 신4
```
---
## 변경 후 동작
### 일반 공백을 Non-Breaking Space(`\u00A0`)로 교체
- `\u00A0`는 HTML에서 축소되지 않으므로 depth별 들여쓰기가 정확히 유지됨
- depth당 3칸(`\u00A0\u00A0\u00A0`)으로 시각적 계층 구분을 명확히 함
- 백엔드 변경 없음 (트리 구조는 이미 정상)
### 변경 후 코드 (flattenTree)
```tsx
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
```
---
## 시각적 예시
| depth | prefix | 드롭다운 표시 |
|-------|--------|-------------|
| 0 (대분류) | `""` | `신예철` |
| 1 (중분류) | `"\u00A0\u00A0\u00A0└ "` | `···└ 신2` |
| 2 (소분류) | `"\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0└ "` | `······└ 신22` |
### 변경 전후 비교
```
변경 전: 변경 후:
신예철 신예철
└ 신2 └ 신2
└ 신22 ← 구분 불가 └ 신22 ← 명확히 구분
└ 신3 └ 신3
└ 신4 └ 신4
```
---
## 아키텍처
```mermaid
flowchart TD
A[category_values 테이블] -->|parent_value_id, depth| B[백엔드 buildHierarchy]
B -->|트리 JSON 응답| C[프론트엔드 API 호출]
C --> D[flattenTree 함수]
D -->|"depth별 \u00A0 prefix 생성"| E[SelectOption 배열]
E --> F{렌더링 모드}
F -->|비검색| G[SelectItem - label 표시]
F -->|검색| H[CommandItem - displayLabel 표시]
style D fill:#f96,stroke:#333,color:#000
```
**변경 지점**: `flattenTree` 함수 내 prefix 생성 로직 (주황색 표시)
---
## 변경 대상 파일
| 파일 경로 | 변경 내용 | 변경 규모 |
|-----------|----------|----------|
| `frontend/components/v2/V2Select.tsx` (904행) | `flattenTree` prefix를 `\u00A0` 기반으로 변경 | 1줄 |
| `frontend/components/unified/UnifiedSelect.tsx` (632행) | 동일한 `flattenTree` prefix 변경 | 1줄 |
---
## 영향받는 기존 로직
V2Select.tsx의 `resolvedValue`(979행)에서 prefix를 제거하는 정규식:
```tsx
const cleanLabel = o.label.replace(/^[\s└]+/, "").trim();
```
- JavaScript `\s``\u00A0`를 포함하므로 기존 정규식이 정상 동작함
- 추가 수정 불필요
---
## 설계 원칙
- 백엔드 변경 없이 프론트엔드 표시 로직만 수정
- `flattenTree` 공통 함수 수정이므로 카테고리 타입 드롭다운 전체에 자동 적용
- DB 저장값(`valueCode`)에는 영향 없음 — `label`만 변경
- 기존 prefix strip 정규식(`/^[\s└]+/`)과 호환 유지
- `V2Select``UnifiedSelect` 두 곳의 동일 패턴을 일관되게 수정
@@ -0,0 +1,105 @@
# [맥락노트] 카테고리 드롭다운 - 3단계 깊이 구분 표시
> 관련 문서: [계획서](./CTI[계획]-카테고리-깊이구분.md) | [체크리스트](./CTI[체크]-카테고리-깊이구분.md)
---
## 왜 이 작업을 하는가
- 품목정보 등록 모달의 "재고단위" 등 카테고리 드롭다운에서 3단계 계층이 시각적으로 구분되지 않음
- 예: "신22"가 "신2"의 하위인데, "신3", "신4"와 같은 레벨로 보임
- 사용자가 대분류/중분류/소분류 관계를 파악할 수 없어 잘못된 항목을 선택할 위험
---
## 핵심 결정 사항과 근거
### 1. 원인: HTML 공백 축소(collapse)
- **현상**: `flattenTree`에서 `" ".repeat(depth)`로 들여쓰기를 만들지만, HTML이 연속 공백을 하나로 합침
- **결과**: depth 1(`" └ "`)과 depth 2(`" └ "`)가 동일하게 렌더링됨
- **확인**: `SelectItem`, `CommandItem` 모두 `white-space: pre` 미적용 상태
### 2. 해결: Non-Breaking Space(`\u00A0`) 사용
- **결정**: 일반 공백 `" "``"\u00A0"`로 교체
- **근거**: `\u00A0`는 HTML에서 축소되지 않아 depth별 들여쓰기가 정확히 유지됨
- **대안 검토**:
- `white-space: pre` CSS 적용 → 기각 (SelectItem, CommandItem 양쪽 모두 수정 필요, shadcn 기본 스타일 오버라이드 부담)
- CSS `padding-left` 사용 → 기각 (label 문자열 기반 옵션 구조에서 개별 아이템에 스타일 전달 어려움)
- 트리 문자(`│`, `├`, `└`) 조합 → 기각 (과도한 시각 정보, 단순 들여쓰기면 충분)
### 3. depth당 3칸 `\u00A0`
- **결정**: `"\u00A0\u00A0\u00A0".repeat(depth)` (depth당 3칸)
- **근거**: 기존 2칸은 `\u00A0`로 바꿔도 depth간 차이가 작음. 3칸이 시각적 구분에 적절
### 4. 두 파일 동시 수정
- **결정**: `V2Select.tsx``UnifiedSelect.tsx` 모두 수정
- **근거**: 동일한 `flattenTree` 패턴이 두 컴포넌트에 존재. 하나만 수정하면 불일치 발생
### 5. 기존 prefix strip 정규식 호환
- **확인**: V2Select.tsx 979행의 `o.label.replace(/^[\s└]+/, "").trim()`
- **근거**: JavaScript `\s``\u00A0`를 포함하므로 추가 수정 불필요
---
## 구현 중 발견한 사항
### CAT_ vs CATEGORY_ 접두사 불일치
테스트 과정에서 최고 관리자 계정으로 리스트 조회 시 `CAT_MMLL6U02_QH2V` 같은 코드가 그대로 표시되는 현상 발견.
- **원인**: 카테고리 값 생성 함수가 두 곳에 존재하며 접두사가 다름
- `CategoryValueAddDialog.tsx`: `CATEGORY_` 접두사
- `CategoryValueManagerTree.tsx`: `CAT_` 접두사
- **영향**: 리스트 해석 로직(`V2Repeater`, `InteractiveDataTable`, `UnifiedRepeater`)이 `CATEGORY_` 접두사만 인식하여 `CAT_` 코드는 라벨 변환 실패
- **판단**: 일반 회사 계정에서는 정상 동작 확인. 본 작업(들여쓰기 표시) 범위 외로 별도 이슈로 분리
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 수정 완료 | `frontend/components/v2/V2Select.tsx` | flattenTree 함수 (904행) |
| 수정 완료 | `frontend/components/unified/UnifiedSelect.tsx` | flattenTree 함수 (632행) |
| 백엔드 (변경 없음) | `backend-node/src/services/tableCategoryValueService.ts` | buildHierarchy 메서드 |
| UI 컴포넌트 (변경 없음) | `frontend/components/ui/select.tsx` | SelectItem 렌더링 |
| UI 컴포넌트 (변경 없음) | `frontend/components/ui/command.tsx` | CommandItem 렌더링 |
---
## 기술 참고
### flattenTree 동작 흐름
```
백엔드 API 응답 (트리 구조):
{
valueCode: "CAT_001", valueLabel: "신예철", children: [
{ valueCode: "CAT_002", valueLabel: "신2", children: [
{ valueCode: "CAT_003", valueLabel: "신22", children: [] }
]},
{ valueCode: "CAT_004", valueLabel: "신3", children: [] },
{ valueCode: "CAT_005", valueLabel: "신4", children: [] }
]
}
→ flattenTree 변환 후 (SelectOption 배열):
[
{ value: "CAT_001", label: "신예철" },
{ value: "CAT_002", label: "\u00A0\u00A0\u00A0└ 신2" },
{ value: "CAT_003", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0└ 신22" },
{ value: "CAT_004", label: "\u00A0\u00A0\u00A0└ 신3" },
{ value: "CAT_005", label: "\u00A0\u00A0\u00A0└ 신4" }
]
```
### value vs label 분리
- `value` (저장값): `valueCode` — DB에 저장되는 값, 들여쓰기 없음
- `label` (표시값): prefix + `valueLabel` — 화면에만 보이는 값, 들여쓰기 포함
- 데이터 무결성에 영향 없음
@@ -0,0 +1,53 @@
# [체크리스트] 카테고리 드롭다운 - 3단계 깊이 구분 표시
> 관련 문서: [계획서](./CTI[계획]-카테고리-깊이구분.md) | [맥락노트](./CTI[맥락]-카테고리-깊이구분.md)
---
## 공정 상태
- 전체 진행률: **100%** (완료)
- 현재 단계: 전체 완료
---
## 구현 체크리스트
### 1단계: 코드 수정
- [x] `V2Select.tsx` 904행 — `flattenTree` prefix를 `\u00A0` 기반으로 변경
- [x] `UnifiedSelect.tsx` 632행 — 동일한 `flattenTree` prefix 변경
### 2단계: 검증
- [x] depth 1 항목: 3칸 들여쓰기 + `└` 표시 확인
- [x] depth 2 항목: 6칸 들여쓰기 + `└` 표시, depth 1과 명확히 구분됨 확인
- [x] depth 0 항목: 들여쓰기 없이 원래대로 표시 확인
- [x] 항목 선택 후 값이 정상 저장되는지 확인 (valueCode 기준)
- [x] 기존 prefix strip 로직 정상 동작 확인 — JS `\s``\u00A0` 포함하므로 호환
- [x] 검색 가능 모드(Combobox): 정상 동작 확인
- [x] 비검색 모드(Select): 렌더링 정상 확인
### 3단계: 정리
- [x] 린트 에러 없음 확인 (기존 에러 제외)
- [x] 계맥체 문서 최신화
---
## 참고: 최고 관리자 계정 표시 이슈
- 최고 관리자(`company_code = "*"`)로 리스트 조회 시 `CAT_MMLL6U02_QH2V` 같은 코드값이 그대로 노출되는 현상 발견
- 원인: `CategoryValueManagerTree.tsx``generateCode()``CAT_` 접두사를 사용하나, 리스트 해석 로직은 `CATEGORY_` 접두사만 인식
- 일반 회사 계정에서는 정상 표시됨을 확인
- 본 작업 범위 외로 판단하여 별도 이슈로 분리
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 |
| 2026-03-11 | 1단계 코드 수정 완료 (V2Select.tsx, UnifiedSelect.tsx) |
| 2026-03-11 | 2단계 검증 완료, 3단계 문서 정리 완료 |
@@ -0,0 +1,374 @@
# [계획서] 렉 구조 위치코드/위치명 포맷 사용자 설정
> 관련 문서: [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md)
## 개요
물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서 생성되는 **위치코드(`location_code`)와 위치명(`location_name`)의 포맷을 관리자가 화면 디자이너에서 자유롭게 설정**할 수 있도록 합니다.
현재 위치코드/위치명 생성 로직은 하드코딩되어 있어, 구분자("-"), 세그먼트 순서(창고코드-층-구역-열-단), 한글 접미사("구역", "열", "단") 등을 변경할 수 없습니다.
---
## 현재 동작
### 1. 타입/설정에 패턴 필드가 정의되어 있지만 사용하지 않음
`types.ts`(57~58행)에 `codePattern`/`namePattern`이 정의되어 있고, `config.ts`(14~15행)에 기본값도 있으나, 실제 컴포넌트에서는 **전혀 참조하지 않음**:
```typescript
// types.ts:57~58 - 정의만 있음
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
// config.ts:14~15 - 기본값만 있음
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
namePattern: "{zone}구역-{row:02d}열-{level}단",
```
### 2. 위치 코드 생성 하드코딩 (RackStructureComponent.tsx:494~510)
```tsx
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const warehouseCode = context?.warehouseCode || "WH001";
const floor = context?.floor;
const zone = context?.zone || "A";
const floorPrefix = floor ? `${floor}` : "";
const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`;
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
const floorNamePrefix = floor ? `${floor}-` : "";
const name = `${floorNamePrefix}${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
return { code, name };
},
[context],
);
```
### 3. ConfigPanel에 포맷 관련 설정 UI 없음
`RackStructureConfigPanel.tsx`에는 필드 매핑, 제한 설정, UI 설정만 있고, `codePattern`/`namePattern`을 편집하는 UI가 없음.
---
## 변경 후 동작
### 1. ConfigPanel에 "포맷 설정" 섹션 추가
화면 디자이너 좌측 속성 패널의 v2-rack-structure ConfigPanel에 새 섹션이 추가됨:
- 위치코드/위치명 각각의 세그먼트 목록
- 최상단에 컬럼 헤더(`라벨` / `구분` / `자릿수`) 표시
- 세그먼트별로 **드래그 순서변경**, **체크박스로 한글 라벨 표시/숨김**, **라벨 텍스트 입력**, **구분자 입력**, **자릿수 입력**
- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 나머지(창고코드, 층, 구역)는 비활성화
- 변경 시 실시간 미리보기로 결과 확인
### 2. 컴포넌트에서 config 기반 코드 생성
`RackStructureComponent``generateLocationCode`가 하드코딩 대신 `config.formatConfig`의 세그먼트 배열을 순회하며 동적으로 코드/이름 생성.
### 3. 기본값은 현재 하드코딩과 동일
`formatConfig`가 설정되지 않으면 기본 세그먼트가 적용되어 현재와 완전히 동일한 결과 생성 (하위 호환).
---
## 시각적 예시
### ConfigPanel UI (화면 디자이너 좌측 속성 패널)
```
┌─ 포맷 설정 ──────────────────────────────────────────────┐
│ │
│ 위치코드 포맷 │
│ 라벨 구분 자릿수 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ☰ 창고코드 [✓] [ ] [ - ] [ 0 ] (비활성) │ │
│ │ ☰ 층 [✓] [ 층 ] [ ] [ 0 ] (비활성) │ │
│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │
│ │ ☰ 열 [✓] [ ] [ - ] [ 2 ] │ │
│ │ ☰ 단 [✓] [ ] [ ] [ 0 ] │ │
│ └──────────────────────────────────────────────────┘ │
│ 미리보기: WH001-1층A구역-01-1 │
│ │
│ 위치명 포맷 │
│ 라벨 구분 자릿수 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │
│ │ ☰ 열 [✓] [ 열 ] [ - ] [ 2 ] │ │
│ │ ☰ 단 [✓] [ 단 ] [ ] [ 0 ] │ │
│ └──────────────────────────────────────────────────┘ │
│ 미리보기: A구역-01열-1단 │
│ │
└───────────────────────────────────────────────────────────┘
```
### 사용자 커스터마이징 예시
| 설정 변경 | 위치코드 결과 | 위치명 결과 |
|-----------|-------------|------------|
| 기본값 (변경 없음) | `WH001-1층A구역-01-1` | `A구역-01열-1단` |
| 구분자를 "/" 로 변경 | `WH001/1층A구역/01/1` | `A구역/01열/1단` |
| 층 라벨 해제 | `WH001-1A구역-01-1` | `A구역-01열-1단` |
| 구역+열 라벨 해제 | `WH001-1층A-01-1` | `A-01-1단` |
| 순서를 구역→층→열→단 으로 변경 | `WH001-A구역1층-01-1` | `A구역-1층-01열-1단` |
| 한글 라벨 모두 해제 | `WH001-1A-01-1` | `A-01-1` |
---
## 아키텍처
### 데이터 흐름
```mermaid
flowchart TD
A["관리자: 화면 디자이너 열기"] --> B["RackStructureConfigPanel\n포맷 세그먼트 편집"]
B --> C["componentConfig.formatConfig\n에 세그먼트 배열 저장"]
C --> D["screen_layouts_v2.layout_data\nDB JSONB에 영구 저장"]
D --> E["엔드유저: 렉 구조 모달 열기"]
E --> F["RackStructureComponent\nconfig.formatConfig 읽기"]
F --> G["generateLocationCode\n세그먼트 배열 순회하며 동적 생성"]
G --> H["미리보기 테이블에 표시\nlocation_code / location_name"]
```
### 컴포넌트 관계
```mermaid
graph LR
subgraph designer ["화면 디자이너 (관리자)"]
CP["RackStructureConfigPanel"]
FE["FormatSegmentEditor\n(신규 서브컴포넌트)"]
CP --> FE
end
subgraph runtime ["렉 구조 모달 (엔드유저)"]
RC["RackStructureComponent"]
GL["generateLocationCode\n(세그먼트 기반으로 교체)"]
RC --> GL
end
subgraph storage ["저장소"]
DB["screen_layouts_v2\nlayout_data.overrides.formatConfig"]
end
FE -->|"onChange → componentConfig"| DB
DB -->|"config prop 전달"| RC
```
> 노란색 영역은 없음. 기존 설정-저장-전달 파이프라인을 그대로 활용.
---
## 변경 대상 파일
| 파일 | 수정 내용 | 수정 규모 |
|------|----------|----------|
| `frontend/lib/registry/components/v2-rack-structure/types.ts` | `FormatSegment`, `LocationFormatConfig` 타입 추가, `RackStructureComponentConfig``formatConfig` 필드 추가 | ~25줄 |
| `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 코드/이름 세그먼트 상수 정의 | ~40줄 |
| `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | **신규** - grid 레이아웃 + 컬럼 헤더 + 드래그 순서변경 + showLabel 체크박스 + 라벨/구분/자릿수 고정 필드 + 자릿수 비숫자 타입 비활성화 + 미리보기 | ~200줄 |
| `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | "포맷 설정" 섹션 추가, FormatSegmentEditor 배치 | ~30줄 |
| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | `generateLocationCode`를 세그먼트 기반으로 교체 | ~20줄 |
### 변경하지 않는 파일
- `buttonActions.ts` - 생성된 `location_code`/`location_name`을 그대로 저장하므로 변경 불필요
- 백엔드 전체 - 포맷은 프론트엔드에서만 처리
- DB 스키마 - `screen_layouts_v2.layout_data` JSONB에 자동 포함
---
## 코드 설계
### 1. 타입 추가 (types.ts)
```typescript
// 포맷 세그먼트 (위치코드/위치명의 각 구성요소)
export interface FormatSegment {
type: 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level';
enabled: boolean; // 이 세그먼트를 포함할지 여부
showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거)
label: string; // 한글 라벨 (예: "층", "구역", "열", "단")
separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "")
pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤)
}
// 위치코드 + 위치명 포맷 설정
export interface LocationFormatConfig {
codeSegments: FormatSegment[];
nameSegments: FormatSegment[];
}
```
`RackStructureComponentConfig`에 필드 추가:
```typescript
export interface RackStructureComponentConfig {
// ... 기존 필드 유지 ...
codePattern?: string; // (기존, 하위 호환용 유지)
namePattern?: string; // (기존, 하위 호환용 유지)
formatConfig?: LocationFormatConfig; // 신규: 구조화된 포맷 설정
}
```
### 2. 기본 세그먼트 상수 (config.ts)
```typescript
import { FormatSegment, LocationFormatConfig } from "./types";
// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과)
export const defaultCodeSegments: FormatSegment[] = [
{ type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 },
{ type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 },
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
{ type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 },
{ type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 },
];
// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과)
export const defaultNameSegments: FormatSegment[] = [
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
{ type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 },
{ type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 },
];
export const defaultFormatConfig: LocationFormatConfig = {
codeSegments: defaultCodeSegments,
nameSegments: defaultNameSegments,
};
```
### 3. 세그먼트 기반 문자열 생성 함수 (config.ts)
```typescript
// context 값에 포함된 한글 접미사 ("1층", "A구역")
const KNOWN_SUFFIXES: Partial<Record<FormatSegmentType, string>> = {
floor: "층",
zone: "구역",
};
function stripKnownSuffix(type: FormatSegmentType, val: string): string {
const suffix = KNOWN_SUFFIXES[type];
if (suffix && val.endsWith(suffix)) {
return val.slice(0, -suffix.length);
}
return val;
}
export function buildFormattedString(
segments: FormatSegment[],
values: Record<string, string>,
): string {
const activeSegments = segments.filter(
(seg) => seg.enabled && values[seg.type],
);
return activeSegments
.map((seg, idx) => {
// 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1")
let val = stripKnownSuffix(seg.type, values[seg.type]);
// 2) showLabel이 켜져 있고 label이 있으면 붙임
if (seg.showLabel && seg.label) {
val += seg.label;
}
if (seg.pad > 0 && !isNaN(Number(val))) {
val = val.padStart(seg.pad, "0");
}
if (idx < activeSegments.length - 1) {
val += seg.separatorAfter;
}
return val;
})
.join("");
}
```
### 4. generateLocationCode 교체 (RackStructureComponent.tsx:494~510)
```typescript
// 변경 전 (하드코딩)
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const warehouseCode = context?.warehouseCode || "WH001";
const floor = context?.floor;
const zone = context?.zone || "A";
const floorPrefix = floor ? `${floor}` : "";
const code = `${warehouseCode}-${floorPrefix}${zone}-...`;
// ...
},
[context],
);
// 변경 후 (세그먼트 기반)
const formatConfig = config.formatConfig || defaultFormatConfig;
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const values: Record<string, string> = {
warehouseCode: context?.warehouseCode || "WH001",
floor: context?.floor || "",
zone: context?.zone || "A",
row: row.toString(),
level: level.toString(),
};
const code = buildFormattedString(formatConfig.codeSegments, values);
const name = buildFormattedString(formatConfig.nameSegments, values);
return { code, name };
},
[context, formatConfig],
);
```
### 5. ConfigPanel에 포맷 설정 섹션 추가 (RackStructureConfigPanel.tsx:284행 위)
```tsx
{/* 포맷 설정 - UI 설정 섹션 아래에 추가 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
<p className="text-xs text-gray-500">
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
구분자/라벨을 편집할 수 있습니다
</p>
<FormatSegmentEditor
label="위치코드 포맷"
segments={formatConfig.codeSegments}
onChange={(segs) => handleFormatChange("codeSegments", segs)}
sampleValues={sampleValues}
/>
<FormatSegmentEditor
label="위치명 포맷"
segments={formatConfig.nameSegments}
onChange={(segs) => handleFormatChange("nameSegments", segs)}
sampleValues={sampleValues}
/>
</div>
```
### 6. FormatSegmentEditor 서브컴포넌트 (신규 파일)
- `@dnd-kit/core` + `@dnd-kit/sortable`로 드래그 순서변경
- 프로젝트 표준 패턴: `useSortable`, `DndContext`, `SortableContext` 사용
- **grid 레이아웃** (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`): 드래그핸들 / 타입명 / 체크박스 / 라벨 / 구분 / 자릿수
- 최상단에 **컬럼 헤더** (`라벨` / `구분` / `자릿수`) 표시 — 각 행에서 텍스트 라벨 제거하여 공간 절약
- 라벨/구분/자릿수 3개 필드는 **항상 고정 표시** (빈 값이어도 입력 필드가 사라지지 않음)
- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 비숫자 타입은 `disabled` + 회색 배경
- 하단에 `buildFormattedString`으로 실시간 미리보기 표시
---
## 설계 원칙
- `formatConfig` 미설정 시 `defaultFormatConfig` 적용으로 **기존 동작 100% 유지** (하위 호환)
- 포맷 설정은 **화면 디자이너 ConfigPanel에서만** 편집 (프로젝트의 설정-사용 분리 관행 준수)
- `componentConfig``screen_layouts_v2.layout_data` 저장 파이프라인을 **그대로 활용** (추가 인프라 불필요)
- 기존 `codePattern`/`namePattern` 문자열 필드는 삭제하지 않고 유지 (하위 호환)
- v2-pivot-grid의 `format` 설정 패턴과 동일한 구조: ConfigPanel에서 설정 → 런타임에서 읽어 사용
- `@dnd-kit` 드래그 구현은 `SortableCodeItem.tsx`, `useDragAndDrop.ts`의 기존 패턴 재사용
- 백엔드 변경 없음, DB 스키마 변경 없음
@@ -0,0 +1,123 @@
# [맥락노트] 렉 구조 위치코드/위치명 포맷 사용자 설정
> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md)
---
## 왜 이 작업을 하는가
- 위치코드(`WH001-1층A구역-01-1`)와 위치명(`A구역-01열-1단`)의 포맷이 하드코딩되어 있음
- 회사마다 구분자("-" vs "/"), 세그먼트 순서, 한글 라벨 유무 등 요구사항이 다름
- 현재는 코드를 직접 수정하지 않으면 포맷 변경 불가 → 관리자가 화면 디자이너에서 설정할 수 있어야 함
---
## 핵심 결정 사항과 근거
### 1. 엔드유저 모달이 아닌 화면 디자이너 ConfigPanel에 설정 UI 배치
- **결정**: 포맷 편집 UI를 렉 구조 등록 모달이 아닌 화면 디자이너 좌측 속성 패널(ConfigPanel)에 배치
- **근거**: 프로젝트의 설정-사용 분리 패턴 준수. 모든 v2 컴포넌트가 ConfigPanel에서 설정하고 런타임에서 읽기만 하는 구조를 따름
- **대안 검토**: 모달 안에 포맷 편집 UI 배치(방법 B) → 기각 (프로젝트 관행에 맞지 않음, 매번 설정해야 함, 설정이 휘발됨)
### 2. 패턴 문자열이 아닌 구조화된 세그먼트 배열 사용
- **결정**: `"{warehouseCode}-{floor}{zone}-{row:02d}-{level}"` 같은 문자열 대신 `FormatSegment[]` 배열로 포맷 정의
- **근거**: 관리자가 패턴 문법을 알 필요 없이 드래그/토글/Input으로 직관적 편집 가능
- **대안 검토**: 기존 `codePattern`/`namePattern` 문자열 활용 → 기각 (관리자가 패턴 문법을 모를 수 있고, 오타 가능성 높음)
### 2-1. 체크박스는 한글 라벨 표시/숨김 제어 (showLabel)
- **결정**: 세그먼트의 체크박스는 `showLabel` 속성을 토글하며, 세그먼트 자체를 제거하지 않음
- **근거**: "A구역-01열-1단"에서 "구역", "열" 체크 해제 시 → "A-01-1단"이 되어야 함 (값은 유지, 한글만 제거)
- **주의**: `enabled`는 세그먼트 자체의 포함 여부, `showLabel`은 한글 라벨만 표시/숨김. 혼동하지 않도록 분리
### 2-2. 라벨/구분/자릿수 3개 필드 항상 고정 표시
- **결정**: 라벨 필드를 비워도 입력 필드가 사라지지 않고, 3개 필드(라벨, 구분, 자릿수)가 모든 세그먼트에 항상 표시
- **근거**: 라벨을 지웠을 때 "라벨 없음"이 뜨면서 입력 필드가 사라지면 다시 라벨을 추가할 수 없는 문제 발생
- **UI 개선**: 컬럼 헤더를 최상단에 배치하고, 각 행에서는 "구분", "자릿수" 텍스트를 제거하여 공간 확보
### 2-3. stripKnownSuffix로 원본 값의 한글 접미사를 먼저 벗긴 뒤 라벨 붙임
- **결정**: `buildFormattedString`에서 값을 처리할 때, 먼저 `KNOWN_SUFFIXES`(층, 구역)를 벗겨내고 순수 값만 남긴 뒤, `showLabel && label`일 때만 라벨을 붙이는 구조
- **근거**: context 값이 "1층", "A구역"처럼 한글이 이미 포함된 상태로 들어옴. 이전 방식(`if (seg.label)`)은 라벨 필드가 빈 문자열이면 조건을 건너뛰어서 한글이 제거되지 않는 버그 발생
- **핵심 흐름**: 원본 값 → `stripKnownSuffix` → 순수 값 → `showLabel && label`이면 라벨 붙임
### 2-4. 자릿수 필드는 숫자 타입만 활성화
- **결정**: 자릿수(pad) 필드는 열(row), 단(level)만 편집 가능, 나머지(창고코드, 층, 구역)는 disabled + 회색 배경
- **근거**: 자릿수(zero-padding)는 숫자 값에만 의미가 있음. 비숫자 타입에 자릿수를 설정하면 혼란을 줄 수 있음
### 3. 기존 codePattern/namePattern 필드는 삭제하지 않고 유지
- **결정**: `types.ts``codePattern`, `namePattern` 필드를 삭제하지 않음
- **근거**: 하위 호환. 기존에 이 필드를 참조하는 코드가 없지만, 향후 다른 용도로 활용될 수 있음
### 4. formatConfig 미설정 시 기본값으로 현재 동작 유지
- **결정**: `config.formatConfig`가 없으면 `defaultFormatConfig` 사용
- **근거**: 기존 화면 설정을 수정하지 않아도 현재와 동일한 위치코드/위치명이 생성됨 (무중단 배포 가능)
### 5. UI 라벨에서 "패딩" 대신 "자릿수" 사용
- **결정**: ConfigPanel UI에서 숫자 제로패딩 설정을 "자릿수"로 표시
- **근거**: 관리자급 사용자가 "패딩"이라는 개발 용어를 모를 수 있음. "자릿수: 2 → 01, 02, ... 99"가 직관적
- **코드 내부**: 변수명은 `pad` 유지 (개발자 영역)
### 6. @dnd-kit으로 드래그 구현
- **결정**: `@dnd-kit/core` + `@dnd-kit/sortable` 사용
- **근거**: 프로젝트에 이미 설치되어 있고(`package.json`), `SortableCodeItem.tsx`, `useDragAndDrop.ts` 등 표준 패턴이 확립되어 있음
- **대안 검토**: 위/아래 화살표 버튼으로 순서 변경 → 기각 (프로젝트에 이미 DnD 패턴이 있으므로 일관성 유지)
### 7. v2-pivot-grid의 format 설정 패턴을 참고
- **결정**: ConfigPanel에서 설정 → componentConfig에 저장 → 런타임에서 읽어 사용하는 흐름
- **근거**: v2-pivot-grid가 필드별 `format`(type, precision, thousandSeparator 등)을 동일한 패턴으로 구현하고 있음. 가장 유사한 선례
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | FormatSegment, LocationFormatConfig 타입 |
| 기본 설정 | `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 세그먼트 상수, buildFormattedString 함수 |
| 신규 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | 포맷 편집 UI 서브컴포넌트 |
| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | FormatSegmentEditor 배치 |
| 런타임 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | generateLocationCode 세그먼트 기반 교체 |
| DnD 참고 | `frontend/hooks/useDragAndDrop.ts` | 프로젝트 표준 DnD 패턴 |
| DnD 참고 | `frontend/components/admin/SortableCodeItem.tsx` | useSortable 사용 예시 |
| 선례 참고 | `frontend/lib/registry/components/v2-pivot-grid/` | ConfigPanel에서 format 설정하는 패턴 |
---
## 기술 참고
### 세그먼트 기반 문자열 생성 흐름
```
FormatSegment[] → filter(enabled && 값 있음) → map(stripKnownSuffix → showLabel && label이면 라벨 붙임 → 자릿수 → 구분자) → join("") → 최종 문자열
```
### componentConfig 저장/로드 흐름
```
ConfigPanel onChange
→ V2PropertiesPanel.onUpdateProperty("componentConfig", mergedConfig)
→ layout.components[i].componentConfig.formatConfig
→ convertLegacyToV2 → screen_layouts_v2.layout_data.overrides.formatConfig (DB)
→ convertV2ToLegacy → componentConfig.formatConfig (런타임)
→ RackStructureComponent config.formatConfig (prop)
```
### context 값 참고
```
context.warehouseCode = "WH001" (창고 코드)
context.floor = "1층" (층 라벨 - 값 자체에 "층" 포함)
context.zone = "A구역" 또는 "A" (구역 라벨 - "구역" 포함 여부 불확실)
row = 1, 2, 3, ... (열 번호 - 숫자)
level = 1, 2, 3, ... (단 번호 - 숫자)
```
@@ -0,0 +1,84 @@
# [체크리스트] 렉 구조 위치코드/위치명 포맷 사용자 설정
> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md)
---
## 공정 상태
- 전체 진행률: **100%** (완료)
- 현재 단계: 완료
---
## 구현 체크리스트
### 1단계: 타입 및 기본값 정의
- [x] `types.ts``FormatSegment` 인터페이스 추가
- [x] `types.ts``LocationFormatConfig` 인터페이스 추가
- [x] `types.ts``RackStructureComponentConfig``formatConfig?: LocationFormatConfig` 필드 추가
- [x] `config.ts``defaultCodeSegments` 상수 정의 (현재 하드코딩과 동일한 결과)
- [x] `config.ts``defaultNameSegments` 상수 정의 (현재 하드코딩과 동일한 결과)
- [x] `config.ts``defaultFormatConfig` 상수 정의
- [x] `config.ts``buildFormattedString()` 함수 구현 (stripKnownSuffix 방식)
### 2단계: FormatSegmentEditor 서브컴포넌트 생성
- [x] `FormatSegmentEditor.tsx` 신규 파일 생성
- [x] `@dnd-kit/sortable` 기반 드래그 순서변경 구현
- [x] 세그먼트별 체크박스로 한글 라벨 표시/숨김 토글 (showLabel)
- [x] 라벨/구분/자릿수 3개 필드 항상 고정 표시 (빈 값이어도 입력 필드 유지)
- [x] 최상단 컬럼 헤더 추가 (라벨 / 구분 / 자릿수), 각 행에서 텍스트 라벨 제거
- [x] grid 레이아웃으로 정렬 (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`)
- [x] 자릿수 필드: 숫자 타입(열, 단)만 활성화, 비숫자 타입은 disabled + 회색 배경
- [x] `buildFormattedString`으로 실시간 미리보기 표시
### 3단계: ConfigPanel에 포맷 설정 섹션 추가
- [x] `RackStructureConfigPanel.tsx`에 FormatSegmentEditor import
- [x] UI 설정 섹션 아래에 "포맷 설정" 섹션 추가
- [x] 위치코드 포맷용 FormatSegmentEditor 배치
- [x] 위치명 포맷용 FormatSegmentEditor 배치
- [x] `onChange``formatConfig` 업데이트 연결
### 4단계: 컴포넌트에서 세그먼트 기반 코드 생성
- [x] `RackStructureComponent.tsx`에서 `defaultFormatConfig` import
- [x] `generateLocationCode` 함수를 세그먼트 기반으로 교체
- [x] `config.formatConfig || defaultFormatConfig` 폴백 적용
### 5단계: 검증
- [x] formatConfig 미설정 시: 기존과 동일한 위치코드/위치명 생성 확인
- [x] ConfigPanel에서 구분자 변경: 미리보기에 즉시 반영 확인
- [x] ConfigPanel에서 라벨 체크 해제: 한글만 사라지고 값은 유지 확인 (예: "A구역" → "A")
- [x] ConfigPanel에서 순서 드래그 변경: 미리보기에 반영 확인
- [x] ConfigPanel에서 라벨 텍스트 변경: 미리보기에 반영 확인
- [x] 설정 저장 후 화면 재로드: 설정 유지 확인
- [x] 렉 구조 모달에서 미리보기 생성: 설정된 포맷으로 생성 확인
- [x] 렉 구조 저장: DB에 설정된 포맷의 코드/이름 저장 확인
### 6단계: 정리
- [x] 린트 에러 없음 확인
- [x] 미사용 import 제거 (FormatSegmentEditor.tsx: useState)
- [x] 파일 끝 불필요한 빈 줄 제거 (types.ts, config.ts)
- [x] 계획서/맥락노트/체크리스트 최종 반영
- [x] 이 체크리스트 완료 표시 업데이트
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-10 | 1~4단계 구현 완료 (types, config, FormatSegmentEditor, ConfigPanel, Component) |
| 2026-03-10 | showLabel 로직 수정: 체크박스가 세그먼트 제거가 아닌 한글 라벨만 표시/숨김 처리 |
| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 showLabel 변경사항 반영 |
| 2026-03-10 | UI 개선: 3필드 고정표시 + 컬럼 헤더 + grid 레이아웃 + 자릿수 비숫자 비활성화 |
| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 UI 개선사항 반영 |
| 2026-03-10 | 라벨 필드 비움 시 한글 미제거 버그 수정 (stripKnownSuffix 도입) |
| 2026-03-10 | 코드 정리 (미사용 import, 빈 줄) + 문서 최종 반영 |
| 2026-03-10 | 5단계 검증 완료, 전체 작업 완료 |
@@ -0,0 +1,128 @@
# [계획서] 페이징 - 페이지 번호 직접 입력 네비게이션
> 관련 문서: [맥락노트](./PGN[맥락]-페이징-직접입력.md) | [체크리스트](./PGN[체크]-페이징-직접입력.md)
## 개요
v2-table-list 컴포넌트의 하단 페이지네이션 중앙 영역에서, 현재 페이지 번호를 **읽기 전용 텍스트**에서 **입력 가능한 필드**로 변경합니다.
사용자가 원하는 페이지 번호를 키보드로 직접 입력하여 빠르게 이동할 수 있게 합니다.
### 이전 설계(10개 번호 버튼 그룹) 폐기 사유
- 10개 버튼은 공간을 많이 차지하고, 모바일에서 렌더링이 어려움
- 고정 슬롯/고정 너비 등 복잡한 레이아웃 제약이 발생
- 입력 필드 방식이 더 직관적이고 공간 효율적
---
## 변경 전 → 변경 후
### 페이지네이션 UI
```
변경 전: [<<] [<] 1 / 38 [>] [>>] ← 읽기 전용 텍스트
변경 후: [<<] [<] [ 15 ] / 49 [>] [>>] ← 입력 가능 필드
```
| 버튼 | 동작 (변경 없음) |
|------|-----------------|
| `<<` | 첫 페이지(1)로 이동 |
| `<` | 이전 페이지(`currentPage - 1`)로 이동 |
| 중앙 | **입력 필드** `/` **총 페이지** — 사용자가 원하는 페이지 번호를 직접 입력 |
| `>` | 다음 페이지(`currentPage + 1`)로 이동 |
| `>>` | 마지막 페이지(`totalPages`)로 이동 |
### 입력 필드 동작 규칙
| 동작 | 설명 |
|------|------|
| 클릭 | 입력 필드에 포커스, 기존 숫자 전체 선택(select all) |
| 숫자 입력 | 자유롭게 타이핑 가능 (입력 중에는 페이지 이동 안 함) |
| Enter | 입력한 페이지로 이동 + 포커스 해제 |
| 포커스 아웃 (blur) | 입력한 페이지로 이동 |
| 유효 범위 보정 | 1 미만 → 1, totalPages 초과 → totalPages, 빈 값/비숫자 → 현재 페이지 유지 |
| `< >` 클릭 | 기존대로 한 페이지씩 이동 (입력 필드 값도 갱신) |
| `<< >>` 클릭 | 기존대로 첫/끝 페이지 이동 (입력 필드 값도 갱신) |
### 비활성화 조건 (기존과 동일)
- `<<` `<` : `currentPage === 1`
- `>` `>>` : `currentPage >= totalPages`
---
## 시각적 동작 예시
총 49페이지 기준:
| 사용자 동작 | 입력 필드 표시 | 결과 |
|------------|---------------|------|
| 초기 상태 | `1 / 49` | 1페이지 표시 |
| 입력 필드 클릭 | `[1]` 전체 선택됨 | 타이핑 대기 |
| `28` 입력 후 Enter | `28 / 49` | 28페이지로 이동 |
| `0` 입력 후 Enter | `1 / 49` | 1로 보정 |
| `999` 입력 후 Enter | `49 / 49` | 49로 보정 |
| 빈 값으로 blur | `28 / 49` | 이전 페이지(28) 유지 |
| `abc` 입력 후 Enter | `28 / 49` | 이전 페이지(28) 유지 |
| `>` 클릭 | `29 / 49` | 29페이지로 이동 |
---
## 아키텍처
### 데이터 흐름
```mermaid
flowchart TD
A["currentPage (state, 단일 소스)"] --> B["입력 필드 표시값 (pageInputValue)"]
B -->|"사용자 타이핑"| C["pageInputValue 갱신 (표시만)"]
C -->|"Enter 또는 blur"| D["유효 범위 보정 (1~totalPages)"]
D -->|"보정된 값"| E[handlePageChange]
E --> F["setCurrentPage → useEffect → fetchTableDataDebounced"]
F --> G[백엔드 API 호출]
G --> H[데이터 갱신]
H --> A
I["<< < > >> 클릭"] --> E
J["페이지크기 변경"] --> K["setCurrentPage(1) + setLocalPageSize + onConfigChange"]
K --> F
```
### 페이징 바 레이아웃
```
┌──────────────────────────────────────────────────────────────┐
│ [페이지크기 입력] │ << < [__입력__] / n > >> │ [내보내기][새로고침] │
│ 좌측(유지) │ 중앙(입력필드 교체) │ 우측(유지) │
└──────────────────────────────────────────────────────────────┘
```
---
## 변경 대상 파일
| 구분 | 파일 | 변경 내용 |
|------|------|----------|
| 수정 | `TableListComponent.tsx` | (1) `pageInputValue` 상태 + `useEffect` 동기화 + `commitPageInput` 핸들러 추가 |
| | | (2) paginationJSX 중앙 `<span>``<input>` + `/` + `<span>` 교체 |
| | | (3) `handlePageSizeChange``onConfigChange` 호출 추가 |
| | | (4) `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 사용 |
| | | (5) `useMemo` 의존성에 `pageInputValue` 추가 |
| 삭제 | `PageGroupNav.tsx` | 이전 설계 산출물 삭제 (이미 삭제됨) |
- 신규 파일 생성 없음
- 백엔드 변경 없음, DB 변경 없음
- v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용
---
## 설계 원칙
- **최소 변경**: `<span>` 1개를 `<input>` + 유효성 검증으로 교체. 나머지 전부 유지
- **기존 버튼 동작 무변경**: `<< < > >>` 4개 버튼의 onClick/disabled 로직은 그대로
- **`handlePageChange` 재사용**: 기존 함수를 그대로 호출
- **입력 중 페이지 이동 안 함**: onChange는 표시만 변경, Enter/blur로 실제 적용
- **유효 범위 자동 보정**: 1 미만 → 1, totalPages 초과 → totalPages, 비숫자 → 현재 값 유지
- **포커스 시 전체 선택**: 클릭하면 바로 타이핑 가능
- **`currentPage`가 단일 소스**: fetch 시 `tableConfig.pagination?.currentPage` 대신 로컬 `currentPage`만 사용 (비동기 전파 문제 방지)
- **페이지크기 변경 시 1페이지로 리셋**: `handlePageSizeChange``onConfigChange`를 호출하여 부모/백엔드 동기화
@@ -0,0 +1,115 @@
# [맥락노트] 페이징 - 페이지 번호 직접 입력 네비게이션
> 관련 문서: [계획서](./PGN[계획]-페이징-직접입력.md) | [체크리스트](./PGN[체크]-페이징-직접입력.md)
---
## 왜 이 작업을 하는가
- 현재 페이지네이션은 `1 / 38` 읽기 전용 텍스트만 표시
- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음 (`>` 연타 필요)
- 페이지 번호를 직접 입력하여 즉시 이동할 수 있어야 UX가 개선됨
---
## 핵심 결정 사항과 근거
### 1. 10개 번호 버튼 그룹 → 입력 필드로 설계 변경
- **결정**: 이전 설계(10개 페이지 번호 버튼 나열)를 폐기하고, 기존 `현재/총` 텍스트에서 현재 부분을 입력 필드로 교체
- **근거**: 10개 버튼은 공간을 많이 차지하고 고정 슬롯/고정 너비 등 복잡한 레이아웃 제약이 발생. 입력 필드 방식이 더 직관적이고 공간 효율적
- **이전 산출물**: `PageGroupNav.tsx` → 삭제 완료
### 2. `<< < > >>` 버튼 동작 유지
- **결정**: 4개 화살표 버튼의 동작은 기존과 완전히 동일하게 유지
- **근거**: 입력 필드가 "원하는 페이지로 점프" 역할을 하므로, 버튼은 기존의 순차 이동(+1/-1, 첫/끝) 그대로 유지하는 것이 자연스러움
### 3. 입력 중에는 페이지 이동 안 함
- **결정**: onChange는 입력 필드 표시만 변경. Enter 또는 blur로 실제 페이지 이동
- **근거**: `28`을 입력하려면 `2`를 먼저 치는데, `2`에서 바로 이동하면 안 됨
### 4. 포커스 시 전체 선택 (select all)
- **결정**: 입력 필드 클릭 시 기존 숫자를 전체 선택
- **근거**: 사용자가 "15페이지로 가고 싶다" → 클릭 → 바로 `15` 타이핑. 기존 값을 지우는 추가 동작 불필요
### 5. 유효 범위 자동 보정
- **결정**: 1 미만 → 1, totalPages 초과 → totalPages, 빈 값/비숫자 → 현재 페이지 유지
- **근거**: 에러 메시지보다 자동 보정이 UX에 유리
- **대안 검토**: 입력 자체를 숫자만 허용 → 기각 (백스페이스로 비울 때 불편)
### 6. `inputMode="numeric"` 사용
- **결정**: `type="text"` + `inputMode="numeric"`
- **근거**: `type="number"`는 브라우저별 스피너 UI가 추가되고, 빈 값 처리가 어려움. `inputMode="numeric"`은 모바일에서 숫자 키보드를 띄우면서 text 입력의 유연성 유지
### 7. 신규 컴포넌트 분리 안 함
- **결정**: v2-table-list의 paginationJSX 내부에 인라인으로 구현
- **근거**: 변경이 `<span>``<input>` + 핸들러 약 30줄 수준으로 매우 작음
### 8. `currentPage`를 fetch의 단일 소스로 사용
- **결정**: `fetchTableDataInternal`에서 `tableConfig.pagination?.currentPage || currentPage` 대신 `currentPage`만 사용
- **근거**: `handlePageSizeChange`에서 `setCurrentPage(1)` + `onConfigChange(...)` 호출 시, `onConfigChange`를 통한 부모의 `tableConfig` 갱신은 다음 렌더 사이클에서 전파됨. fetch가 실행되는 시점에 `tableConfig.pagination?.currentPage`가 아직 이전 값(예: 4)이고 truthy이므로 로컬 `currentPage`(1) 대신 4를 사용하게 되는 문제 발생. 로컬 `currentPage``setCurrentPage`로 즉시 갱신되므로 이 문제가 없음
- **발견 과정**: 페이지 크기를 20→40으로 변경하면 1페이지로 설정되지만 리스트가 빈 상태로 표시되는 버그로 발견
### 9. `handlePageSizeChange`에서 `onConfigChange` 호출 필수
- **결정**: 페이지 크기 변경 시 `onConfigChange``{ pageSize, currentPage: 1 }`을 부모에게 전달
- **근거**: 기존 코드는 `setLocalPageSize` + `setCurrentPage(1)`만 호출하고 `onConfigChange`를 호출하지 않았음. 이로 인해 부모 컴포넌트의 `tableConfig.pagination`이 갱신되지 않아 후속 동작에서 stale 값 참조 가능
- **발견 과정**: 위 8번과 같은 맥락에서 발견
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 입력 필드 + fetch 소스 수정 |
| 삭제 | `frontend/components/common/PageGroupNav.tsx` | 이전 설계 산출물 (삭제 완료) |
---
## 기술 참고
### 로컬 입력 상태와 실제 페이지 상태 분리
```
pageInputValue (string) — 입력 필드에 표시되는 값 (사용자가 타이핑 중일 수 있음)
currentPage (number) — 실제 현재 페이지 (API 호출의 단일 소스)
동기화:
- currentPage 변경 시 → useEffect → setPageInputValue(String(currentPage))
- Enter/blur 시 → commitPageInput → parseInt + clamp → handlePageChange(보정된 값)
```
### handlePageChange 호출 흐름
```
입력 필드 Enter/blur
→ commitPageInput()
→ parseInt + clamp(1, totalPages)
→ handlePageChange(clampedPage)
→ setCurrentPage(clampedPage) + onConfigChange
→ useEffect 트리거 → fetchTableDataDebounced
→ fetchTableDataInternal(page = currentPage)
→ 백엔드 API 호출
```
### handlePageSizeChange 호출 흐름
```
좌측 페이지크기 입력 onChange/onBlur
→ handlePageSizeChange(newSize)
→ setLocalPageSize(newSize)
→ setCurrentPage(1)
→ sessionStorage 저장
→ onConfigChange({ pageSize: newSize, currentPage: 1 })
→ useEffect 트리거 → fetchTableDataDebounced
→ fetchTableDataInternal(page = 1, pageSize = newSize)
→ 백엔드 API 호출
```
@@ -0,0 +1,73 @@
# [체크리스트] 페이징 - 페이지 번호 직접 입력 네비게이션
> 관련 문서: [계획서](./PGN[계획]-페이징-직접입력.md) | [맥락노트](./PGN[맥락]-페이징-직접입력.md)
---
## 공정 상태
- 전체 진행률: **100%** (완료)
- 현재 단계: 완료
---
## 구현 체크리스트
### 1단계: 이전 설계 산출물 정리
- [x] `frontend/components/common/PageGroupNav.tsx` 삭제
- [x] `TableListComponent.tsx`에서 `PageGroupNav` import 제거 (있으면) — 이미 없음
### 2단계: 입력 필드 구현
- [x] `pageInputValue` 로컬 상태 추가 (`useState<string>`)
- [x] `currentPage` 변경 시 `pageInputValue` 동기화 (`useEffect`)
- [x] `commitPageInput` 핸들러 구현 (parseInt + clamp + handlePageChange)
- [x] paginationJSX 중앙의 `<span>``<input>` + `/` + `<span>` 교체
- [x] `inputMode="numeric"` 적용
- [x] `onFocus`에 전체 선택 (`e.target.select()`)
- [x] `onChange``setPageInputValue` (표시만 변경)
- [x] `onKeyDown` Enter에 `commitPageInput` + `blur()`
- [x] `onBlur``commitPageInput`
- [x] `disabled={loading}` 적용
- [x] 기존 좌측 페이지크기 입력과 일관된 스타일 적용
### 3단계: 버그 수정
- [x] `handlePageSizeChange``onConfigChange` 호출 추가 (`pageSize` + `currentPage: 1` 전달)
- [x] `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 변경 (stale `tableConfig.pagination?.currentPage` 문제 해결)
- [x] `useCallback` 의존성에서 `tableConfig.pagination?.currentPage` 제거
- [x] `useMemo` 의존성에 `pageInputValue` 추가
### 4단계: 검증
- [x] 입력 필드에 숫자 입력 후 Enter → 해당 페이지로 이동
- [x] 입력 필드에 숫자 입력 후 포커스 아웃 → 해당 페이지로 이동
- [x] 0 입력 → 1로 보정
- [x] totalPages 초과 입력 → totalPages로 보정
- [x] 빈 값으로 blur → 현재 페이지 유지
- [x] 비숫자(abc) 입력 후 Enter → 현재 페이지 유지
- [x] 입력 필드 클릭 시 기존 숫자 전체 선택 확인
- [x] `< >` 버튼 클릭 시 입력 필드 값도 갱신 확인
- [x] `<< >>` 버튼 클릭 시 입력 필드 값도 갱신 확인
- [x] 로딩 중 입력 필드 비활성화 확인
- [x] 좌측 페이지크기 입력과 스타일 일관성 확인
- [x] 기존 `<< < > >>` 버튼 동작 변화 없음 확인
- [x] 페이지크기 변경 시 1페이지로 리셋 + 데이터 정상 로딩 확인
### 5단계: 정리
- [x] 린트 에러 없음 확인 (기존 에러만 존재, 신규 없음)
- [x] 문서(계획서/맥락노트/체크리스트) 최신화 완료
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-11 | 최초 설계: 10개 번호 버튼 그룹 (PageGroupNav) |
| 2026-03-11 | 설계 변경: 입력 필드 방식으로 전면 재작성 |
| 2026-03-11 | 구현 완료: 입력 필드 + 유효성 검증 |
| 2026-03-11 | 버그 수정: 페이지크기 변경 시 빈 데이터 문제 (onConfigChange 누락 + stale currentPage) |
| 2026-03-11 | 문서 최신화: 버그 수정 내역 반영, 코드 설계 섹션 제거 (구현 완료) |
@@ -0,0 +1,350 @@
# [계획서] 렉 구조 등록 - 층(floor) 필수 입력 해제
> 관련 문서: [맥락노트](./RFO[맥락]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md)
## 개요
탑씰 회사의 물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서, "층" 필드를 필수 입력에서 선택 입력으로 변경합니다. 현재 "창고 코드 / 층 / 구역" 3개가 모두 필수로 하드코딩되어 있어, 층을 선택하지 않으면 미리보기 생성과 저장이 불가능합니다.
---
## 현재 동작
### 1. 필수 필드 경고 (RackStructureComponent.tsx:291~298)
층을 선택하지 않으면 빨간 경고가 표시됨:
```tsx
const missingFields = useMemo(() => {
const missing: string[] = [];
if (!context.warehouseCode) missing.push("창고 코드");
if (!context.floor) missing.push("층"); // ← 하드코딩 필수
if (!context.zone) missing.push("구역");
return missing;
}, [context]);
```
> "다음 필드를 먼저 입력해주세요: **층**"
### 2. 미리보기 생성 차단 (RackStructureComponent.tsx:517~521)
`missingFields`에 "층"이 포함되어 있으면 `generatePreview()` 실행이 차단됨:
```tsx
if (missingFields.length > 0) {
alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`);
return;
}
```
### 3. 위치 코드 생성 (RackStructureComponent.tsx:497~513)
floor가 없으면 기본값 `"1"`을 사용하여 위치 코드를 생성:
```tsx
const floor = context?.floor || "1";
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
// 예: WH001-1층A구역-01-1
```
### 4. 기존 데이터 조회 (RackStructureComponent.tsx:378~432)
floor가 비어있으면 기존 데이터 조회 자체를 건너뜀 → 중복 체크 불가:
```tsx
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
setExistingLocations([]);
return;
}
```
### 5. 렉 구조 화면 감지 (buttonActions.ts:692~698)
floor가 비어있으면 렉 구조 화면으로 인식하지 않음 → 일반 저장으로 빠짐:
```tsx
const isRackStructureScreen =
context.tableName === "warehouse_location" &&
context.formData?.floor && // ← floor 없으면 false
context.formData?.zone &&
!rackStructureLocations;
```
### 6. 저장 전 중복 체크 (buttonActions.ts:2085~2131)
floor가 없으면 중복 체크 전체를 건너뜀:
```tsx
if (warehouseCode && floor && zone) {
// 중복 체크 로직
}
```
---
## 변경 후 동작
### 1. 필수 필드에서 "층" 제거
- "창고 코드"와 "구역"만 필수
- 층을 선택하지 않아도 경고가 뜨지 않음
### 2. 미리보기 생성 정상 동작
- 층 없이도 미리보기 생성 가능
- 위치 코드에서 층 부분을 생략하여 깔끔하게 생성
### 3. 위치 코드 생성 규칙 변경
- 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일)
- 층 없을 때: `WH001-A구역-01-1` (층 부분 생략)
### 4. 기존 데이터 조회 (중복 체크)
- 층 있을 때: `warehouse_code + floor + zone`으로 조회 (기존과 동일)
- 층 없을 때: `warehouse_code + zone`으로 조회 (floor 조건 제외)
### 5. 렉 구조 화면 감지
- floor 유무와 관계없이 `warehouse_location` 테이블 + zone 필드가 있으면 렉 구조 화면으로 인식
### 6. 저장 시 floor 값
- 층 선택함: `floor = "1층"` 등 선택한 값 저장
- 층 미선택: `floor = NULL`로 저장
---
## 시각적 예시
| 상태 | 경고 메시지 | 미리보기 | 위치 코드 | DB floor 값 |
|------|------------|---------|-----------|------------|
| 창고+층+구역 모두 선택 | 없음 | 생성 가능 | `WH001-1층A구역-01-1` | `"1층"` |
| 창고+구역만 선택 (층 미선택) | 없음 | 생성 가능 | `WH001-A구역-01-1` | `NULL` |
| 창고만 선택 | "구역을 먼저 입력해주세요" | 차단 | - | - |
| 아무것도 미선택 | "창고 코드, 구역을 먼저 입력해주세요" | 차단 | - | - |
---
## 아키텍처
### 데이터 흐름 (변경 전)
```mermaid
flowchart TD
A[사용자: 창고/층/구역 입력] --> B{필수 필드 검증}
B -->|층 없음| C[경고: 층을 입력하세요]
B -->|3개 다 있음| D[기존 데이터 조회<br/>warehouse_code + floor + zone]
D --> E[미리보기 생성]
E --> F{저장 버튼}
F --> G[렉 구조 화면 감지<br/>floor && zone 필수]
G --> H[중복 체크<br/>warehouse_code + floor + zone]
H --> I[일괄 INSERT<br/>floor = 선택값]
```
### 데이터 흐름 (변경 후)
```mermaid
flowchart TD
A[사용자: 창고/구역 입력<br/>층은 선택사항] --> B{필수 필드 검증}
B -->|창고 or 구역 없음| C[경고: 해당 필드를 입력하세요]
B -->|창고+구역 있음| D{floor 값 존재?}
D -->|있음| E1[기존 데이터 조회<br/>warehouse_code + floor + zone]
D -->|없음| E2[기존 데이터 조회<br/>warehouse_code + zone]
E1 --> F[미리보기 생성]
E2 --> F
F --> G{저장 버튼}
G --> H[렉 구조 화면 감지<br/>zone만 필수]
H --> I{floor 값 존재?}
I -->|있음| J1[중복 체크<br/>warehouse_code + floor + zone]
I -->|없음| J2[중복 체크<br/>warehouse_code + zone]
J1 --> K[일괄 INSERT<br/>floor = 선택값]
J2 --> K2[일괄 INSERT<br/>floor = NULL]
```
### 컴포넌트 관계
```mermaid
graph LR
subgraph 프론트엔드
A[폼 필드<br/>창고/층/구역] -->|formData| B[RackStructureComponent<br/>필수 검증 + 미리보기]
B -->|locations 배열| C[buttonActions.ts<br/>화면 감지 + 중복 체크 + 저장]
end
subgraph 백엔드
C -->|POST /dynamic-form/save| D[DynamicFormApi<br/>데이터 저장]
D --> E[(warehouse_location<br/>floor: nullable)]
end
style B fill:#fff3cd,stroke:#ffc107
style C fill:#fff3cd,stroke:#ffc107
```
> 노란색 = 이번에 수정하는 부분
---
## 변경 대상 파일
| 파일 | 수정 내용 | 수정 규모 |
|------|----------|----------|
| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증에서 floor 제거, 위치 코드 생성 로직 수정, 기존 데이터 조회 로직 수정 | ~20줄 |
| `frontend/lib/utils/buttonActions.ts` | 렉 구조 화면 감지 조건 수정, 중복 체크 조건 수정 | ~10줄 |
### 사전 확인 필요
| 확인 항목 | 내용 |
|----------|------|
| DB 스키마 | `warehouse_location.floor` 컬럼이 `NULL` 허용인지 확인. NOT NULL이면 `ALTER TABLE` 필요 |
---
## 코드 설계
### 1. 필수 필드 검증 수정 (RackStructureComponent.tsx:291~298)
```tsx
// 변경 전
const missingFields = useMemo(() => {
const missing: string[] = [];
if (!context.warehouseCode) missing.push("창고 코드");
if (!context.floor) missing.push("층");
if (!context.zone) missing.push("구역");
return missing;
}, [context]);
// 변경 후
const missingFields = useMemo(() => {
const missing: string[] = [];
if (!context.warehouseCode) missing.push("창고 코드");
if (!context.zone) missing.push("구역");
return missing;
}, [context]);
```
### 2. 위치 코드 생성 수정 (RackStructureComponent.tsx:497~513)
```tsx
// 변경 전
const floor = context?.floor || "1";
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
// 변경 후
const floor = context?.floor;
const floorPrefix = floor ? `${floor}` : "";
const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`;
// 층 있을 때: WH001-1층A구역-01-1
// 층 없을 때: WH001-A구역-01-1
```
### 3. 기존 데이터 조회 수정 (RackStructureComponent.tsx:378~432)
```tsx
// 변경 전
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
setExistingLocations([]);
return;
}
const searchParams = {
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
floor: { value: floorForQuery, operator: "equals" },
zone: { value: zoneForQuery, operator: "equals" },
};
// 변경 후
if (!warehouseCodeForQuery || !zoneForQuery) {
setExistingLocations([]);
return;
}
const searchParams: Record<string, any> = {
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
zone: { value: zoneForQuery, operator: "equals" },
};
if (floorForQuery) {
searchParams.floor = { value: floorForQuery, operator: "equals" };
}
```
### 4. 렉 구조 화면 감지 수정 (buttonActions.ts:692~698)
```tsx
// 변경 전
const isRackStructureScreen =
context.tableName === "warehouse_location" &&
context.formData?.floor &&
context.formData?.zone &&
!rackStructureLocations;
// 변경 후
const isRackStructureScreen =
context.tableName === "warehouse_location" &&
context.formData?.zone &&
!rackStructureLocations;
```
### 5. 저장 전 중복 체크 수정 (buttonActions.ts:2085~2131)
```tsx
// 변경 전
if (warehouseCode && floor && zone) {
const existingResponse = await DynamicFormApi.getTableData(tableName, {
search: {
warehouse_code: { value: warehouseCode, operator: "equals" },
floor: { value: floor, operator: "equals" },
zone: { value: zone, operator: "equals" },
},
// ...
});
}
// 변경 후
if (warehouseCode && zone) {
const searchParams: Record<string, any> = {
warehouse_code: { value: warehouseCode, operator: "equals" },
zone: { value: zone, operator: "equals" },
};
if (floor) {
searchParams.floor = { value: floor, operator: "equals" };
}
const existingResponse = await DynamicFormApi.getTableData(tableName, {
search: searchParams,
// ...
});
}
```
---
## 적용 범위 및 영향도
### 이번 변경은 전역 설정
방법 B는 렉 구조 컴포넌트 코드에서 직접 "층 필수"를 제거하는 방식이므로, 이 컴포넌트를 사용하는 **모든 회사**에 동일하게 적용됩니다.
| 회사 | 변경 후 |
|------|--------|
| 탑씰 | 층 안 골라도 됨 (요청 사항) |
| 다른 회사 | 층 안 골라도 됨 (동일하게 적용) |
### 기존 사용자에 대한 영향
- 층을 안 골라도 **되는** 것이지, 안 골라야 **하는** 것이 아님
- 기존처럼 층을 선택하면 **완전히 동일하게** 동작함 (하위 호환 보장)
- 즉, 기존 사용 패턴을 유지하는 회사에는 아무런 차이가 없음
### 회사별 독립 제어가 필요한 경우
만약 특정 회사는 층을 필수로 유지하고, 다른 회사는 선택으로 해야 하는 상황이 발생하면, 방법 A(설정 기능 추가)로 업그레이드가 필요합니다. 이번 방법 B의 변경은 향후 방법 A로 전환할 때 충돌 없이 확장 가능합니다.
---
## 설계 원칙
- "창고 코드"와 "구역"의 필수 검증은 기존과 동일하게 유지
- 층을 선택한 경우의 동작은 기존과 완전히 동일 (하위 호환)
- 층 미선택 시 위치 코드에서 층 부분을 깔끔하게 생략 (폴백값 "1" 사용하지 않음)
- 중복 체크는 가용한 필드 기준으로 수행 (floor 없으면 warehouse_code + zone 기준)
- DB에는 NULL로 저장하여 "미입력"을 정확하게 표현 (프로젝트 표준 패턴)
- 특수 문자열("상관없음" 등) 사용하지 않음 (프로젝트 관행에 맞지 않으므로)
@@ -0,0 +1,92 @@
# [맥락노트] 렉 구조 등록 - 층(floor) 필수 입력 해제
> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md)
---
## 왜 이 작업을 하는가
- 탑씰 회사에서 창고 렉 구조 등록 시 "층"을 선택하지 않아도 되게 해달라는 요청
- 현재 코드에 창고 코드 / 층 / 구역 3개가 필수로 하드코딩되어 있어, 층 미선택 시 미리보기 생성과 저장이 모두 차단됨
- 층 필수 검증이 6곳에 분산되어 있어 한 곳만 고치면 다른 곳에서 오류 발생
---
## 핵심 결정 사항과 근거
### 1. 방법 B(하드코딩 제거) 채택, 방법 A(설정 기능) 미채택
- **결정**: 코드에서 floor 필수 조건을 직접 제거
- **근거**: 이 프로젝트의 다른 모달/컴포넌트들은 모두 코드에서 직접 "필수/선택"을 정해놓는 방식을 사용. 설정으로 필수 여부를 바꿀 수 있게 만든 패턴은 기존에 없음
- **대안 검토**:
- 방법 A(ConfigPanel에 requiredFields 설정 추가): 유연하지만 4파일 수정 + 프로젝트에 없던 새 패턴 도입 → 기각
- "상관없음" 값 추가 후 null 변환: 프로젝트 어디에서도 magic value → null 변환 패턴을 쓰지 않음 → 기각
- "상관없음" 값만 추가 (코드 무변경): DB에 "상관없음" 텍스트가 저장되어 데이터가 지저분함 → 기각
- **향후**: 회사별 독립 제어가 필요해지면 방법 A로 확장 가능 (충돌 없음)
### 2. 전역 적용 (회사별 독립 설정 아님)
- **결정**: 렉 구조 컴포넌트를 사용하는 모든 회사에 동일 적용
- **근거**: 방법 B는 코드 직접 수정이므로 회사별 분기 불가. 단, 기존처럼 층을 선택하면 완전히 동일하게 동작하므로 다른 회사에 실질적 영향 없음 (선택 안 해도 "되는" 것이지, 안 해야 "하는" 것이 아님)
### 3. floor 미선택 시 NULL 저장 (특수값 아님)
- **결정**: floor를 선택하지 않으면 DB에 `NULL` 저장
- **근거**: 프로젝트 표준 패턴. `UserFormModal``email: formData.email || null`, `EnhancedFormService`의 빈 문자열 → null 자동 변환 등과 동일한 방식
- **대안 검토**: "상관없음" 저장 후 null 변환 → 프로젝트에서 미사용 패턴이므로 기각
### 4. 위치 코드에서 층 부분 생략 (폴백값 "1" 사용 안 함)
- **결정**: floor 없을 때 위치 코드에서 층 부분을 아예 빼버림
- **근거**: 기존 코드는 `context?.floor || "1"`로 폴백하여 1층을 선택한 것처럼 위장됨. 이는 잘못된 데이터를 만들 수 있음
- **결과**:
- 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일)
- 층 없을 때: `WH001-A구역-01-1` (층 부분 없이 깔끔)
### 5. 중복 체크는 가용 필드 기준으로 수행
- **결정**: floor 없으면 `warehouse_code + zone`으로 중복 체크, floor 있으면 `warehouse_code + floor + zone`으로 중복 체크
- **근거**: 기존 코드는 floor 없으면 중복 체크 전체를 건너뜀 → 중복 데이터 발생 위험. 가용 필드 기준으로 체크하면 floor 유무와 관계없이 안전
### 6. 렉 구조 화면 감지에서 floor 조건 제거
- **결정**: `buttonActions.ts``isRackStructureScreen` 조건에서 `context.formData?.floor` 제거
- **근거**: floor 없으면 렉 구조 화면으로 인식되지 않아 일반 단건 저장으로 빠짐 → 예기치 않은 동작. zone만으로 감지해야 floor 미선택 시에도 렉 구조 일괄 저장이 정상 동작
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 수정 대상 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증, 위치 코드 생성, 기존 데이터 조회 |
| 수정 대상 | `frontend/lib/utils/buttonActions.ts` | 화면 감지, 중복 체크 |
| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | RackStructureContext, FieldMapping 등 |
| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | 필드 매핑 설정 (이번에 수정 안 함) |
| 저장 모달 | `frontend/components/screen/SaveModal.tsx` | 필수 검증 (DB NOT NULL 기반, 별도 확인 필요) |
| 사전 확인 | DB `warehouse_location.floor` 컬럼 | NULL 허용 여부 확인, NOT NULL이면 ALTER TABLE 필요 |
---
## 기술 참고
### 수정 포인트 6곳 요약
| # | 파일 | 행 | 내용 | 수정 방향 |
|---|------|-----|------|----------|
| 1 | RackStructureComponent.tsx | 291~298 | missingFields에서 floor 체크 | floor 체크 제거 |
| 2 | RackStructureComponent.tsx | 517~521 | 미리보기 생성 차단 | 1번 수정으로 자동 해결 |
| 3 | RackStructureComponent.tsx | 497~513 | 위치 코드 생성 `floor \|\| "1"` | 폴백값 제거, 없으면 생략 |
| 4 | RackStructureComponent.tsx | 378~432 | 기존 데이터 조회 조건 | floor 없어도 조회 가능하게 |
| 5 | buttonActions.ts | 692~698 | 렉 구조 화면 감지 | floor 조건 제거 |
| 6 | buttonActions.ts | 2085~2131 | 저장 전 중복 체크 | floor 조건부로 포함 |
### 프로젝트 표준 optional 필드 처리 패턴
```
빈 값 → null 변환: value || null (UserFormModal)
nullable 자동 변환: value === "" && isNullable === "Y" → null (EnhancedFormService)
Select placeholder: "__none__" → "" 또는 undefined (여러 ConfigPanel)
```
이번 변경은 위 패턴들과 일관성을 유지합니다.
@@ -0,0 +1,57 @@
# [체크리스트] 렉 구조 등록 - 층(floor) 필수 입력 해제
> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [맥락노트](./RFO[맥락]-렉구조-층필수해제.md)
---
## 공정 상태
- 전체 진행률: **100%** (완료)
- 현재 단계: 전체 완료
---
## 구현 체크리스트
### 0단계: 사전 확인
- [x] DB `warehouse_location.floor` 컬럼 nullable 여부 확인 → 이미 NULL 허용 상태, 변경 불필요
### 1단계: RackStructureComponent.tsx 수정
- [x] `missingFields`에서 `if (!context.floor) missing.push("층")` 제거 (291~298행)
- [x] `generateLocationCode`에서 `context?.floor || "1"` 폴백 제거, floor 없으면 위치 코드에서 생략 (497~513행)
- [x] `loadExistingLocations`에서 floor 없어도 조회 가능하도록 조건 수정 (378~432행)
- [x] `searchParams`에 floor를 조건부로 포함하도록 변경
### 2단계: buttonActions.ts 수정
- [x] `isRackStructureScreen` 조건에서 `context.formData?.floor` 제거 (692~698행)
- [x] `handleRackStructureBatchSave` 중복 체크에서 floor를 조건부로 포함 (2085~2131행)
### 3단계: 검증
- [x] 층 선택 + 구역 선택: 기존과 동일하게 동작 확인
- [x] 층 미선택 + 구역 선택: 경고 없이 미리보기 생성 가능 확인
- [x] 층 미선택 시 위치 코드에 층 부분이 빠져있는지 확인
- [x] 층 미선택 시 저장 정상 동작 확인
- [x] 층 미선택 시 기존 데이터 중복 체크 정상 동작 확인
- [x] 창고 코드 미입력 시 여전히 경고 표시되는지 확인
- [x] 구역 미입력 시 여전히 경고 표시되는지 확인
### 4단계: 정리
- [x] 린트 에러 없음 확인 (기존 WARNING 1개만 존재, 이번 변경과 무관)
- [x] 이 체크리스트 완료 표시 업데이트
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-10 | 1단계 코드 수정 완료 (RackStructureComponent.tsx) |
| 2026-03-10 | 2단계 코드 수정 완료 (buttonActions.ts) |
| 2026-03-10 | 린트 에러 확인 완료 |
| 2026-03-10 | 사용자 검증 완료, 전체 작업 완료 |
+41 -7
View File
@@ -123,15 +123,49 @@
- [ ] 비활성 탭: 캐시에서 복원
- [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제
### 6-3. 캐시 키 관리 (clearTabStateCache)
### 6-3. 캐시 키 관리 (clearTabCache)
탭 닫기/새로고침 시 관련 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}`
- `tab-cache-{tabId}` (폼/스크롤 캐시)
- `tableState_{tabId}_*` (컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터)
- `pageSize_{tabId}_*` (표시갯수)
- `filterSettings_{tabId}_*` (검색 필터 설정)
- `groupSettings_{tabId}_*` (그룹 설정)
### 6-4. F5 새로고침 시 캐시 정책 (구현 완료)
| 탭 상태 | F5 시 동작 |
|---------|-----------|
| **활성 탭** | `clearTabCache(activeTabId)` → 캐시 전체 삭제 → fresh API 호출 |
| **비활성 탭** | 캐시 유지 → 탭 전환 시 복원 |
**구현 방식**: `TabContent.tsx`에 모듈 레벨 플래그(`hasHandledPageLoad`)를 사용.
전체 페이지 로드 시 모듈이 재실행되어 플래그가 `false`로 리셋.
SPA 내비게이션에서는 모듈이 유지되므로 `true`로 남아 중복 실행 방지.
### 6-5. 탭 바 새로고침 버튼 (구현 완료)
`tabStore.refreshTab(tabId)` 호출 시:
1. `clearTabCache(tabId)` → 해당 탭의 모든 sessionStorage 캐시 삭제
2. `refreshKey` 증가 → 컴포넌트 리마운트 → 기본값으로 초기화
### 6-6. 저장소 분류 기준 (구현 완료)
| 데이터 성격 | 저장소 | 키 구조 | 비고 |
|------------|--------|---------|------|
| 탭별 캐시 | sessionStorage | `{prefix}_{tabId}_{tableName}` | 탭 닫으면 소멸 |
| 사용자 설정 | localStorage | `{prefix}_{tableName}_{userId}` | 세션 간 보존 |
**탭별 캐시 (sessionStorage)**:
- tableState: 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터
- pageSize: 표시갯수
- filterSettings: 검색 필터 설정
- groupSettings: 그룹 설정
**사용자 설정 (localStorage)**:
- table_column_visibility: 컬럼 표시/숨김
- table_sort_state: 정렬 상태
- table_column_order: 컬럼 순서
---
+380
View File
@@ -0,0 +1,380 @@
# 결재 시스템 v2 사용 가이드
## 개요
결재 시스템 v2는 기존 순차결재(escalation) 외에 다양한 결재 방식을 지원합니다.
| 결재 유형 | 코드 | 설명 |
|-----------|------|------|
| 순차결재 (기본) | `escalation` | 결재선 순서대로 한 명씩 처리 |
| 전결 (자기결재) | `self` | 상신자 본인이 직접 승인 (결재자 불필요) |
| 합의결재 | `consensus` | 같은 단계에 여러 결재자 → 전원 승인 필요 |
| 후결 | `post` | 먼저 실행 후 나중에 결재 (결재 전 상태에서도 업무 진행) |
추가 기능:
- **대결 위임**: 부재 시 다른 사용자에게 결재 위임
- **통보 단계**: 결재선에 통보만 하는 단계 (자동 승인 처리)
- **긴급도**: `normal` / `urgent` / `critical`
- **혼합형 결재선**: 한 결재선에 결재/합의/통보 단계를 자유롭게 조합
---
## DB 스키마 변경사항
### 마이그레이션 적용
```bash
# 개발 DB에 마이그레이션 적용
psql -h 39.117.244.52 -p 11132 -U postgres -d plm -f db/migrations/1051_approval_system_v2.sql
psql -h 39.117.244.52 -p 11132 -U postgres -d plm -f db/migrations/1052_rename_proxy_id_to_id.sql
```
### 변경된 테이블
#### approval_requests (추가 컬럼)
| 컬럼 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| approval_type | VARCHAR(20) | 'escalation' | self/escalation/consensus/post |
| is_post_approved | BOOLEAN | FALSE | 후결 처리 완료 여부 |
| post_approved_at | TIMESTAMPTZ | NULL | 후결 처리 시각 |
| urgency | VARCHAR(20) | 'normal' | normal/urgent/critical |
#### approval_lines (추가 컬럼)
| 컬럼 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| step_type | VARCHAR(20) | 'approval' | approval/consensus/notification |
| proxy_for | VARCHAR(50) | NULL | 대결 시 원래 결재자 ID |
| proxy_reason | TEXT | NULL | 대결 사유 |
| is_required | BOOLEAN | TRUE | 필수 결재 여부 |
#### approval_proxy_settings (신규)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | SERIAL PK | |
| company_code | VARCHAR(20) NOT NULL | |
| original_user_id | VARCHAR(50) | 원래 결재자 |
| proxy_user_id | VARCHAR(50) | 대결자 |
| start_date | DATE | 위임 시작일 |
| end_date | DATE | 위임 종료일 |
| reason | TEXT | 위임 사유 |
| is_active | CHAR(1) | 'Y'/'N' |
---
## API 엔드포인트
모든 API는 `/api/approval` 접두사 + JWT 인증 필수.
### 결재 요청 (Requests)
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/requests` | 목록 조회 |
| GET | `/requests/:id` | 상세 조회 (lines 포함) |
| POST | `/requests` | 결재 요청 생성 |
| POST | `/requests/:id/cancel` | 결재 회수 |
| POST | `/requests/:id/post-approve` | 후결 처리 |
#### 결재 요청 생성 Body
```typescript
{
title: string;
target_table: string;
target_record_id: string;
approval_type?: "self" | "escalation" | "consensus" | "post"; // 기본: escalation
urgency?: "normal" | "urgent" | "critical"; // 기본: normal
definition_id?: number;
target_record_data?: Record<string, any>;
approvers: Array<{
approver_id: string;
step_order: number;
step_type?: "approval" | "consensus" | "notification"; // 기본: approval
}>;
}
```
#### 결재 유형별 요청 예시
**전결 (self)**: 결재자 없이 본인 즉시 승인
```typescript
await createApprovalRequest({
title: "긴급 출장비 전결",
target_table: "expense",
target_record_id: "123",
approval_type: "self",
approvers: [],
});
```
**합의결재 (consensus)**: 같은 step_order에 여러 결재자
```typescript
await createApprovalRequest({
title: "프로젝트 예산안 합의",
target_table: "budget",
target_record_id: "456",
approval_type: "consensus",
approvers: [
{ approver_id: "user1", step_order: 1, step_type: "consensus" },
{ approver_id: "user2", step_order: 1, step_type: "consensus" },
{ approver_id: "user3", step_order: 1, step_type: "consensus" },
],
});
```
**혼합형 결재선**: 결재 → 합의 → 통보 조합
```typescript
await createApprovalRequest({
title: "신규 채용 승인",
target_table: "recruitment",
target_record_id: "789",
approval_type: "escalation",
approvers: [
{ approver_id: "teamLead", step_order: 1, step_type: "approval" },
{ approver_id: "hrManager", step_order: 2, step_type: "consensus" },
{ approver_id: "cfo", step_order: 2, step_type: "consensus" },
{ approver_id: "ceo", step_order: 3, step_type: "approval" },
{ approver_id: "secretary", step_order: 4, step_type: "notification" },
],
});
```
**후결 (post)**: 먼저 실행 후 나중에 결재
```typescript
await createApprovalRequest({
title: "긴급 자재 발주",
target_table: "purchase_order",
target_record_id: "101",
approval_type: "post",
urgency: "urgent",
approvers: [
{ approver_id: "manager", step_order: 1, step_type: "approval" },
],
});
```
### 결재 처리 (Lines)
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/my-pending` | 내 결재 대기 목록 |
| POST | `/lines/:lineId/process` | 승인/반려 처리 |
#### 승인/반려 Body
```typescript
{
action: "approved" | "rejected";
comment?: string;
proxy_reason?: string; // 대결 시 사유
}
```
대결 처리: 원래 결재자가 아닌 사용자가 처리하면 자동으로 대결 설정 확인 후 `proxy_for`, `proxy_reason` 기록.
### 대결 위임 설정 (Proxy Settings)
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/proxy-settings` | 위임 목록 |
| POST | `/proxy-settings` | 위임 생성 |
| PUT | `/proxy-settings/:id` | 위임 수정 |
| DELETE | `/proxy-settings/:id` | 위임 삭제 |
| GET | `/proxy-settings/check/:userId` | 활성 대결자 확인 |
#### 대결 생성 Body
```typescript
{
original_user_id: string;
proxy_user_id: string;
start_date: string; // "2026-03-10"
end_date: string; // "2026-03-20"
reason?: string;
is_active?: "Y" | "N";
}
```
### 템플릿 (Templates)
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/templates` | 템플릿 목록 |
| GET | `/templates/:id` | 템플릿 상세 (steps 포함) |
| POST | `/templates` | 템플릿 생성 |
| PUT | `/templates/:id` | 템플릿 수정 |
| DELETE | `/templates/:id` | 템플릿 삭제 |
---
## 프론트엔드 화면
### 1. 결재 요청 모달 (`ApprovalRequestModal`)
경로: `frontend/components/approval/ApprovalRequestModal.tsx`
- 결재 유형 선택: 상신결재 / 전결 / 합의결재 / 후결
- 템플릿 불러오기: 등록된 템플릿에서 결재선 자동 세팅
- 전결 시 결재자 섹션 숨김 + "본인이 직접 승인합니다" 안내
- 합의결재 시 결재자 레이블 "합의 결재자"로 변경
- 후결 시 안내 배너 표시
- 혼합형 step_type 뱃지 표시 (결재/합의/통보)
### 2. 결재함 (`/admin/approvalBox`)
경로: `frontend/app/(main)/admin/approvalBox/page.tsx`
탭 구성:
- **수신함**: 내가 결재할 건 목록
- **상신함**: 내가 요청한 건 목록
- **대결 설정**: 대결 위임 CRUD
대결 설정 기능:
- 위임자/대결자 사용자 검색 (디바운스 300ms)
- 시작일/종료일 설정
- 활성/비활성 토글
- 기간 중복 체크 (서버 측)
- 등록/수정/삭제 모달
### 3. 결재 템플릿 관리 (`/admin/approvalTemplate`)
경로: `frontend/app/(main)/admin/approvalTemplate/page.tsx`
- 템플릿 목록/검색
- 등록/수정 Dialog
- 단계별 결재 유형 설정 (결재/합의/통보)
- 합의 단계: "합의자 추가" 버튼으로 같은 step_order에 복수 결재자
- 결재자 사용자 검색
### 4. 결재 단계 컴포넌트 (`v2-approval-step`)
경로: `frontend/lib/registry/components/v2-approval-step/`
화면 디자이너에서 사용하는 결재 단계 시각화 컴포넌트:
- 가로형/세로형 스테퍼
- step_order 기준 그룹핑 (합의결재 시 가로 나열)
- step_type 아이콘: 결재(CheckCircle), 합의(Users), 통보(Bell)
- 상태별 색상: 승인(success), 반려(destructive), 대기(warning)
- 대결/후결/전결 뱃지
- 긴급도 표시 (urgent: 주황 dot, critical: 빨강 배경)
---
## API 클라이언트 사용법
```typescript
import {
// 결재 요청
createApprovalRequest,
getApprovalRequests,
getApprovalRequest,
cancelApprovalRequest,
postApproveRequest,
// 대결 위임
getProxySettings,
createProxySetting,
updateProxySetting,
deleteProxySetting,
checkActiveProxy,
// 템플릿 단계
getTemplateSteps,
createTemplateStep,
updateTemplateStep,
deleteTemplateStep,
// 타입
type ApprovalProxySetting,
type CreateApprovalRequestInput,
type ApprovalLineTemplateStep,
} from "@/lib/api/approval";
```
---
## 핵심 로직 설명
### 동시성 보호 (FOR UPDATE)
결재 처리(`processApproval`)에서 동시 승인/반려 방지:
```sql
SELECT * FROM approval_lines WHERE line_id = $1 FOR UPDATE
SELECT * FROM approval_requests WHERE request_id = $1 FOR UPDATE
```
### 대결 자동 감지
결재자가 아닌 사용자가 결재 처리하면:
1. `approval_proxy_settings`에서 활성 대결 설정 확인
2. 대결 설정이 있으면 → `proxy_for`, `proxy_reason` 자동 기록
3. 없으면 → 403 에러
### 통보 단계 자동 처리
`step_type = 'notification'`인 단계가 활성화되면:
1. 해당 단계의 모든 결재자를 자동 `approved` 처리
2. `comment = '자동 통보 처리'` 기록
3. `activateNextStep()` 재귀 호출로 다음 단계 진행
### 합의결재 단계 완료 판정
같은 `step_order`의 모든 결재자가 `approved`여야 다음 단계로:
```sql
SELECT COUNT(*) FROM approval_lines
WHERE request_id = $1 AND step_order = $2
AND status NOT IN ('approved', 'skipped')
```
하나라도 `rejected`면 전체 결재 반려.
---
## 메뉴 등록
결재 관련 화면을 메뉴에 등록하려면:
| 화면 | URL | 메뉴명 예시 |
|------|-----|-------------|
| 결재함 | `/admin/approvalBox` | 결재함 |
| 결재 템플릿 관리 | `/admin/approvalTemplate` | 결재 템플릿 |
| 결재 유형 관리 | `/admin/approvalMng` | 결재 유형 (기존) |
---
## 파일 구조
```
backend-node/src/
├── controllers/
│ ├── approvalController.ts # 결재 유형/템플릿/요청/라인 처리
│ └── approvalProxyController.ts # 대결 위임 CRUD
└── routes/
└── approvalRoutes.ts # 라우트 등록
frontend/
├── app/(main)/admin/
│ ├── approvalBox/page.tsx # 결재함 (수신/상신/대결)
│ ├── approvalTemplate/page.tsx # 템플릿 관리
│ └── approvalMng/page.tsx # 결재 유형 관리 (기존)
├── components/approval/
│ └── ApprovalRequestModal.tsx # 결재 요청 모달
└── lib/
├── api/approval.ts # API 클라이언트
└── registry/components/v2-approval-step/
├── ApprovalStepComponent.tsx # 결재 단계 시각화
└── types.ts # 확장 타입
db/migrations/
├── 1051_approval_system_v2.sql # v2 스키마 확장
└── 1052_rename_proxy_id_to_id.sql # PK 컬럼명 통일
```
@@ -0,0 +1,759 @@
# WACE 시스템 문제점 분석 및 개선 계획
> **작성일**: 2026-03-01
> **상태**: 분석 완료, 계획 수립
> **목적**: 반복적으로 발생하는 시스템 문제의 근본 원인 분석 및 구조적 개선 방안
---
## 목차
1. [문제 요약](#1-문제-요약)
2. [문제 1: AI(Cursor) 대화 길어질수록 정확도 저하](#2-문제-1-aicursor-대화-길어질수록-정확도-저하)
3. [문제 2: 컴포넌트가 일관되지 않게 생성됨](#3-문제-2-컴포넌트가-일관되지-않게-생성됨)
4. [문제 3: 코드 수정 시 다른 곳에 사이드 이펙트 발생](#4-문제-3-코드-수정-시-다른-곳에-사이드-이펙트-발생)
5. [근본 원인 종합](#5-근본-원인-종합)
6. [개선 계획](#6-개선-계획)
7. [우선순위 로드맵](#7-우선순위-로드맵)
---
## 1. 문제 요약
| # | 증상 | 빈도 | 심각도 |
|---|------|------|--------|
| 1 | Cursor로 오래 작업하면 정확도 떨어짐 | 매 세션 | 중 |
| 2 | 로우코드 컴포넌트 생성 시 오류, 비일관성 | 매 컴포넌트 | 높 |
| 3 | 수정/신규 코드가 다른 곳에 영향 (저장 안됨, 특정 기능 깨짐) | 수시 | 높 |
세 문제는 독립적으로 보이지만, **하나의 구조적 원인**에서 파생된다.
---
## 2. 문제 1: AI(Cursor) 대화 길어질수록 정확도 저하
### 2.1. 증상
- 대화 초반에는 정확한 코드를 생성하다가, 30분~1시간 이상 작업하면 엉뚱한 코드 생성
- 이전 맥락을 잊고 같은 질문을 반복하거나, 이미 수정한 부분을 되돌림
- 관련 없는 파일을 수정하거나, 존재하지 않는 함수/변수를 참조
### 2.2. 원인 분석
AI의 컨텍스트 윈도우는 유한하다. 우리 코드베이스의 핵심 파일들이 **비정상적으로 거대**해서, AI가 한 번에 파악해야 할 정보량이 폭발한다.
#### 거대 파일 목록 (상위 10개)
| 파일 | 줄 수 | 역할 |
|------|-------|------|
| `frontend/lib/utils/buttonActions.ts` | **7,609줄** | 버튼 액션 전체 로직 |
| `frontend/components/screen/ScreenDesigner.tsx` | **7,559줄** | 화면 설계기 |
| `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | **6,867줄** | V2 테이블 컴포넌트 |
| `frontend/lib/registry/components/table-list/TableListComponent.tsx` | **6,829줄** | 레거시 테이블 컴포넌트 |
| `frontend/components/screen/EditModal.tsx` | **1,648줄** | 편집 모달 |
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | **1,524줄** | 버튼 컴포넌트 |
| `frontend/components/v2/V2Repeater.tsx` | **1,442줄** | 리피터 컴포넌트 |
| `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | **1,435줄** | 화면 뷰어 |
| `frontend/lib/utils/improvedButtonActionExecutor.ts` | **1,063줄** | 버튼 실행기 |
| `frontend/lib/registry/DynamicComponentRenderer.tsx` | **980줄** | 컴포넌트 렌더러 |
**상위 3개 파일만 합쳐도 22,035줄**이다. AI가 이 파일 하나를 읽는 것만으로도 컨텍스트의 상당 부분을 소모한다.
#### 타입 안전성 부재
```typescript
// frontend/types/component.ts:37-39
export interface ComponentConfig {
[key: string]: any; // 사실상 타입 검증 없음
}
// frontend/types/component.ts:56-78
export interface ComponentRendererProps {
component: any; // ComponentData인데 any로 선언
// ... 중략 ...
[key: string]: any; // 여기도 any
}
```
`any` 타입이 핵심 인터페이스에 사용되어, AI가 "이 prop에 뭘 넣어야 하는지" 추론 불가.
사람이 봐도 모르는데 AI가 알 리가 없다.
#### 이벤트 이름이 문자열 상수
```typescript
// 이 이벤트들이 코드 전체에 흩어져 있음
window.dispatchEvent(new CustomEvent("refreshTable"));
window.dispatchEvent(new CustomEvent("closeEditModal"));
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
window.dispatchEvent(new CustomEvent("refreshTableData"));
window.dispatchEvent(new CustomEvent("saveSuccess"));
window.dispatchEvent(new CustomEvent("closeScreenModal"));
```
문자열 기반이라 AI가 이벤트 흐름을 추적할 수 없다. 어떤 이벤트가 어디서 발생하고 어디서 수신되는지 **정적 분석이 불가능**하다.
### 2.3. 영향
- AI가 파일 하나를 읽으면 다른 파일의 맥락을 잊음
- 함수 시그니처를 추론하지 못하고 잘못된 파라미터를 넣음
- 이벤트 기반 로직을 이해하지 못해 부정확한 코드 생성
---
## 3. 문제 2: 컴포넌트가 일관되지 않게 생성됨
### 3.1. 증상
- 새 컴포넌트를 만들 때마다 구조가 다름
- Config 패널의 UI 패턴이 컴포넌트마다 제각각
- 같은 기능인데 어떤 컴포넌트는 동작하고 어떤 컴포넌트는 안 됨
### 3.2. 원인 분석
#### 컴포넌트 수량과 중복
현재 등록된 컴포넌트 디렉토리: **81개**
이 중 V2와 레거시가 병존하는 **중복 쌍**:
| V2 버전 | 레거시 버전 | 기능 |
|---------|------------|------|
| `v2-table-list` (6,867줄) | `table-list` (6,829줄) | 테이블 |
| `v2-button-primary` (1,524줄) | `button-primary` | 버튼 |
| `v2-card-display` | `card-display` | 카드 표시 |
| `v2-aggregation-widget` | `aggregation-widget` | 집계 위젯 |
| `v2-file-upload` | `file-upload` | 파일 업로드 |
| `v2-split-panel-layout` | `split-panel-layout` | 분할 패널 |
| `v2-section-card` | `section-card` | 섹션 카드 |
| `v2-section-paper` | `section-paper` | 섹션 페이퍼 |
| `v2-category-manager` | `category-manager` | 카테고리 |
| `v2-repeater` | `repeater-field-group` | 리피터 |
| `v2-pivot-grid` | `pivot-grid` | 피벗 그리드 |
| `v2-rack-structure` | `rack-structure` | 랙 구조 |
| `v2-repeat-container` | `repeat-container` | 반복 컨테이너 |
**13쌍이 중복** 존재. `v2-table-list``table-list`는 각각 6,800줄 이상으로, 거의 같은 코드가 두 벌 있다.
#### 패턴은 있지만 강제되지 않음
컴포넌트 표준 구조:
```
v2-example/
├── index.ts # createComponentDefinition()
├── ExampleRenderer.tsx # AutoRegisteringComponentRenderer 상속
├── ExampleComponent.tsx # 실제 UI
├── ExampleConfigPanel.tsx # 설정 패널 (선택)
└── types.ts # ExampleConfig extends ComponentConfig
```
이 패턴을 **문서(`.cursor/rules/component-development-guide.mdc`)에서 설명**하고 있지만:
1. **런타임 검증 없음**: `createComponentDefinition()`이 ID 형식만 검증, 나머지는 자유
2. **Config 타입이 `any`**: `ComponentConfig = { [key: string]: any }` → 아무 값이나 들어감
3. **테스트 0개**: 전체 프론트엔드에 테스트 파일 **1개** (`buttonDataflowPerformance.test.ts`), 컴포넌트 테스트는 **0개**
4. **스캐폴딩 도구 없음**: 수동으로 파일을 만들고 index.ts에 import를 추가해야 함
#### 컴포넌트 간 복잡도 격차
| 분류 | 예시 | 줄 수 | 외부 의존 | Error Boundary |
|------|------|-------|-----------|----------------|
| 단순 표시형 | `v2-text-display` | ~100줄 | 거의 없음 | 없음 |
| 입력형 | `v2-input` | ~500줄 | formData, eventBus | 없음 |
| 버튼 | `v2-button-primary` | 1,524줄 | buttonActions, apiClient, context, eventBus, modalDataStore | 있음 |
| 테이블 | `v2-table-list` | 6,867줄 | 거의 모든 것 | 있음 |
100줄짜리와 6,867줄짜리가 같은 "컴포넌트"로 취급된다. AI에게 "컴포넌트 만들어"라고 하면 어떤 수준으로 만들어야 하는지 기준이 없다.
#### POP 컴포넌트는 완전 별도 시스템
```
frontend/lib/registry/
├── ComponentRegistry.ts # 웹 컴포넌트 레지스트리
├── PopComponentRegistry.ts # POP 컴포넌트 레지스트리 (별도 인터페이스)
```
같은 "컴포넌트"인데 등록 방식, 인터페이스, 설정 구조가 완전히 다르다.
### 3.3. 영향
- 새 컴포넌트를 만들 때 "어떤 컴포넌트를 참고해야 하는지" 불명확
- AI가 참조하는 컴포넌트에 따라 결과물이 달라짐
- Config 구조가 제각각이라 설정 패널 UI도 불일치
---
## 4. 문제 3: 코드 수정 시 다른 곳에 사이드 이펙트 발생
### 4.1. 증상
- 저장 로직 수정했더니 다른 화면에서 저장이 안 됨
- 테이블 관련 코드 수정했더니 모달에서 특정 기능이 깨짐
- 리피터 수정했더니 버튼 동작이 달라짐
### 4.2. 원인 분석
#### 원인 A: window 전역 상태 오염
코드베이스 전체에서 `window.__*` 패턴 사용: **8개 파일, 32회 참조**
| 전역 변수 | 정의 위치 | 사용 위치 | 위험도 |
|-----------|-----------|-----------|--------|
| `window.__v2RepeaterInstances` | `V2Repeater.tsx` (220줄) | `EditModal.tsx`, `buttonActions.ts` (4곳) | **높음** |
| `window.__relatedButtonsTargetTables` | `RelatedDataButtonsComponent.tsx` (25줄) | `v2-table-list`, `table-list`, `buttonActions.ts` | **높음** |
| `window.__relatedButtonsSelectedData` | `RelatedDataButtonsComponent.tsx` (51줄) | `buttonActions.ts` (3113줄) | **높음** |
| `window.__unifiedRepeaterInstances` | `UnifiedRepeater.tsx` (110줄) | `UnifiedRepeater.tsx` | 중간 |
| `window.__AUTH_LOG` | `authLogger.ts` | 디버깅용 | 낮음 |
**사이드 이펙트 시나리오 예시**:
```
1. V2Repeater 마운트 → window.__v2RepeaterInstances에 등록
2. EditModal이 저장 시 → window.__v2RepeaterInstances 체크
3. 만약 Repeater가 언마운트 타이밍에 늦게 정리되면?
→ EditModal은 "리피터가 있다"고 판단
→ 리피터 저장 로직 실행
→ 실제로는 리피터 데이터 없음
→ 저장 실패 또는 빈 데이터 저장
```
#### 원인 B: 이벤트 스파게티
`window.dispatchEvent(new CustomEvent(...))` 사용: **43개 파일, 총 120회 이상**
주요 이벤트와 발신/수신 관계:
```
[refreshTable 이벤트]
발신 (8곳):
- buttonActions.ts (5회)
- BomItemEditorComponent.tsx
- SelectedItemsDetailInputComponent.tsx
- BomTreeComponent.tsx (2회)
- ButtonPrimaryComponent.tsx (레거시)
- ScreenModal.tsx (2회)
- InteractiveScreenViewerDynamic.tsx
수신 (5곳):
- v2-table-list/TableListComponent.tsx
- table-list/TableListComponent.tsx
- SplitPanelLayoutComponent.tsx
- InteractiveScreenViewerDynamic.tsx
- InteractiveScreenViewer.tsx
```
```
[closeEditModal 이벤트]
발신 (4곳):
- buttonActions.ts (4회)
수신 (2곳):
- EditModal.tsx
- screens/[screenId]/page.tsx
```
```
[beforeFormSave 이벤트]
수신 (6곳):
- V2Input.tsx
- V2Repeater.tsx
- BomItemEditorComponent.tsx
- SelectedItemsDetailInputComponent.tsx
- UniversalFormModalComponent.tsx
- V2FormContext.tsx
```
**문제**: 이벤트 이름이 **문자열 상수**이고, 발신과 수신이 **타입으로 연결되지 않음**.
`refreshTable` 이벤트를 `refreshTableData`로 오타내도 컴파일 에러 없이 런타임에서만 발견된다.
#### 원인 C: 이중/삼중 이벤트 시스템
동시에 3개의 이벤트 시스템이 공존:
| 시스템 | 위치 | 방식 | 타입 안전 |
|--------|------|------|-----------|
| `window.dispatchEvent` | 전역 | CustomEvent 문자열 | 없음 |
| `v2EventBus` | `lib/v2-core/events/EventBus.ts` | 타입 기반 pub/sub | 있음 |
| `LegacyEventAdapter` | `lib/v2-core/adapters/LegacyEventAdapter.ts` | 1번↔2번 브릿지 | 부분적 |
어떤 컴포넌트는 `window.dispatchEvent`를 쓰고, 어떤 컴포넌트는 `v2EventBus`를 쓰고, 또 어떤 컴포넌트는 둘 다 쓴다. **같은 이벤트가 두 시스템에서 동시에 발생**할 수 있어 예측 불가능한 동작이 발생한다.
#### 원인 D: SplitPanelContext 이름 충돌
같은 이름의 Context가 2개 존재:
| 위치 | 용도 | 제공하는 것 |
|------|------|------------|
| `frontend/contexts/SplitPanelContext.tsx` | 데이터 전달 | `selectedLeftData`, `transfer()`, `registerReceiver()` |
| `frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx` | 리사이즈/좌표 | `getAdjustedX()`, `dividerX`, `leftWidthPercent` |
import 경로에 따라 **완전히 다른 Context**를 가져온다. AI가 자동완성으로 잘못된 Context를 import하면 런타임에 `undefined` 에러가 발생한다.
#### 원인 E: buttonActions.ts - 7,609줄의 신(God) 파일
이 파일 하나가 다음 기능을 전부 담당:
- 저장 (INSERT/UPDATE/DELETE)
- 모달 열기/닫기
- 리피터 데이터 수집
- 테이블 새로고침
- 파일 업로드
- 외부 API 호출
- 화면 전환
- 데이터 검증
- 이벤트 발송 (33회)
- window 전역 상태 읽기 (5회)
**이 파일의 한 줄을 수정하면, 위의 모든 기능이 영향을 받을 수 있다.**
#### 원인 F: 레거시-V2 코드 동시 존재
```
v2-table-list/TableListComponent.tsx (6,867줄)
table-list/TableListComponent.tsx (6,829줄)
```
거의 같은 코드가 두 벌. 한쪽을 수정하면 다른 쪽은 수정 안 되어 동작이 달라진다.
또한 두 컴포넌트가 **같은 전역 이벤트를 수신**하므로, 한 화면에 둘 다 있으면 이중으로 반응할 수 있다.
#### 원인 G: Error Boundary 미적용
| 컴포넌트 | Error Boundary |
|----------|----------------|
| `v2-button-primary` | 있음 |
| `v2-table-list` | 있음 |
| `v2-repeater` | 있음 |
| `v2-input` | **없음** |
| `v2-select` | **없음** |
| `v2-card-display` | **없음** |
| `v2-text-display` | **없음** |
| 기타 대부분 | **없음** |
Error Boundary가 없는 컴포넌트에서 에러가 발생하면, **상위 컴포넌트까지 전파**되어 화면 전체가 깨진다.
### 4.3. 사이드 이펙트 발생 위험 지도
```
┌─────────────────────────────────────────────────────┐
│ buttonActions.ts │
│ (7,609줄) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 저장 로직 │ │ 모달 로직 │ │ 이벤트 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼──────────────┼─────────────┼─────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌─────────────────┐
│ window.__v2 │ │EditModal │ │ CustomEvent │
│ RepeaterInst │ │(1,648줄) │ │ "refreshTable" │
│ ances │ │ │ │ "closeEditModal" │
└──────┬───────┘ └────┬─────┘ │ "saveSuccess" │
│ │ └───────┬─────────┘
▼ │ │
┌──────────────┐ │ ┌──────▼───────┐
│ V2Repeater │◄─────┘ │ TableList │
│ (1,442줄) │ │ (6,867줄) │
└──────────────┘ │ + 레거시 │
│ (6,829줄) │
└──────────────┘
```
**위 그래프에서 어디를 수정하든 화살표를 따라 다른 곳에 영향이 전파된다.**
---
## 5. 근본 원인 종합
세 가지 문제의 근본 원인은 하나다: **경계(Boundary)가 없는 아키텍처**
| 근본 원인 | 문제 1 영향 | 문제 2 영향 | 문제 3 영향 |
|-----------|-------------|-------------|-------------|
| 거대 파일 (God File) | AI 컨텍스트 소모 | 참조할 기준 불명확 | 수정 영향 범위 광범위 |
| `any` 타입 남발 | AI 타입 추론 불가 | Config 검증 없음 | 런타임 에러 |
| 문자열 이벤트 | AI 이벤트 흐름 추적 불가 | 이벤트 패턴 불일치 | 이벤트 누락/오타 |
| window 전역 상태 | AI 상태 추적 불가 | 컴포넌트 간 의존 증가 | 상태 오염 |
| 테스트 부재 (0개) | 변경 검증 불가 | 컴포넌트 계약 불명 | 사이드 이펙트 감지 불가 |
| 레거시-V2 중복 (13쌍) | AI 혼동 | 어느 쪽을 기준으로? | 한쪽만 수정 시 불일치 |
---
## 6. 개선 계획
### Phase 1: 즉시 효과 (1~2주) - 안전장치 설치
#### 1-1. 이벤트 이름 상수화
**현재**:
```typescript
window.dispatchEvent(new CustomEvent("refreshTable"));
```
**개선**:
```typescript
// frontend/lib/constants/events.ts
export const EVENTS = {
REFRESH_TABLE: "refreshTable",
CLOSE_EDIT_MODAL: "closeEditModal",
SAVE_SUCCESS: "saveSuccess",
SAVE_SUCCESS_IN_MODAL: "saveSuccessInModal",
REPEATER_SAVE_COMPLETE: "repeaterSaveComplete",
REFRESH_CARD_DISPLAY: "refreshCardDisplay",
REFRESH_TABLE_DATA: "refreshTableData",
CLOSE_SCREEN_MODAL: "closeScreenModal",
BEFORE_FORM_SAVE: "beforeFormSave",
} as const;
// 사용
window.dispatchEvent(new CustomEvent(EVENTS.REFRESH_TABLE));
```
**효과**: 오타 방지, AI가 이벤트 흐름 추적 가능, IDE 자동완성 지원
**위험도**: 낮음 (기능 변경 없음, 리팩토링만)
**소요 예상**: 2~3시간
#### 1-2. window 전역 변수 타입 선언
**현재**: `window.__v2RepeaterInstances`를 사용하지만 타입 선언 없음
**개선**:
```typescript
// frontend/types/global.d.ts
declare global {
interface Window {
__v2RepeaterInstances?: Set<string>;
__unifiedRepeaterInstances?: Set<string>;
__relatedButtonsTargetTables?: Set<string>;
__relatedButtonsSelectedData?: {
tableName: string;
selectedRows: any[];
};
__AUTH_LOG?: { show: () => void };
__COMPONENT_REGISTRY__?: Map<string, any>;
}
}
```
**효과**: 타입 안전성 확보, AI가 전역 상태 구조 이해 가능
**위험도**: 낮음 (타입 선언만, 런타임 변경 없음)
**소요 예상**: 1시간
#### 1-3. ComponentConfig에 제네릭 타입 적용
**현재**:
```typescript
export interface ComponentConfig {
[key: string]: any;
}
```
**개선**:
```typescript
export interface ComponentConfig {
[key: string]: unknown; // any → unknown으로 변경하여 타입 체크 강제
}
// 각 컴포넌트에서
export interface ButtonPrimaryConfig extends ComponentConfig {
text: string; // 구체적 타입
action: ButtonAction; // 구체적 타입
variant?: "default" | "destructive" | "outline";
}
```
**효과**: 잘못된 config 값 사전 차단
**위험도**: 중간 (기존 `any` 사용처에서 타입 에러 발생 가능, 점진적 적용 필요)
**소요 예상**: 3~5일 (점진적)
---
### Phase 2: 구조 개선 (2~4주) - 핵심 분리
#### 2-1. buttonActions.ts 분할
**현재**: 7,609줄, 1개 파일
**개선 목표**: 도메인별 분리
```
frontend/lib/actions/
├── index.ts # re-export
├── types.ts # 공통 타입
├── saveActions.ts # INSERT/UPDATE 저장 로직
├── deleteActions.ts # DELETE 로직
├── modalActions.ts # 모달 열기/닫기
├── tableActions.ts # 테이블 새로고침, 데이터 조작
├── repeaterActions.ts # 리피터 데이터 수집/저장
├── fileActions.ts # 파일 업로드/다운로드
├── navigationActions.ts # 화면 전환
├── validationActions.ts # 데이터 검증
└── externalActions.ts # 외부 API 호출
```
**효과**:
- 저장 로직 수정 시 `saveActions.ts`만 영향
- AI가 관련 파일만 읽으면 됨 (7,600줄 → 평균 500줄)
- import 관계로 의존성 명확화
**위험도**: 높음 (가장 많이 사용되는 파일, 신중한 분리 필요)
**소요 예상**: 1~2주
#### 2-2. 이벤트 시스템 통일
**현재**: 3개 시스템 공존 (window CustomEvent, v2EventBus, LegacyEventAdapter)
**개선**:
```typescript
// v2EventBus로 통일, 타입 안전한 이벤트 정의
interface EventMap {
"table:refresh": { tableId?: string };
"modal:close": { modalId: string };
"form:save": { formData: Record<string, any> };
"form:saveComplete": { success: boolean; message?: string };
"repeater:saveComplete": { repeaterId: string };
}
// 사용
v2EventBus.emit("table:refresh", { tableId: "order_table" });
v2EventBus.on("table:refresh", (data) => { /* data.tableId 타입 안전 */ });
```
**마이그레이션 전략**:
1. `v2EventBus``EventMap` 타입 추가
2. 새 코드는 반드시 `v2EventBus` 사용
3. 기존 `window.dispatchEvent``v2EventBus`로 점진적 교체
4. `LegacyEventAdapter`에서 양방향 브릿지 유지 (과도기)
5. 모든 교체 완료 후 `LegacyEventAdapter` 제거
**효과**: 이벤트 흐름 추적 가능, 타입 안전, 디버깅 용이
**위험도**: 중간 (과도기 브릿지로 안전하게 전환)
**소요 예상**: 2~3주
#### 2-3. window 전역 상태 → Zustand 스토어 전환
**현재**:
```typescript
window.__v2RepeaterInstances = new Set();
window.__relatedButtonsSelectedData = { tableName, selectedRows };
```
**개선**:
```typescript
// frontend/lib/stores/componentInstanceStore.ts
import { create } from "zustand";
interface ComponentInstanceState {
repeaterInstances: Set<string>;
relatedButtonsTargetTables: Set<string>;
relatedButtonsSelectedData: {
tableName: string;
selectedRows: any[];
} | null;
registerRepeater: (key: string) => void;
unregisterRepeater: (key: string) => void;
setRelatedData: (data: { tableName: string; selectedRows: any[] }) => void;
clearRelatedData: () => void;
}
export const useComponentInstanceStore = create<ComponentInstanceState>((set) => ({
repeaterInstances: new Set(),
relatedButtonsTargetTables: new Set(),
relatedButtonsSelectedData: null,
registerRepeater: (key) =>
set((state) => {
const next = new Set(state.repeaterInstances);
next.add(key);
return { repeaterInstances: next };
}),
unregisterRepeater: (key) =>
set((state) => {
const next = new Set(state.repeaterInstances);
next.delete(key);
return { repeaterInstances: next };
}),
setRelatedData: (data) => set({ relatedButtonsSelectedData: data }),
clearRelatedData: () => set({ relatedButtonsSelectedData: null }),
}));
```
**효과**:
- 상태 변경 추적 가능 (Zustand devtools)
- 컴포넌트 리렌더링 최적화 (selector 사용)
- window 오염 제거
**위험도**: 중간
**소요 예상**: 1주
---
### Phase 3: 품질 강화 (4~8주) - 예방 체계
#### 3-1. 레거시 컴포넌트 제거
**목표**: V2-레거시 중복 13쌍 → V2만 유지
**전략**:
1. 각 중복 쌍에서 레거시 사용처 검색
2. 사용처가 없는 레거시 컴포넌트 즉시 제거
3. 사용처가 있는 경우 V2로 교체 후 제거
4. `components/index.ts`에서 import 제거
**효과**: 코드베이스 ~15,000줄 감소, AI 혼동 제거
**소요 예상**: 2~3주
#### 3-2. 컴포넌트 스캐폴딩 CLI
**목표**: `npx create-v2-component my-component` 실행 시 표준 구조 자동 생성
```bash
$ npx create-v2-component my-widget --category data
생성 완료:
frontend/lib/registry/components/v2-my-widget/
├── index.ts # 자동 생성
├── MyWidgetRenderer.tsx # 자동 생성
├── MyWidgetComponent.tsx # 템플릿
├── MyWidgetConfigPanel.tsx # 템플릿
└── types.ts # Config 인터페이스 템플릿
components/index.ts에 import 자동 추가 완료
```
**효과**: 컴포넌트 구조 100% 일관성 보장
**소요 예상**: 3~5일
#### 3-3. 핵심 컴포넌트 통합 테스트
**목표**: 사이드 이펙트 감지용 테스트 작성
```typescript
// __tests__/integration/save-flow.test.ts
describe("저장 플로우", () => {
it("버튼 저장 → refreshTable 이벤트 발생", async () => {
const listener = vi.fn();
v2EventBus.on("table:refresh", listener);
await executeSaveAction({ tableName: "test_table", data: mockData });
expect(listener).toHaveBeenCalledTimes(1);
});
it("리피터가 있을 때 저장 → 리피터 데이터도 포함", async () => {
useComponentInstanceStore.getState().registerRepeater("detail_table");
const result = await executeSaveAction({ tableName: "master_table", data: mockData });
expect(result.repeaterDataCollected).toBe(true);
});
});
```
**대상**: 저장/삭제/모달/리피터 흐름 (가장 빈번하게 깨지는 부분)
**효과**: 코드 수정 후 즉시 사이드 이펙트 감지
**소요 예상**: 2~3주
#### 3-4. SplitPanelContext 통합
**목표**: 이름이 같은 2개의 Context → 1개로 통합 또는 명확히 분리
**방안 A - 통합**:
```typescript
// frontend/contexts/SplitPanelContext.tsx에 통합
interface SplitPanelContextValue {
// 데이터 전달 (기존 contexts/ 버전)
selectedLeftData: any;
transfer: (data: any) => void;
registerReceiver: (handler: (data: any) => void) => void;
// 리사이즈 (기존 components/ 버전)
getAdjustedX: (x: number) => number;
dividerX: number;
leftWidthPercent: number;
}
```
**방안 B - 명확 분리**:
```typescript
// SplitPanelDataContext.tsx → 데이터 전달용
// SplitPanelResizeContext.tsx → 리사이즈용
```
**효과**: import 혼동 제거
**소요 예상**: 2~3일
---
### Phase 4: 장기 개선 (8주+) - 아키텍처 전환
#### 4-1. 거대 컴포넌트 분할
| 대상 파일 | 현재 줄 수 | 분할 목표 |
|-----------|-----------|-----------|
| `v2-table-list/TableListComponent.tsx` | 6,867줄 | 훅 분리, 렌더링 분리 → 각 1,000줄 이하 |
| `ScreenDesigner.tsx` | 7,559줄 | 패널별 분리 → 각 1,500줄 이하 |
| `EditModal.tsx` | 1,648줄 | 저장/폼/UI 분리 → 각 500줄 이하 |
| `ButtonPrimaryComponent.tsx` | 1,524줄 | 액션 실행 분리 → 각 500줄 이하 |
#### 4-2. Config 스키마 검증 (Zod)
```typescript
// v2-button-primary/types.ts
import { z } from "zod";
export const ButtonPrimaryConfigSchema = z.object({
text: z.string().default("버튼"),
variant: z.enum(["default", "destructive", "outline", "secondary", "ghost"]).default("default"),
action: z.object({
type: z.enum(["save", "delete", "navigate", "custom"]),
targetTable: z.string().optional(),
// ...
}),
});
export type ButtonPrimaryConfig = z.infer<typeof ButtonPrimaryConfigSchema>;
```
`createComponentDefinition()`에서 스키마 검증을 강제하여 잘못된 config가 등록 시점에 차단되도록 한다.
---
## 7. 우선순위 로드맵
### 즉시 (이번 주)
- [ ] **1-1**: 이벤트 이름 상수 파일 생성 (`frontend/lib/constants/events.ts`)
- [ ] **1-2**: window 전역 변수 타입 선언 (`frontend/types/global.d.ts`)
### 단기 (1~2주)
- [ ] **2-3**: window 전역 상태 → Zustand 스토어 전환
- [ ] **1-3**: ComponentConfig `any``unknown` 점진적 적용
### 중기 (2~4주)
- [ ] **2-1**: buttonActions.ts 분할 (7,609줄 → 도메인별)
- [ ] **2-2**: 이벤트 시스템 통일 (v2EventBus 기반)
- [ ] **3-4**: SplitPanelContext 통합/분리
### 장기 (4~8주)
- [ ] **3-1**: 레거시 컴포넌트 13쌍 제거
- [ ] **3-2**: 컴포넌트 스캐폴딩 CLI
- [ ] **3-3**: 핵심 플로우 통합 테스트
- [ ] **4-1**: 거대 컴포넌트 분할
- [ ] **4-2**: Config 스키마 Zod 검증
---
## 부록: 수치 요약
| 지표 | 현재 | 목표 |
|------|------|------|
| 최대 파일 크기 | 7,609줄 | 1,500줄 이하 |
| 컴포넌트 수 | 81개 (13쌍 중복) | ~55개 (중복 제거) |
| window 전역 변수 | 5개 | 0개 |
| 이벤트 시스템 | 3개 공존 | 1개 (v2EventBus) |
| 테스트 파일 | 1개 | 핵심 플로우 최소 10개 |
| `any` 타입 사용 (핵심 인터페이스) | 3곳 | 0곳 |
| SplitPanelContext 중복 | 2개 | 1개 (또는 명확 분리) |
+361
View File
@@ -0,0 +1,361 @@
# Agent Pipeline 한계점 분석
> 결재 시스템 같은 대규모 크로스도메인 프로젝트에서 현재 파이프라인이 왜 제대로 동작할 수 없는가
---
## 1. 에이전트 컨텍스트 격리 문제
### 현상
`executor.ts``spawnAgent()`는 매번 새로운 Cursor Agent CLI 프로세스를 생성한다. 각 에이전트는 `systemPrompt + taskDescription + fileContext`만 받고, 이전 대화/결정/아키텍처 논의는 전혀 알지 못한다.
```typescript
// executor.ts:64-118
function spawnAgent(agentType, prompt, model, workspacePath, timeoutMs) {
const child = spawn(agentPath, ['--model', model, '--print', '--trust'], {
cwd: workspacePath,
stdio: ['pipe', 'pipe', 'pipe'],
});
child.stdin.write(prompt); // 이게 에이전트가 받는 전부
child.stdin.end();
}
```
### 문제 본질
- 에이전트는 **"왜 이렇게 만들어야 하는지"** 모른다. 단지 task description에 적힌 대로 만든다
- 결재 시스템의 **설계 의도** (한국 기업 결재 문화, 자기결재/상신결재/합의결재/대결/후결)는 task description에 다 담을 수 없다
- PM과 사용자 사이에 오간 **아키텍처 논의** (이벤트 훅 시스템, 제어관리 연동, 엔티티 조인으로 결재 상태 표시) 같은 결정 사항이 전달되지 않는다
### 결재 시스템에서의 구체적 영향
- "ApprovalRequestModal에 결재 유형 선택을 추가해라"라고 지시하면, 에이전트는 기존 모달 코드를 읽겠지만, **왜 그 UI가 그렇게 생겼는지, 다른 패널(TableListConfigPanel)의 Combobox 패턴을 왜 따라야 하는지** 모른다
- 실제로 이 대화에서 Combobox UI가 4번 수정됐다. 매번 "다른 패널 참고해서 만들라"고 해도 패턴을 정확히 못 따라했다
---
## 2. 파일 컨텍스트 3000자 절삭
### 현상
```typescript
// executor.ts:124-138
async function readFileContexts(files, workspacePath) {
for (const file of files) {
const content = await readFile(fullPath, 'utf-8');
contents.push(`--- ${file} ---\n${content.substring(0, 3000)}`); // 3000자 잘림
}
}
```
### 문제 본질
주요 파일들의 실제 크기:
- `approvalController.ts`: ~800줄 (3000자로는 약 100줄, 12.5%만 보인다)
- `improvedButtonActionExecutor.ts`: ~1500줄
- `ButtonConfigPanel.tsx`: ~600줄
- `ApprovalStepConfigPanel.tsx`: ~300줄
에이전트가 수정해야 할 파일의 **전체 구조를 이해할 수 없다**. 앞부분만 보고 import 구문이나 초기 코드만 파악하고, 실제 수정 지점에 도달하지 못한다.
### 결재 시스템에서의 구체적 영향
- `approvalController.ts`를 수정하려면 기존 함수 구조, DB 쿼리 패턴, 에러 처리 방식, 멀티테넌시 적용 패턴을 전부 알아야 한다. 3000자로는 불가능
- `improvedButtonActionExecutor.ts`의 제어관리 연동 패턴을 이해하려면 파일 전체를 봐야 한다
- V2 컴포넌트 표준을 따르려면 기존 컴포넌트(`v2-table-list/` 등)의 전체 구조를 참고해야 한다
---
## 3. 에이전트 간 실시간 소통 부재
### 현상
병렬 실행 시 에이전트들은 **서로의 작업 결과를 실시간으로 공유하지 못한다**:
```typescript
// executor.ts:442-454
if (state.config.parallel) {
const promises = readyTasks.map(async (task, index) => {
if (index > 0) await sleep(index * STAGGER_DELAY); // 500ms 딜레이뿐
return executeAndTrack(task);
});
await Promise.all(promises); // 완료까지 기다린 후 PM이 리뷰
}
```
PM 에이전트가 라운드 후에 리뷰하지만, 이것도 **round-N.md의 텍스트 기반 리뷰**일 뿐이다.
### 문제 본질
- DB 에이전트가 스키마를 변경하면, Backend 에이전트가 그 결과를 **같은 라운드에서 즉시 반영할 수 없다**
- Frontend 에이전트가 "이 API 응답 구조 좀 바꿔줘"라고 Backend에 요청할 수 없다
- 협업 모드(`CollabMessage`)가 존재하지만, 이것도 **라운드 단위의 비동기 메시지**이지 실시간 대화가 아니다
### 결재 시스템에서의 구체적 영향
- DB가 `approval_proxy_settings` 테이블을 만들고, Backend가 대결 API를 만들고, Frontend가 대결 설정 UI를 만드는 과정이 **최소 3라운드**가 필요하다 (각 의존성 해소를 위해)
- 실제로는 Backend가 DB 스키마를 보고 쿼리를 짜는 과정에서 "이 컬럼 타입이 좀 다른 것 같은데"라는 이슈가 생기면, 즉시 수정 불가하고 다음 라운드로 넘어간다
- 라운드당 에이전트 호출 1~3분 + PM 리뷰 1~2분 = **라운드당 최소 3~5분**. 8개 phase를 3라운드씩 = **최소 72~120분 (1~2시간)**
---
## 4. 시스템 프롬프트의 한계 (프로젝트 특수 패턴 부재)
### 현상
`prompts.ts`의 시스템 프롬프트는 **범용적**이다:
```typescript
// prompts.ts:75-118
export const BACKEND_PROMPT = `
# Role
You are a Backend specialist for ERP-node project.
Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query.
// ... 멀티테넌시, 기본 코드 패턴만 포함
`;
```
### 프로젝트 특수 패턴 중 프롬프트에 없는 것들
| 필수 패턴 | 프롬프트 포함 여부 | 영향 |
|-----------|:------------------:|------|
| V2 컴포넌트 레지스트리 (`createComponentDefinition`, `AutoRegisteringComponentRenderer`) | 프론트엔드 프롬프트에 기본 구조만 | 컴포넌트 등록 방식 오류 가능 |
| ConfigPanelBuilder / ConfigSection | 언급만 | 직접 JSX로 패널 만드는 실수 반복 |
| Combobox UI 패턴 (Popover + Command) | 없음 | 실제로 4번 재수정 필요했음 |
| 엔티티 조인 시스템 | 없음 | 결재 상태를 대상 테이블에 표시하는 핵심 기능 구현 불가 |
| 제어관리(Node Flow) 연동 | 없음 | 결재 후 자동 액션 트리거 구현 불가 |
| ButtonActionExecutor 패턴 | 없음 | 결재 버튼 액션 구현 시 기존 패턴 미준수 |
| apiClient 사용법 (frontend/lib/api/) | 간략한 언급 | fetch 직접 사용 가능성 |
| CustomEvent 기반 모달 오픈 | 없음 | approval-modal 열기 방식 이해 불가 |
| 화면 디자이너 컨텍스트 | 없음 | screenTableName 같은 설계 시 컨텍스트 활용 불가 |
### 결재 시스템에서의 구체적 영향
- **이벤트 훅 시스템**을 만들려면 기존 `NodeFlowExecutionService`의 실행 패턴, 액션 타입 enum, 입력/출력 구조를 알아야 하는데, 프롬프트에 전혀 없다
- **엔티티 조인으로 결재 상태 표시**하려면 기존 엔티티 조인 시스템이 어떻게 작동하는지(reverse lookup, join config) 알아야 하는데, 에이전트가 이 시스템 자체를 모른다
---
## 5. 단일 패스 실행 + 재시도의 비효율
### 현상
```typescript
// executor.ts:240-288
async function executeTaskWithRetry(task, state) {
while (task.attempts < task.maxRetries) {
const result = await executeTaskOnce(task, state, retryContext);
task.attempts++;
if (result.success) break;
// 검증 실패 → retryContext에 에러 메시지만 전달
retryContext = failResult.retryContext || `이전 시도 실패: ${result.agentOutput.substring(0, 500)}`;
await sleep(2000);
}
}
```
### 문제 본질
- 재시도 시 에이전트가 받는 건 **이전 에러 메시지 500자**뿐이다
- "Combobox 패턴 대신 Select 박스를 썼다" 같은 **UI/UX 품질 문제**는 L1~L6 검증으로 잡을 수 없다 (빌드는 통과하니까)
- 사용자의 실시간 피드백("이거 다른 패널이랑 UI가 다른데?")을 반영할 수 없다
### 검증 피라미드(L1~L6)가 못 잡는 것들
| 검증 레벨 | 잡을 수 있는 것 | 못 잡는 것 |
|-----------|----------------|-----------|
| L1 (TS 빌드) | 타입 에러, import 오류 | 로직 오류, 패턴 미준수 |
| L2 (앱 빌드) | Next.js 빌드 에러 | 런타임 에러 |
| L3 (API 호출) | 엔드포인트 존재 여부, 기본 응답 | 복잡한 비즈니스 로직 (다단계 결재 플로우) |
| L4 (DB 검증) | 테이블 존재, 기본 CRUD | 결재 상태 전이 로직, 병렬 결재 집계 |
| L5 (브라우저 E2E) | 화면 렌더링, 기본 클릭 | 결재 모달 Combobox UX, 대결 설정 UI 일관성 |
| L6 (커스텀) | 명시적 조건 | 비명시적 품질 요구사항 |
### 결재 시스템에서의 구체적 영향
- "자기결재 시 즉시 approved로 처리"가 올바르게 동작하는지 L3/L4로 검증 가능하지만, **"자기결재 선택 시 결재자 선택 UI가 숨겨지고 즉시 처리된다"는 UX**는 L5 자연어로는 불충분
- "합의결재(병렬)에서 3명 중 2명 승인 + 1명 반려 시 전체 반려" 같은 **엣지 케이스 비즈니스 로직**은 자동 검증이 어렵다
- 결재 완료 후 이벤트 훅 → Node Flow 실행 → 이메일 발송 같은 **체이닝된 비동기 로직**은 E2E로 검증 불가
---
## 6. 태스크 분할의 구조적 한계
### 현상: 파이프라인이 잘 되는 경우
```
[DB 테이블 생성] → [Backend CRUD API] → [Frontend 화면] → [UI 개선]
```
각 태스크가 **독립적**이고, 새 파일을 만들고, 의존성이 단방향이다.
### 현상: 파이프라인이 안 되는 경우 (결재 시스템)
```
[DB 스키마 변경]
↓ ↘
[Controller 수정] [새 API 추가] ← 기존 코드 500줄 이해 필요
↓ ↓ ↑
[모달 수정] [새 화면] ← 기존 UI 패턴 준수 필요 + 엔티티 조인 시스템 이해
[V2 컴포넌트 수정] ← 레지스트리 시스템 + ConfigPanelBuilder 패턴 이해
[이벤트 훅 시스템] ← NodeFlowExecutionService 전체 이해 + 새 시스템 설계
[엔티티 조인 등록] ← 기존 엔티티 조인 시스템 전체 이해
```
### 문제 본질
- **기존 파일 수정**이 대부분이다. 새 파일 생성이 아니라 기존 코드에 기능을 끼워넣어야 한다
- **패턴 준수**가 필수다. "돌아가기만 하면" 안 되고, 기존 시스템과 **일관된 방식**으로 구현해야 한다
- **설계 결정**이 코드 작성보다 중요하다. "이벤트 훅을 어떻게 설계할까?"는 에이전트가 task description만 보고 결정할 수 없다
---
## 7. PM 에이전트의 역할 한계
### 현상
```typescript
// pm-agent.ts:21-70
const PM_SYSTEM_PROMPT = `
# 판단 기준
- 빌드만 통과하면 "complete" 아니다 -- 기능이 실제로 동작해야 "complete"
- 같은 에러 2회 반복 -> instruction에 구체적 해결책 제시
- 같은 에러 3회 반복 -> "fail" 판정
`;
```
PM은 `round-N.md`(에이전트 응답 + git diff + 테스트 결과)와 `progress.md`만 보고 판단한다.
### PM이 할 수 없는 것
| 역할 | PM 가능 여부 | 이유 |
|------|:----------:|------|
| 빌드 실패 원인 파악 | 가능 | 에러 로그가 round-N.md에 있음 |
| 비즈니스 로직 검증 | 불가 | 실제 코드를 읽지 않고 git diff만 봄 |
| UI/UX 품질 판단 | 불가 | 스크린샷 없음, 렌더링 결과 못 봄 |
| 아키텍처 일관성 검증 | 불가 | 전체 시스템 구조를 모름 |
| 기존 패턴 준수 여부 | 불가 | 기존 코드를 참조하지 않음 |
| 사용자 의도 반영 여부 | 불가 | 사용자와 대화 맥락 없음 |
### 결재 시스템에서의 구체적 영향
- PM이 "Backend task 성공, Frontend task 실패"라고 판정할 수는 있지만, **"Backend가 만든 API 응답 구조가 Frontend가 기대하는 것과 다르다"**를 파악할 수 없다
- "이 모달의 Combobox가 다른 패널과 UI가 다르다"는 사용자만 판단 가능
- "이벤트 훅 시스템의 트리거 타이밍이 잘못됐다"는 전체 아키텍처를 이해해야 판단 가능
---
## 8. 안전성 리스크
### 역사적 사고
> "과거 에이전트가 범위 밖 파일 50000줄 삭제하여 2800+ TS 에러 발생"
> — user rules
### 결재 시스템의 리스크
수정 대상 파일이 **시스템 핵심 파일**들이다:
| 파일 | 리스크 |
|------|--------|
| `improvedButtonActionExecutor.ts` (~1500줄) | 모든 버튼 동작의 핵심. 잘못 건드리면 시스템 전체 버튼 동작 불능 |
| `approvalController.ts` (~800줄) | 기존 결재 API 깨질 수 있음 |
| `ButtonConfigPanel.tsx` (~600줄) | 화면 디자이너 설정 패널 전체에 영향 |
| `v2-approval-step/` (5개 파일) | V2 컴포넌트 레지스트리 손상 가능 |
| `AppLayout.tsx` | 전체 레이아웃 메뉴 깨질 수 있음 |
| `UserDropdown.tsx` | 사용자 프로필 메뉴 깨질 수 있음 |
`files` 필드로 범위를 제한하더라도, **에이전트가 `--trust` 모드로 실행**되기 때문에 실제로는 모든 파일에 접근 가능하다:
```typescript
// executor.ts:78
const child = spawn(agentPath, ['--model', model, '--print', '--trust'], {
```
code-guard가 일부 보호하지만, **구조적 파괴(잘못된 import 삭제, 함수 시그니처 변경)는 코드 가드가 감지 불가**하다.
---
## 9. 종합: 파이프라인이 적합한 경우 vs 부적합한 경우
### 적합한 경우 (현재 파이프라인)
| 특성 | 예시 |
|------|------|
| 새 파일 생성 위주 | 새 CRUD 화면 만들기 |
| 독립적 태스크 | 테이블 → API → 화면 순차 |
| 패턴이 단순/반복적 | 표준 CRUD, 표준 Form |
| 검증이 명확 | 빌드 + API 호출 + 브라우저 기본 확인 |
| 컨텍스트 최소 | 기존 시스템 이해 불필요 |
### 부적합한 경우 (결재 시스템)
| 특성 | 결재 시스템 해당 여부 |
|------|:-------------------:|
| 기존 파일 대규모 수정 | 해당 (10+ 파일 수정) |
| 크로스도메인 의존성 | 해당 (DB ↔ BE ↔ FE ↔ 기존 시스템) |
| 복잡한 비즈니스 로직 | 해당 (5가지 결재 유형, 상태 전이, 이벤트 훅) |
| 기존 시스템 깊은 이해 필요 | 해당 (제어관리, 엔티티 조인, 컴포넌트 레지스트리) |
| UI/UX 일관성 필수 | 해당 (Combobox, 모달, 설정 패널 패턴 통일) |
| 설계 결정이 선행 필요 | 해당 (이벤트 훅 아키텍처, 결재 타입 상태 머신) |
| 사용자 피드백 반복 필요 | 해당 (실제로 4회 UI 수정 반복) |
---
## 10. 개선 방향 제안
현재 파이프라인을 결재 시스템 같은 대규모 프로젝트에서 사용하려면 다음이 필요하다:
### 10.1 컨텍스트 전달 강화
- **프로젝트 컨텍스트 파일**: `.cursor/rules/` 수준의 프로젝트 규칙을 에이전트 프롬프트에 동적 주입
- **아키텍처 결정 기록**: PM-사용자 간 논의된 설계 결정을 구조화된 형태로 에이전트에 전달
- **패턴 레퍼런스 파일**: "이 파일을 참고해서 만들어라"를 task description이 아닌 시스템 차원에서 지원
### 10.2 파일 컨텍스트 확대
- 3000자 절삭 → **전체 파일 전달** 또는 최소 10000자 이상
- 관련 파일 자동 탐지 (import 그래프 기반)
- 참고 파일(reference files)과 수정 파일(target files) 구분
### 10.3 에이전트 간 소통 채널
- 라운드 내에서도 에이전트 간 **중간 결과 공유** 가능
- "Backend가 API 스펙을 먼저 정의 → Frontend가 그 스펙 기반으로 구현" 같은 **단계적 소통**
- 질문-응답 프로토콜 (현재 CollabMessage가 있지만 실질적으로 사용 안 됨)
### 10.4 PM 에이전트 강화
- **코드 리뷰 기능**: git diff만 보지 말고 실제 파일을 읽어서 패턴 준수 여부 확인
- **아키텍처 검증**: 전체 시스템 구조와의 일관성 검증
- **사용자 피드백 루프**: PM이 사용자에게 "이 부분 확인 필요합니다" 알림 가능
### 10.5 검증 시스템 확장
- **비즈니스 로직 검증**: 상태 전이 테스트 (결재 플로우 시나리오 자동 실행)
- **UI 일관성 검증**: 스크린샷 비교, 컴포넌트 패턴 분석
- **통합 테스트**: 단일 API 호출이 아닌 시나리오 기반 E2E
### 10.6 안전성 강화
- `--trust` 모드 대신 **파일 범위 제한된 실행 모드**
- 라운드별 git diff 자동 리뷰 (의도치 않은 파일 변경 감지)
- 롤백 자동화 (검증 실패 시 자동 `git checkout`)
---
## 부록: 결재 시스템 파이프라인 실행 시 예상 시나리오
### 시도할 경우 예상되는 실패 패턴
```
Round 1: DB 마이그레이션 (task-1)
→ 성공 가능 (신규 파일 생성이므로)
Round 2: Backend Controller 수정 (task-2)
→ approvalController.ts 3000자만 보고 수정 시도
→ 기존 함수 구조 파악 실패
→ L1 빌드 에러 (import 누락, 타입 불일치)
→ 재시도 1: 에러 메시지 보고 고치지만, 기존 패턴과 다른 방식으로 구현
→ L3 API 테스트 통과 (기능은 동작)
→ 하지만 코드 품질/패턴 불일치 (PM이 감지 불가)
Round 3: Frontend 모달 수정 (task-4)
→ 기존 ApprovalRequestModal 3000자만 보고 수정
→ Combobox 패턴 대신 기본 Select 사용 (다른 패널 참고 불가)
→ L1 빌드 통과, L5 브라우저 테스트도 기본 동작 통과
→ 하지만 UI 일관성 미달 (사용자가 보면 즉시 지적)
Round 4-6: 이벤트 훅 시스템 (task-7)
→ NodeFlowExecutionService 전체 이해 필요한데 3000자만 봄
→ 기존 시스템과 연동 불가능한 독립적 구현 생산
→ PM이 "빌드 통과했으니 complete" 판정
→ 실제로는 기존 제어관리와 전혀 연결 안 됨
최종: 8/8 task "성공" 판정
→ 사용자가 확인: "이거 다 뜯어 고쳐야 하는데?"
→ 파이프라인 2시간 + 사용자 수동 수정 3시간 = 5시간 낭비
→ PM이 직접 했으면 2~3시간에 끝
```
---
*작성일: 2026-03-03*
*대상: Agent Pipeline v3.0 (`_local/agent-pipeline/`)*
*맥락: 결재 시스템 v2 재설계 프로젝트 (`docs/결재시스템_구현_현황.md`)*
+185
View File
@@ -0,0 +1,185 @@
# Modal Repeater Table 디버깅 가이드
## 📊 콘솔 로그 확인 순서
새로고침 후 수주 등록 모달을 열고, 아래 순서대로 콘솔 로그를 확인하세요:
### 1️⃣ 컴포넌트 마운트 (초기 로드)
```
🎬 ModalRepeaterTableComponent 마운트: {
config: {...},
propColumns: [...],
columns: [...],
columnsLength: N, // ⚠️ 0이면 문제!
value: [],
valueLength: 0,
sourceTable: "item_info",
sourceColumns: [...],
uniqueField: "item_number"
}
```
**✅ 정상:**
- `columnsLength: 8` (품번, 품명, 규격, 재질, 수량, 단가, 금액, 납기일)
- `columns` 배열에 각 컬럼의 `field`, `label`, `type` 정보가 있어야 함
**❌ 문제:**
- `columnsLength: 0` → **이것이 문제의 원인!**
- 빈 배열이면 테이블에 컬럼이 표시되지 않음
---
### 2️⃣ 항목 검색 모달 열림
```
🚪 모달 열림 - uniqueField: "item_number", multiSelect: true
```
---
### 3️⃣ 품목 체크 (선택)
```
🖱️ 행 클릭: {
item: { item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... },
uniqueField: "item_number",
itemValue: "SLI-2025-0003",
currentSelected: 0,
selectedValues: []
}
✅ 매칭 발견: { selectedValue: "SLI-2025-0003", itemValue: "SLI-2025-0003", uniqueField: "item_number" }
```
---
### 4️⃣ 추가 버튼 클릭
```
✅ ItemSelectionModal 추가 버튼 클릭: {
selectedCount: 1,
selectedItems: [{ item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... }],
uniqueField: "item_number"
}
```
---
### 5️⃣ 데이터 추가 처리
```
handleAddItems 호출: {
selectedItems: [{ item_number: "SLI-2025-0003", ... }],
currentValue: [],
columns: [...], // ⚠️ 여기도 확인!
calculationRules: [...]
}
📝 기본값 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, ... }]
🔢 계산 필드 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }]
✅ 최종 데이터 (onChange 호출): [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }]
```
---
### 6️⃣ Renderer 업데이트
```
🔄 ModalRepeaterTableRenderer onChange 호출: {
previousValue: [],
newValue: [{ item_number: "SLI-2025-0003", ... }]
}
```
---
### 7️⃣ value 변경 감지
```
📦 ModalRepeaterTableComponent value 변경: {
valueLength: 1,
value: [{ item_number: "SLI-2025-0003", ... }],
columns: [...] // ⚠️ 여기도 확인!
}
```
---
### 8️⃣ 테이블 리렌더링
```
📊 RepeaterTable 데이터 업데이트: {
rowCount: 1,
data: [{ item_number: "SLI-2025-0003", ... }],
columns: ["item_number", "item_name", "specification", "material", "quantity", "selling_price", "amount", "delivery_date"]
}
```
---
## 🔍 문제 진단
### Case 1: columns가 비어있음 (columnsLength: 0)
**원인:**
- 화면관리 시스템에서 modal-repeater-table 컴포넌트의 `columns` 설정을 하지 않음
- DB에 컬럼 설정이 저장되지 않음
**해결:**
1. 화면 관리 페이지로 이동
2. 해당 화면 편집
3. modal-repeater-table 컴포넌트 선택
4. 우측 설정 패널에서 "컬럼 설정" 탭 열기
5. 다음 컬럼들을 추가:
- 품번 (item_number, text, 편집불가)
- 품명 (item_name, text, 편집불가)
- 규격 (specification, text, 편집불가)
- 재질 (material, text, 편집불가)
- 수량 (quantity, number, 편집가능, 기본값: 1)
- 단가 (selling_price, number, 편집가능)
- 금액 (amount, number, 편집불가, 계산필드)
- 납기일 (delivery_date, date, 편집가능)
6. 저장
---
### Case 2: 로그가 8번까지 나오는데 화면에 안 보임
**원인:**
- React 리렌더링 문제
- 화면관리 시스템의 상태 동기화 문제
**해결:**
1. 브라우저 개발자 도구 → Elements 탭
2. `#component-comp_5jdmuzai .border.rounded-md table tbody` 찾기
3. 실제 DOM에 `<tr>` 요소가 추가되었는지 확인
4. 추가되었다면 CSS 문제 (display: none 등)
5. 추가 안 되었다면 컴포넌트 렌더링 문제
---
### Case 3: 로그가 5번까지만 나오고 멈춤
**원인:**
- `onChange` 콜백이 제대로 전달되지 않음
- Renderer의 `updateComponent`가 작동하지 않음
**해결:**
- 이미 수정한 `ModalRepeaterTableRenderer.tsx` 코드 확인
- `handleChange` 함수가 호출되는지 확인
---
## 📝 다음 단계
위 로그를 **모두** 복사해서 공유해주세요. 특히:
1. **🎬 마운트 로그의 `columnsLength` 값**
2. **로그가 어디까지 출력되는지**
3. **Elements 탭에서 `tbody` 내부 HTML 구조**
이 정보로 정확한 문제를 진단할 수 있습니다!
@@ -2,23 +2,29 @@
import dynamic from "next/dynamic";
const VehicleReport = dynamic(() => import("@/components/vehicle/VehicleReport"), {
const VehicleReport = dynamic(
() => import("@/components/vehicle/VehicleReport"),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center">
<div className="text-muted-foreground"> ...</div>
</div>
),
});
}
);
export default function VehicleReportsPage() {
return (
<div className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground"> .</p>
<p className="text-muted-foreground">
.
</p>
</div>
<VehicleReport />
</div>
);
}
@@ -2,21 +2,26 @@
import dynamic from "next/dynamic";
const VehicleTripHistory = dynamic(() => import("@/components/vehicle/VehicleTripHistory"), {
const VehicleTripHistory = dynamic(
() => import("@/components/vehicle/VehicleTripHistory"),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center">
<div className="text-muted-foreground"> ...</div>
</div>
),
});
}
);
export default function VehicleTripsPage() {
return (
<div className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground"> .</p>
<p className="text-muted-foreground">
.
</p>
</div>
<VehicleTripHistory />
</div>
+15 -8
View File
@@ -5,17 +5,22 @@ import { LoginHeader } from "@/components/auth/LoginHeader";
import { LoginForm } from "@/components/auth/LoginForm";
import { LoginFooter } from "@/components/auth/LoginFooter";
/**
*
* useLogin , UI
*/
export default function LoginPage() {
const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } =
useLogin();
const {
formData,
isLoading,
error,
showPassword,
isPopMode,
handleInputChange,
handleLogin,
togglePasswordVisibility,
togglePopMode,
} = useLogin();
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
<div className="w-full max-w-md space-y-8">
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
<div className="w-full max-w-md space-y-6">
<LoginHeader />
<LoginForm
@@ -23,9 +28,11 @@ export default function LoginPage() {
isLoading={isLoading}
error={error}
showPassword={showPassword}
isPopMode={isPopMode}
onInputChange={handleInputChange}
onSubmit={handleLogin}
onTogglePassword={togglePasswordVisibility}
onTogglePop={togglePopMode}
/>
<LoginFooter />
@@ -23,3 +23,4 @@ export default function DynamicAdminPage() {
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,963 @@
"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 { Textarea } from "@/components/ui/textarea";
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,
Loader2,
UserPlus,
GripVertical,
} from "lucide-react";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import {
type ApprovalDefinition,
type ApprovalLineTemplate,
type ApprovalLineTemplateStep,
getApprovalDefinitions,
getApprovalTemplates,
getApprovalTemplate,
createApprovalTemplate,
updateApprovalTemplate,
deleteApprovalTemplate,
} from "@/lib/api/approval";
import { getUserList } from "@/lib/api/user";
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
// ============================================================
// 타입 정의
// ============================================================
type StepType = "approval" | "consensus" | "notification";
interface StepApprover {
approver_type: "user" | "position" | "dept";
approver_user_id?: string;
approver_position?: string;
approver_dept_code?: string;
approver_label?: string;
}
interface StepFormData {
step_order: number;
step_type: StepType;
approvers: StepApprover[];
}
interface TemplateFormData {
template_name: string;
description: string;
definition_id: number | null;
steps: StepFormData[];
}
const STEP_TYPE_OPTIONS: { value: StepType; label: string }[] = [
{ value: "approval", label: "결재" },
{ value: "consensus", label: "합의" },
{ value: "notification", label: "통보" },
];
const STEP_TYPE_BADGE: Record<StepType, { label: string; variant: "default" | "secondary" | "outline" }> = {
approval: { label: "결재", variant: "default" },
consensus: { label: "합의", variant: "secondary" },
notification: { label: "통보", variant: "outline" },
};
const INITIAL_FORM: TemplateFormData = {
template_name: "",
description: "",
definition_id: null,
steps: [
{
step_order: 1,
step_type: "approval",
approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
},
],
};
// ============================================================
// 사용자 검색 컴포넌트
// ============================================================
function UserSearchInput({
value,
label,
onSelect,
onLabelChange,
}: {
value: string;
label: string;
onSelect: (userId: string, userName: string) => void;
onLabelChange: (label: string) => void;
}) {
const [searchText, setSearchText] = useState("");
const [results, setResults] = useState<any[]>([]);
const [showResults, setShowResults] = useState(false);
const [searching, setSearching] = useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowResults(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSearch = useCallback(
async (text: string) => {
setSearchText(text);
if (text.length < 1) {
setResults([]);
setShowResults(false);
return;
}
setSearching(true);
try {
const res = await getUserList({ search: text, limit: 10 });
const users = res?.success !== false ? (res?.data || res || []) : [];
setResults(Array.isArray(users) ? users : []);
setShowResults(true);
} catch {
setResults([]);
} finally {
setSearching(false);
}
},
[],
);
const selectUser = (user: any) => {
const userId = user.user_id || user.userId || "";
const userName = user.user_name || user.userName || userId;
onSelect(userId, userName);
setSearchText("");
setShowResults(false);
};
return (
<div className="space-y-1.5">
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"> ID</Label>
<div ref={containerRef} className="relative">
<Input
value={value || searchText}
onChange={(e) => {
if (value) {
onSelect("", "");
}
handleSearch(e.target.value);
}}
placeholder="ID 또는 이름 검색"
className="h-7 text-xs"
/>
{showResults && results.length > 0 && (
<div className="absolute z-50 mt-1 max-h-40 w-full overflow-y-auto rounded-md border bg-popover shadow-md">
{results.map((user, i) => (
<button
key={i}
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
onClick={() => selectUser(user)}
>
<span className="font-medium">{user.user_name || user.userName}</span>
<span className="text-muted-foreground">({user.user_id || user.userId})</span>
</button>
))}
</div>
)}
{showResults && results.length === 0 && !searching && searchText.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover p-2 text-center text-xs text-muted-foreground shadow-md">
</div>
)}
</div>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={label}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="예: 팀장"
className="h-7 text-xs"
/>
</div>
</div>
</div>
);
}
// ============================================================
// 단계 편집 행 컴포넌트
// ============================================================
function StepEditor({
step,
stepIndex,
onUpdate,
onRemove,
}: {
step: StepFormData;
stepIndex: number;
onUpdate: (stepIndex: number, updated: StepFormData) => void;
onRemove: (stepIndex: number) => void;
}) {
const updateStepType = (newType: StepType) => {
const updated = { ...step, step_type: newType };
if (newType === "notification" && updated.approvers.length > 1) {
updated.approvers = [updated.approvers[0]];
}
onUpdate(stepIndex, updated);
};
const addApprover = () => {
onUpdate(stepIndex, {
...step,
approvers: [
...step.approvers,
{ approver_type: "user", approver_user_id: "", approver_label: "" },
],
});
};
const removeApprover = (approverIdx: number) => {
if (step.approvers.length <= 1) return;
onUpdate(stepIndex, {
...step,
approvers: step.approvers.filter((_, i) => i !== approverIdx),
});
};
const updateApprover = (approverIdx: number, field: string, value: string) => {
onUpdate(stepIndex, {
...step,
approvers: step.approvers.map((a, i) =>
i === approverIdx ? { ...a, [field]: value } : a,
),
});
};
const handleUserSelect = (approverIdx: number, userId: string, userName: string) => {
onUpdate(stepIndex, {
...step,
approvers: step.approvers.map((a, i) =>
i === approverIdx
? { ...a, approver_user_id: userId, approver_label: a.approver_label || userName }
: a,
),
});
};
const badgeInfo = STEP_TYPE_BADGE[step.step_type];
return (
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold">{step.step_order}</span>
<Badge variant={badgeInfo.variant} className="text-[10px]">
{badgeInfo.label}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => onRemove(stepIndex)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select value={step.step_type} onValueChange={(v) => updateStepType(v as StepType)}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STEP_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{step.step_type === "notification" && (
<p className="text-[10px] text-muted-foreground italic">
( - )
</p>
)}
<div className="space-y-2">
{step.approvers.map((approver, aIdx) => (
<div key={aIdx} className="rounded border bg-background p-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground">
{step.step_type === "consensus"
? `합의자 ${aIdx + 1}`
: step.step_type === "notification"
? "통보 대상"
: "결재자"}
</span>
{step.approvers.length > 1 && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive"
onClick={() => removeApprover(aIdx)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={approver.approver_type}
onValueChange={(v) => updateApprover(aIdx, "approver_type", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user" className="text-xs"> </SelectItem>
<SelectItem value="position" className="text-xs"> </SelectItem>
<SelectItem value="dept" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
{approver.approver_type === "user" && (
<UserSearchInput
value={approver.approver_user_id || ""}
label={approver.approver_label || ""}
onSelect={(userId, userName) => handleUserSelect(aIdx, userId, userName)}
onLabelChange={(label) => updateApprover(aIdx, "approver_label", label)}
/>
)}
{approver.approver_type === "position" && (
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={approver.approver_position || ""}
onChange={(e) => updateApprover(aIdx, "approver_position", e.target.value)}
placeholder="예: 부장, 이사"
className="h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={approver.approver_label || ""}
onChange={(e) => updateApprover(aIdx, "approver_label", e.target.value)}
placeholder="예: 팀장"
className="h-7 text-xs"
/>
</div>
</div>
)}
{approver.approver_type === "dept" && (
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"> </Label>
<Input
value={approver.approver_dept_code || ""}
onChange={(e) => updateApprover(aIdx, "approver_dept_code", e.target.value)}
placeholder="예: DEPT001"
className="h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={approver.approver_label || ""}
onChange={(e) => updateApprover(aIdx, "approver_label", e.target.value)}
placeholder="예: 경영지원팀"
className="h-7 text-xs"
/>
</div>
</div>
)}
</div>
))}
</div>
{step.step_type === "consensus" && (
<Button
variant="outline"
size="sm"
onClick={addApprover}
className="h-6 w-full gap-1 text-[10px]"
>
<UserPlus className="h-3 w-3" />
</Button>
)}
</div>
);
}
// ============================================================
// 메인 페이지
// ============================================================
export default function ApprovalTemplatePage() {
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 [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<TemplateFormData>({ ...INITIAL_FORM });
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 stepsToFormData = (steps: ApprovalLineTemplateStep[]): StepFormData[] => {
const stepMap = new Map<number, StepFormData>();
const sorted = [...steps].sort((a, b) => a.step_order - b.step_order);
for (const s of sorted) {
const existing = stepMap.get(s.step_order);
const approver: StepApprover = {
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,
};
if (existing) {
existing.approvers.push(approver);
if (s.step_type) existing.step_type = s.step_type;
} else {
stepMap.set(s.step_order, {
step_order: s.step_order,
step_type: s.step_type || "approval",
approvers: [approver],
});
}
}
return Array.from(stepMap.values()).sort((a, b) => a.step_order - b.step_order);
};
const formDataToSteps = (
steps: StepFormData[],
): Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[] => {
const result: Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[] = [];
for (const step of steps) {
for (const approver of step.approvers) {
result.push({
step_order: step.step_order,
step_type: step.step_type,
approver_type: approver.approver_type,
approver_user_id: approver.approver_user_id || undefined,
approver_position: approver.approver_position || undefined,
approver_dept_code: approver.approver_dept_code || undefined,
approver_label: approver.approver_label || undefined,
});
}
}
return result;
};
const openCreate = () => {
setEditingTpl(null);
setFormData({
template_name: "",
description: "",
definition_id: null,
steps: [
{
step_order: 1,
step_type: "approval",
approvers: [{ 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,
steps:
detail.steps && detail.steps.length > 0
? stepsToFormData(detail.steps)
: [
{
step_order: 1,
step_type: "approval",
approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
},
],
});
setEditOpen(true);
};
const addStep = () => {
setFormData((p) => ({
...p,
steps: [
...p.steps,
{
step_order: p.steps.length + 1,
step_type: "approval",
approvers: [
{
approver_type: "user",
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, updated: StepFormData) => {
setFormData((p) => ({
...p,
steps: p.steps.map((s, i) => (i === idx ? updated : s)),
}));
};
const handleSave = async () => {
if (!formData.template_name.trim()) {
toast.warning("템플릿명을 입력해주세요.");
return;
}
if (formData.steps.length === 0) {
toast.warning("결재 단계를 최소 1개 추가해주세요.");
return;
}
const hasEmptyApprover = formData.steps.some((step) =>
step.approvers.some((a) => {
if (a.approver_type === "user" && !a.approver_user_id) return true;
if (a.approver_type === "position" && !a.approver_position) return true;
if (a.approver_type === "dept" && !a.approver_dept_code) return true;
return false;
}),
);
if (hasEmptyApprover) {
toast.warning("모든 결재자 정보를 입력해주세요.");
return;
}
setSaving(true);
const payload = {
template_name: formData.template_name,
description: formData.description || undefined,
definition_id: formData.definition_id || undefined,
steps: formDataToSteps(formData.steps),
};
let res;
if (editingTpl) {
res = await updateApprovalTemplate(editingTpl.template_id, payload);
} else {
res = await createApprovalTemplate(payload);
}
setSaving(false);
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()),
);
const renderStepSummary = (tpl: ApprovalLineTemplate) => {
if (!tpl.steps || tpl.steps.length === 0) return <span className="text-muted-foreground">-</span>;
const stepMap = new Map<number, { type: StepType; count: number }>();
for (const s of tpl.steps) {
const existing = stepMap.get(s.step_order);
if (existing) {
existing.count++;
} else {
stepMap.set(s.step_order, { type: s.step_type || "approval", count: 1 });
}
}
return (
<div className="flex flex-wrap gap-1">
{Array.from(stepMap.entries())
.sort(([a], [b]) => a - b)
.map(([order, info]) => {
const badge = STEP_TYPE_BADGE[info.type];
return (
<Badge key={order} variant={badge.variant} className="text-[10px]">
{order} {badge.label}
{info.count > 1 && ` (${info.count}명)`}
</Badge>
);
})}
</div>
);
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
const d = new Date(dateStr);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
};
const columns: RDVColumn<ApprovalLineTemplate>[] = [
{
key: "template_name",
label: "템플릿명",
render: (_val, tpl) => (
<span className="font-medium">{tpl.template_name}</span>
),
},
{
key: "description",
label: "설명",
hideOnMobile: true,
render: (_val, tpl) => (
<span className="text-muted-foreground">{tpl.description || "-"}</span>
),
},
{
key: "steps",
label: "단계 구성",
render: (_val, tpl) => renderStepSummary(tpl),
},
{
key: "definition_name",
label: "연결된 유형",
width: "120px",
hideOnMobile: true,
render: (_val, tpl) => (
<span>{tpl.definition_name || "-"}</span>
),
},
{
key: "created_at",
label: "생성일",
width: "100px",
hideOnMobile: true,
className: "text-center",
render: (_val, tpl) => (
<span className="text-center">{formatDate(tpl.created_at)}</span>
),
},
];
const cardFields: RDVCardField<ApprovalLineTemplate>[] = [
{
label: "단계 구성",
render: (tpl) => renderStepSummary(tpl),
},
{
label: "생성일",
render: (tpl) => formatDate(tpl.created_at),
},
];
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>
{/* 검색 + 신규 등록 버튼 */}
<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>
<ResponsiveDataView<ApprovalLineTemplate>
data={filtered}
columns={columns}
keyExtractor={(tpl) => String(tpl.template_id)}
isLoading={loading}
emptyMessage="등록된 결재 템플릿이 없습니다."
skeletonCount={5}
cardTitle={(tpl) => tpl.template_name}
cardSubtitle={(tpl) => tpl.description ? (
<span className="text-muted-foreground text-sm">{tpl.description}</span>
) : undefined}
cardHeaderRight={(tpl) => tpl.definition_name ? (
<Badge variant="outline" className="text-xs">{tpl.definition_name}</Badge>
) : undefined}
cardFields={cardFields}
actionsLabel="관리"
actionsWidth="100px"
renderActions={(tpl) => (
<>
<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>
{/* 등록/수정 Dialog */}
<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 htmlFor="template_name" className="text-xs sm:text-sm">
릿 *
</Label>
<Input
id="template_name"
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 htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
placeholder="템플릿에 대한 설명을 입력하세요"
rows={2}
className="text-xs 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>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
릿 .
</p>
</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) => (
<StepEditor
key={`step-${idx}-${step.step_order}`}
step={step}
stepIndex={idx}
onUpdate={updateStep}
onRemove={removeStep}
/>
))}
</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}
disabled={saving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingTpl ? "수정" : "등록"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> 릿 </AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.template_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>
<ScrollToTop />
</div>
);
}
+31 -30
View File
@@ -69,33 +69,32 @@ const RESOURCE_TYPE_CONFIG: Record<
string,
{ label: string; icon: React.ElementType; color: string }
> = {
MENU: { label: "메뉴", icon: Layout, color: "bg-blue-100 text-blue-700" },
MENU: { label: "메뉴", icon: Layout, color: "bg-primary/10 text-primary" },
SCREEN: { label: "화면", icon: Monitor, color: "bg-purple-100 text-purple-700" },
SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" },
FLOW: { label: "플로우", icon: GitBranch, color: "bg-green-100 text-green-700" },
FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-green-100 text-green-700" },
USER: { label: "사용자", icon: User, color: "bg-orange-100 text-orange-700" },
ROLE: { label: "권한", icon: Shield, color: "bg-red-100 text-red-700" },
PERMISSION: { label: "권한", icon: Shield, color: "bg-red-100 text-red-700" },
FLOW: { label: "플로우", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
DATA: { label: "데이터", icon: Database, color: "bg-gray-100 text-gray-700" },
TABLE: { label: "테이블", icon: Database, color: "bg-gray-100 text-gray-700" },
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" },
};
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
CREATE: { label: "생성", color: "bg-emerald-100 text-emerald-700" },
UPDATE: { label: "수정", color: "bg-blue-100 text-blue-700" },
DELETE: { label: "삭제", color: "bg-red-100 text-red-700" },
UPDATE: { label: "수정", color: "bg-primary/10 text-primary" },
DELETE: { label: "삭제", color: "bg-destructive/10 text-destructive" },
COPY: { label: "복사", color: "bg-violet-100 text-violet-700" },
LOGIN: { label: "로그인", color: "bg-gray-100 text-gray-700" },
LOGIN: { label: "로그인", color: "bg-muted text-foreground" },
STATUS_CHANGE: { label: "상태변경", color: "bg-amber-100 text-amber-700" },
BATCH_CREATE: { label: "배치생성", color: "bg-emerald-100 text-emerald-700" },
BATCH_UPDATE: { label: "배치수정", color: "bg-blue-100 text-blue-700" },
BATCH_DELETE: { label: "배치삭제", color: "bg-red-100 text-red-700" },
BATCH_UPDATE: { label: "배치수정", color: "bg-primary/10 text-primary" },
BATCH_DELETE: { label: "배치삭제", color: "bg-destructive/10 text-destructive" },
};
function formatDateTime(dateStr: string): string {
@@ -203,12 +202,12 @@ function renderChanges(changes: Record<string, unknown>) {
<tr className="bg-muted/50">
<th className="px-3 py-1.5 text-left font-medium"></th>
{hasBefore && (
<th className="px-3 py-1.5 text-left font-medium text-red-600">
<th className="px-3 py-1.5 text-left font-medium text-destructive">
</th>
)}
{hasAfter && (
<th className="px-3 py-1.5 text-left font-medium text-blue-600">
<th className="px-3 py-1.5 text-left font-medium text-primary">
</th>
)}
@@ -234,7 +233,7 @@ function renderChanges(changes: Record<string, unknown>) {
{hasBefore && (
<td className="px-3 py-1.5">
{row.beforeVal !== null ? (
<span className="rounded bg-red-50 px-1.5 py-0.5 text-red-700">
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-destructive">
{row.beforeVal}
</span>
) : (
@@ -245,7 +244,7 @@ function renderChanges(changes: Record<string, unknown>) {
{hasAfter && (
<td className="px-3 py-1.5">
{row.afterVal !== null ? (
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-blue-700">
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-primary">
{row.afterVal}
</span>
) : (
@@ -460,9 +459,9 @@ export default function AuditLogPage() {
<CardContent className="p-4">
<form
onSubmit={handleSearch}
className="flex flex-wrap items-end gap-3"
className="flex flex-col gap-3 sm:flex-wrap sm:flex-row sm:items-end"
>
<div className="min-w-[120px] flex-1">
<div className="w-full sm:min-w-[120px] sm:flex-1">
<label className="text-xs font-medium"></label>
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
@@ -475,7 +474,7 @@ export default function AuditLogPage() {
</div>
</div>
<div className="w-[130px]">
<div className="w-full sm:w-[130px]">
<label className="text-xs font-medium"></label>
<Select
value={filters.resourceType || "all"}
@@ -497,7 +496,7 @@ export default function AuditLogPage() {
</Select>
</div>
<div className="w-[120px]">
<div className="w-full sm:w-[120px]">
<label className="text-xs font-medium"></label>
<Select
value={filters.action || "all"}
@@ -520,7 +519,7 @@ export default function AuditLogPage() {
</div>
{isSuperAdmin && (
<div className="w-[160px]">
<div className="w-full sm:w-[160px]">
<label className="text-xs font-medium"></label>
<Popover open={companyComboOpen} onOpenChange={setCompanyComboOpen}>
<PopoverTrigger asChild>
@@ -604,7 +603,7 @@ export default function AuditLogPage() {
</div>
)}
<div className="w-[160px]">
<div className="w-full sm:w-[160px]">
<label className="text-xs font-medium"></label>
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
<PopoverTrigger asChild>
@@ -685,7 +684,7 @@ export default function AuditLogPage() {
</Popover>
</div>
<div className="w-[130px]">
<div className="w-full sm:w-[130px]">
<label className="text-xs font-medium"></label>
<Input
type="date"
@@ -695,7 +694,7 @@ export default function AuditLogPage() {
/>
</div>
<div className="w-[130px]">
<div className="w-full sm:w-[130px]">
<label className="text-xs font-medium"></label>
<Input
type="date"
@@ -705,7 +704,7 @@ export default function AuditLogPage() {
/>
</div>
<Button type="submit" size="sm" className="h-9">
<Button type="submit" size="sm" className="h-9 w-full sm:w-auto">
<Filter className="mr-1 h-4 w-4" />
</Button>
@@ -816,7 +815,7 @@ export default function AuditLogPage() {
</Badge>
{entry.company_code && entry.company_code !== "*" && (
<span className="text-muted-foreground text-[10px]">
[{entry.company_code}]
[{entry.company_name || entry.company_code}]
</span>
)}
</div>
@@ -861,9 +860,11 @@ export default function AuditLogPage() {
</div>
<div>
<label className="text-muted-foreground text-xs">
</label>
<p className="font-medium">{selectedEntry.company_code}</p>
<p className="font-medium">
{selectedEntry.company_name || selectedEntry.company_code}
</p>
</div>
<div>
<label className="text-muted-foreground text-xs">
@@ -12,7 +12,13 @@ import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation";
import { BatchAPI, BatchMapping, ConnectionInfo, ColumnInfo, BatchMappingRequest } from "@/lib/api/batch";
import {
BatchAPI,
BatchMapping,
ConnectionInfo,
ColumnInfo,
BatchMappingRequest,
} from "@/lib/api/batch";
export default function BatchCreatePage() {
const router = useRouter();
@@ -62,11 +68,11 @@ export default function BatchCreatePage() {
// FROM 커넥션 변경
const handleFromConnectionChange = async (connectionId: string) => {
if (connectionId === "unknown") return;
if (connectionId === 'unknown') return;
const connection = connections.find((conn) => {
if (conn.type === "internal") {
return connectionId === "internal";
const connection = connections.find(conn => {
if (conn.type === 'internal') {
return connectionId === 'internal';
}
return conn.id ? conn.id.toString() === connectionId : false;
});
@@ -90,11 +96,11 @@ export default function BatchCreatePage() {
// TO 커넥션 변경
const handleToConnectionChange = async (connectionId: string) => {
if (connectionId === "unknown") return;
if (connectionId === 'unknown') return;
const connection = connections.find((conn) => {
if (conn.type === "internal") {
return connectionId === "internal";
const connection = connections.find(conn => {
if (conn.type === 'internal') {
return connectionId === 'internal';
}
return conn.id ? conn.id.toString() === connectionId : false;
});
@@ -162,9 +168,9 @@ export default function BatchCreatePage() {
}
// n:1 매핑 검사
const toKey = `${toConnection.type}:${toConnection.id || "internal"}:${toTable}:${toColumn.column_name}`;
const existingMapping = mappings.find((mapping) => {
const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || "internal"}:${mapping.to_table_name}:${mapping.to_column_name}`;
const toKey = `${toConnection.type}:${toConnection.id || 'internal'}:${toTable}:${toColumn.column_name}`;
const existingMapping = mappings.find(mapping => {
const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`;
return existingToKey === toKey;
});
@@ -178,12 +184,12 @@ export default function BatchCreatePage() {
from_connection_id: fromConnection.id || null,
from_table_name: fromTable,
from_column_name: selectedFromColumn.column_name,
from_column_type: selectedFromColumn.data_type || "",
from_column_type: selectedFromColumn.data_type || '',
to_connection_type: toConnection.type,
to_connection_id: toConnection.id || null,
to_table_name: toTable,
to_column_name: toColumn.column_name,
to_column_type: toColumn.data_type || "",
to_column_type: toColumn.data_type || '',
mapping_order: mappings.length + 1,
};
@@ -197,7 +203,7 @@ export default function BatchCreatePage() {
const newMappings = mappings.filter((_, i) => i !== index);
const reorderedMappings = newMappings.map((mapping, i) => ({
...mapping,
mapping_order: i + 1,
mapping_order: i + 1
}));
setMappings(reorderedMappings);
toast.success("매핑이 삭제되었습니다.");
@@ -227,7 +233,7 @@ export default function BatchCreatePage() {
description: description || undefined,
cronSchedule: cronSchedule,
mappings: mappings,
isActive: true,
isActive: true
};
await BatchAPI.createBatchConfig(request);
@@ -244,7 +250,7 @@ export default function BatchCreatePage() {
};
return (
<div className="container mx-auto space-y-6 p-6">
<div className="container mx-auto p-6 space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
@@ -269,7 +275,7 @@ export default function BatchCreatePage() {
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="batchName"> *</Label>
<Input
@@ -303,12 +309,12 @@ export default function BatchCreatePage() {
</Card>
{/* 매핑 설정 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* FROM 섹션 */}
<Card className="border-green-200">
<CardHeader className="bg-green-50">
<CardTitle className="text-green-700">FROM ( )</CardTitle>
<p className="text-sm text-green-600">
<Card className="border-emerald-200">
<CardHeader className="bg-emerald-50">
<CardTitle className="text-emerald-700">FROM ( )</CardTitle>
<p className="text-sm text-emerald-600">
1단계: 커넥션을 2단계: 테이블을 3단계: 컬럼을
</p>
</CardHeader>
@@ -316,7 +322,7 @@ export default function BatchCreatePage() {
<div className="space-y-2">
<Label> </Label>
<Select
value={fromConnection?.type === "internal" ? "internal" : fromConnection?.id?.toString() || ""}
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ""}
onValueChange={handleFromConnectionChange}
disabled={loadingConnections}
>
@@ -324,11 +330,10 @@ export default function BatchCreatePage() {
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
</SelectTrigger>
<SelectContent>
{Array.isArray(connections) &&
connections.map((conn) => (
{Array.isArray(connections) && connections.map((conn) => (
<SelectItem
key={conn.type === "internal" ? "internal" : conn.id?.toString() || conn.name}
value={conn.type === "internal" ? "internal" : conn.id?.toString() || "unknown"}
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
>
{conn.name} ({conn.type})
</SelectItem>
@@ -339,7 +344,11 @@ export default function BatchCreatePage() {
<div className="space-y-2">
<Label> </Label>
<Select value={fromTable} onValueChange={handleFromTableChange} disabled={!fromConnection}>
<Select
value={fromTable}
onValueChange={handleFromTableChange}
disabled={!fromConnection}
>
<SelectTrigger>
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
@@ -356,24 +365,26 @@ export default function BatchCreatePage() {
{/* FROM 컬럼 목록 */}
{fromTable && (
<div className="space-y-2">
<Label className="font-semibold text-blue-600">{fromTable} </Label>
<div className="max-h-80 space-y-2 overflow-y-auto rounded-lg border p-4">
<Label className="text-primary font-semibold">{fromTable} </Label>
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
{fromColumns.map((column) => (
<div
key={column.column_name}
onClick={() => handleFromColumnClick(column)}
className={`cursor-pointer rounded border p-3 transition-colors ${
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedFromColumn?.column_name === column.column_name
? "border-green-300 bg-green-100"
: "border-gray-200 hover:bg-gray-50"
? 'bg-emerald-100 border-green-300'
: 'hover:bg-muted border-border'
}`}
>
<div className="font-medium">{column.column_name}</div>
<div className="text-sm text-gray-500">{column.data_type}</div>
<div className="text-sm text-muted-foreground">{column.data_type}</div>
</div>
))}
{fromColumns.length === 0 && fromTable && (
<div className="py-4 text-center text-gray-500"> ...</div>
<div className="text-center text-muted-foreground py-4">
...
</div>
)}
</div>
</div>
@@ -382,16 +393,18 @@ export default function BatchCreatePage() {
</Card>
{/* TO 섹션 */}
<Card className="border-red-200">
<CardHeader className="bg-red-50">
<CardTitle className="text-red-700">TO ( )</CardTitle>
<p className="text-sm text-red-600">FROM에서 , </p>
<Card className="border-destructive/20">
<CardHeader className="bg-destructive/10">
<CardTitle className="text-destructive">TO ( )</CardTitle>
<p className="text-sm text-destructive">
FROM에서 ,
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label> </Label>
<Select
value={toConnection?.type === "internal" ? "internal" : toConnection?.id?.toString() || ""}
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ""}
onValueChange={handleToConnectionChange}
disabled={loadingConnections}
>
@@ -399,11 +412,10 @@ export default function BatchCreatePage() {
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
</SelectTrigger>
<SelectContent>
{Array.isArray(connections) &&
connections.map((conn) => (
{Array.isArray(connections) && connections.map((conn) => (
<SelectItem
key={conn.type === "internal" ? "internal" : conn.id?.toString() || conn.name}
value={conn.type === "internal" ? "internal" : conn.id?.toString() || "unknown"}
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
>
{conn.name} ({conn.type})
</SelectItem>
@@ -414,7 +426,11 @@ export default function BatchCreatePage() {
<div className="space-y-2">
<Label> </Label>
<Select value={toTable} onValueChange={handleToTableChange} disabled={!toConnection}>
<Select
value={toTable}
onValueChange={handleToTableChange}
disabled={!toConnection}
>
<SelectTrigger>
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
@@ -431,24 +447,26 @@ export default function BatchCreatePage() {
{/* TO 컬럼 목록 */}
{toTable && (
<div className="space-y-2">
<Label className="font-semibold text-blue-600">{toTable} </Label>
<div className="max-h-80 space-y-2 overflow-y-auto rounded-lg border p-4">
<Label className="text-primary font-semibold">{toTable} </Label>
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
{toColumns.map((column) => (
<div
key={column.column_name}
onClick={() => handleToColumnClick(column)}
className={`cursor-pointer rounded border p-3 transition-colors ${
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedFromColumn
? "border-gray-200 hover:bg-red-50"
: "cursor-not-allowed border-gray-300 bg-gray-100"
? 'hover:bg-destructive/10 border-border'
: 'bg-muted border-input cursor-not-allowed'
}`}
>
<div className="font-medium">{column.column_name}</div>
<div className="text-sm text-gray-500">{column.data_type}</div>
<div className="text-sm text-muted-foreground">{column.data_type}</div>
</div>
))}
{toColumns.length === 0 && toTable && (
<div className="py-4 text-center text-gray-500"> ...</div>
<div className="text-center text-muted-foreground py-4">
...
</div>
)}
</div>
</div>
@@ -466,27 +484,31 @@ export default function BatchCreatePage() {
<CardContent>
<div className="space-y-3">
{mappings.map((mapping, index) => (
<div key={index} className="flex items-center justify-between rounded-lg border bg-yellow-50 p-4">
<div key={index} className="flex items-center justify-between p-4 border rounded-lg bg-amber-50">
<div className="flex items-center space-x-4">
<div className="text-sm">
<div className="font-medium">
{mapping.from_table_name}.{mapping.from_column_name}
</div>
<div className="text-gray-500">{mapping.from_column_type}</div>
<div className="text-muted-foreground">
{mapping.from_column_type}
</div>
<ArrowRight className="h-4 w-4 text-gray-400" />
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/70" />
<div className="text-sm">
<div className="font-medium">
{mapping.to_table_name}.{mapping.to_column_name}
</div>
<div className="text-gray-500">{mapping.to_column_type}</div>
<div className="text-muted-foreground">
{mapping.to_column_type}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeMapping(index)}
className="text-red-600 hover:text-red-700"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -499,7 +521,10 @@ export default function BatchCreatePage() {
{/* 저장 버튼 */}
<div className="flex justify-end space-x-4">
<Button variant="outline" onClick={() => router.push("/admin/batchmng")}>
<Button
variant="outline"
onClick={() => router.push("/admin/batchmng")}
>
</Button>
<Button
@@ -507,7 +532,11 @@ export default function BatchCreatePage() {
disabled={loading || mappings.length === 0}
className="flex items-center space-x-2"
>
{loading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{loading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
<span>{loading ? "저장 중..." : "배치 매핑 저장"}</span>
</Button>
</div>
@@ -7,12 +7,23 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { BatchAPI, BatchConfig, BatchMapping, ConnectionInfo } from "@/lib/api/batch";
import {
BatchAPI,
BatchConfig,
BatchMapping,
ConnectionInfo,
} from "@/lib/api/batch";
import { BatchManagementAPI } from "@/lib/api/batchManagement";
interface BatchColumnInfo {
@@ -22,16 +33,16 @@ interface BatchColumnInfo {
}
// 배치 타입 감지 함수
const detectBatchType = (mapping: BatchMapping): "db-to-db" | "restapi-to-db" | "db-to-restapi" => {
const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' | 'db-to-restapi' => {
const fromType = mapping.from_connection_type;
const toType = mapping.to_connection_type;
if (fromType === "restapi" && (toType === "internal" || toType === "external")) {
return "restapi-to-db";
} else if ((fromType === "internal" || fromType === "external") && toType === "restapi") {
return "db-to-restapi";
if (fromType === 'restapi' && (toType === 'internal' || toType === 'external')) {
return 'restapi-to-db';
} else if ((fromType === 'internal' || fromType === 'external') && toType === 'restapi') {
return 'db-to-restapi';
} else {
return "db-to-db";
return 'db-to-db';
}
};
@@ -70,7 +81,7 @@ export default function BatchEditPage() {
const [mappings, setMappings] = useState<BatchMapping[]>([]);
// 배치 타입 감지
const [batchType, setBatchType] = useState<"db-to-db" | "restapi-to-db" | "db-to-restapi" | null>(null);
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
// REST API 미리보기 상태
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
@@ -122,35 +133,33 @@ export default function BatchEditPage() {
console.log("🔗 연결 정보 설정 시작:", firstMapping);
// FROM 연결 정보 설정
if (firstMapping.from_connection_type === "internal") {
setFromConnection({ type: "internal", name: "내부 DB" });
if (firstMapping.from_connection_type === 'internal') {
setFromConnection({ type: 'internal', name: '내부 DB' });
// 내부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection({ type: "internal", name: "내부 DB" }).then((tables) => {
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
console.log("📋 FROM 테이블 목록:", tables);
setFromTables(tables);
// 컬럼 정보도 로드
if (firstMapping.from_table_name) {
BatchAPI.getTableColumns({ type: "internal", name: "내부 DB" }, firstMapping.from_table_name).then(
(columns) => {
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.from_table_name).then(columns => {
console.log("📊 FROM 컬럼 목록:", columns);
setFromColumns(columns);
},
);
});
}
});
} else if (firstMapping.from_connection_id) {
const fromConn = connections.find((c) => c.id === firstMapping.from_connection_id);
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
if (fromConn) {
setFromConnection(fromConn);
// 외부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection(fromConn).then((tables) => {
BatchAPI.getTablesFromConnection(fromConn).then(tables => {
console.log("📋 FROM 테이블 목록:", tables);
setFromTables(tables);
// 컬럼 정보도 로드
if (firstMapping.from_table_name) {
BatchAPI.getTableColumns(fromConn, firstMapping.from_table_name).then((columns) => {
BatchAPI.getTableColumns(fromConn, firstMapping.from_table_name).then(columns => {
console.log("📊 FROM 컬럼 목록:", columns);
setFromColumns(columns);
});
@@ -160,35 +169,33 @@ export default function BatchEditPage() {
}
// TO 연결 정보 설정
if (firstMapping.to_connection_type === "internal") {
setToConnection({ type: "internal", name: "내부 DB" });
if (firstMapping.to_connection_type === 'internal') {
setToConnection({ type: 'internal', name: '내부 DB' });
// 내부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection({ type: "internal", name: "내부 DB" }).then((tables) => {
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
console.log("📋 TO 테이블 목록:", tables);
setToTables(tables);
// 컬럼 정보도 로드
if (firstMapping.to_table_name) {
BatchAPI.getTableColumns({ type: "internal", name: "내부 DB" }, firstMapping.to_table_name).then(
(columns) => {
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.to_table_name).then(columns => {
console.log("📊 TO 컬럼 목록:", columns);
setToColumns(columns);
},
);
});
}
});
} else if (firstMapping.to_connection_id) {
const toConn = connections.find((c) => c.id === firstMapping.to_connection_id);
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
if (toConn) {
setToConnection(toConn);
// 외부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection(toConn).then((tables) => {
BatchAPI.getTablesFromConnection(toConn).then(tables => {
console.log("📋 TO 테이블 목록:", tables);
setToTables(tables);
// 컬럼 정보도 로드
if (firstMapping.to_table_name) {
BatchAPI.getTableColumns(toConn, firstMapping.to_table_name).then((columns) => {
BatchAPI.getTableColumns(toConn, firstMapping.to_table_name).then(columns => {
console.log("📊 TO 컬럼 목록:", columns);
setToColumns(columns);
});
@@ -237,7 +244,7 @@ export default function BatchEditPage() {
console.log(`📊 매핑 #${idx + 1}:`, {
from: `${mapping.from_column_name} (${mapping.from_column_type})`,
to: `${mapping.to_column_name} (${mapping.to_column_type})`,
type: mapping.mapping_type,
type: mapping.mapping_type
});
});
setMappings(config.batch_mappings);
@@ -253,12 +260,12 @@ export default function BatchEditPage() {
console.log("🎯 감지된 배치 타입:", detectedBatchType);
// FROM 연결 정보 설정
if (firstMapping.from_connection_type === "internal") {
setFromConnection({ type: "internal", name: "내부 DB" });
if (firstMapping.from_connection_type === 'internal') {
setFromConnection({ type: 'internal', name: '내부 DB' });
} else if (firstMapping.from_connection_id) {
// 외부 연결은 connections 로드 후 설정
setTimeout(() => {
const fromConn = connections.find((c) => c.id === firstMapping.from_connection_id);
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
if (fromConn) {
setFromConnection(fromConn);
}
@@ -266,12 +273,12 @@ export default function BatchEditPage() {
}
// TO 연결 정보 설정
if (firstMapping.to_connection_type === "internal") {
setToConnection({ type: "internal", name: "내부 DB" });
if (firstMapping.to_connection_type === 'internal') {
setToConnection({ type: 'internal', name: '내부 DB' });
} else if (firstMapping.to_connection_id) {
// 외부 연결은 connections 로드 후 설정
setTimeout(() => {
const toConn = connections.find((c) => c.id === firstMapping.to_connection_id);
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
if (toConn) {
setToConnection(toConn);
}
@@ -282,20 +289,21 @@ export default function BatchEditPage() {
fromTable: firstMapping.from_table_name,
toTable: firstMapping.to_table_name,
fromConnectionType: firstMapping.from_connection_type,
toConnectionType: firstMapping.to_connection_type,
toConnectionType: firstMapping.to_connection_type
});
// 기존 매핑을 mappingList로 변환
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({
id: `mapping-${index}-${Date.now()}`,
dbColumn: mapping.to_column_name || "",
sourceType: (mapping as any).mapping_type === "fixed" ? ("fixed" as const) : ("api" as const),
sourceType: (mapping as any).mapping_type === "fixed" ? "fixed" as const : "api" as const,
apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "",
fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "",
}));
setMappingList(convertedMappingList);
console.log("🔄 변환된 mappingList:", convertedMappingList);
}
} catch (error) {
console.error("❌ 배치 설정 조회 오류:", error);
toast.error("배치 설정을 불러오는데 실패했습니다.");
@@ -317,9 +325,8 @@ export default function BatchEditPage() {
// FROM 연결 변경 시
const handleFromConnectionChange = async (connectionId: string) => {
const connection =
connections.find((c) => c.id?.toString() === connectionId) ||
(connectionId === "internal" ? { type: "internal" as const, name: "내부 DB" } : null);
const connection = connections.find(c => c.id?.toString() === connectionId) ||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
if (connection) {
setFromConnection(connection);
@@ -338,9 +345,8 @@ export default function BatchEditPage() {
// TO 연결 변경 시
const handleToConnectionChange = async (connectionId: string) => {
const connection =
connections.find((c) => c.id?.toString() === connectionId) ||
(connectionId === "internal" ? { type: "internal" as const, name: "내부 DB" } : null);
const connection = connections.find(c => c.id?.toString() === connectionId) ||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
if (connection) {
setToConnection(connection);
@@ -390,18 +396,18 @@ export default function BatchEditPage() {
// 매핑 추가
const addMapping = () => {
const newMapping: BatchMapping = {
from_connection_type: fromConnection?.type === "internal" ? "internal" : "external",
from_connection_id: fromConnection?.type === "internal" ? undefined : fromConnection?.id,
from_connection_type: fromConnection?.type === 'internal' ? 'internal' : 'external',
from_connection_id: fromConnection?.type === 'internal' ? undefined : fromConnection?.id,
from_table_name: fromTable,
from_column_name: "",
from_column_type: "",
to_connection_type: toConnection?.type === "internal" ? "internal" : "external",
to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id,
from_column_name: '',
from_column_type: '',
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
to_table_name: toTable,
to_column_name: "",
to_column_type: "",
mapping_type: "direct",
mapping_order: mappings.length + 1,
to_column_name: '',
to_column_type: '',
mapping_type: 'direct',
mapping_order: mappings.length + 1
};
setMappings([...mappings, newMapping]);
@@ -479,7 +485,8 @@ export default function BatchEditPage() {
}
try {
const method = (first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
const method =
(first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
const paramInfo =
apiParamType !== "none" && apiParamName && apiParamValue
@@ -500,13 +507,15 @@ export default function BatchEditPage() {
paramInfo,
first.from_api_body || undefined,
authTokenMode === "db" ? authServiceName : undefined, // DB 선택 모드일 때 서비스명 전달
dataArrayPath || undefined,
dataArrayPath || undefined
);
setApiPreviewData(result.samples || []);
setFromApiFields(result.fields || []);
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`);
toast.success(
`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`
);
} catch (error: any) {
console.error("REST API 미리보기 오류:", error);
toast.error(error?.message || "API 데이터 미리보기에 실패했습니다.");
@@ -521,7 +530,7 @@ export default function BatchEditPage() {
// 매핑 업데이트
const updateMapping = (index: number, field: keyof BatchMapping, value: any) => {
setMappings((prevMappings) => {
setMappings(prevMappings => {
const updatedMappings = [...prevMappings];
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
return updatedMappings;
@@ -579,11 +588,12 @@ export default function BatchEditPage() {
saveMode,
conflictKey: saveMode === "UPSERT" ? conflictKey : null, // INSERT면 null로 명시적 삭제
authServiceName: authTokenMode === "db" ? authServiceName : null, // 직접입력이면 null로 명시적 삭제
dataArrayPath: dataArrayPath || null,
dataArrayPath: dataArrayPath || null
});
toast.success("배치 설정이 성공적으로 수정되었습니다.");
router.push("/admin/batchmng");
} catch (error) {
console.error("배치 설정 수정 실패:", error);
toast.error("배치 설정 수정에 실패했습니다.");
@@ -595,8 +605,8 @@ export default function BatchEditPage() {
if (loading && !batchConfig) {
return (
<div className="container mx-auto p-6">
<div className="flex h-64 items-center justify-center">
<RefreshCw className="h-8 w-8 animate-spin" />
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-8 h-8 animate-spin" />
<span className="ml-2"> ...</span>
</div>
</div>
@@ -607,7 +617,11 @@ export default function BatchEditPage() {
<div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="flex items-center gap-4 border-b pb-4">
<Button variant="outline" onClick={() => router.push("/admin/batchmng")} className="gap-2">
<Button
variant="outline"
onClick={() => router.push("/admin/batchmng")}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -685,7 +699,11 @@ export default function BatchEditPage() {
<div>
<Label></Label>
<Select
value={fromConnection?.type === "internal" ? "internal" : fromConnection?.id?.toString() || ""}
value={
fromConnection?.type === "internal"
? "internal"
: fromConnection?.id?.toString() || ""
}
onValueChange={handleFromConnectionChange}
>
<SelectTrigger>
@@ -797,7 +815,7 @@ export default function BatchEditPage() {
</SelectContent>
</Select>
)}
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-muted-foreground">
{authTokenMode === "direct"
? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요."
: "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
@@ -856,7 +874,7 @@ export default function BatchEditPage() {
onChange={(e) => setDataArrayPath(e.target.value)}
placeholder="response (예: data.items, results)"
/>
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-muted-foreground">
API . .
<br />
예시: response, data.items, result.list
@@ -884,7 +902,7 @@ export default function BatchEditPage() {
className="min-h-[100px]"
rows={5}
/>
<p className="mt-1 text-xs text-gray-500">API JSON .</p>
<p className="mt-1 text-xs text-muted-foreground">API JSON .</p>
</div>
)}
@@ -892,7 +910,7 @@ export default function BatchEditPage() {
<div className="space-y-4">
<div className="border-t pt-4">
<Label className="text-base font-medium">API </Label>
<p className="mt-1 text-sm text-gray-600"> .</p>
<p className="mt-1 text-sm text-muted-foreground"> .</p>
</div>
<div>
@@ -936,7 +954,9 @@ export default function BatchEditPage() {
</div>
<div>
<Label>{apiParamSource === "static" ? "파라미터 값" : "파라미터 템플릿"} *</Label>
<Label>
{apiParamSource === "static" ? "파라미터 값" : "파라미터 템플릿"} *
</Label>
<Input
value={apiParamValue}
onChange={(e) => setApiParamValue(e.target.value)}
@@ -947,26 +967,26 @@ export default function BatchEditPage() {
}
/>
{apiParamSource === "dynamic" && (
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-muted-foreground">
. : {"{{user_id}}"} ID
</p>
)}
</div>
{apiParamType === "url" && (
<div className="rounded-lg bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-800">URL </div>
<div className="mt-1 text-sm text-blue-700">
<div className="rounded-lg bg-primary/10 p-3">
<div className="text-sm font-medium text-primary">URL </div>
<div className="mt-1 text-sm text-primary">
: /api/users/{`{${apiParamName || "userId"}}`}
</div>
<div className="text-sm text-blue-700"> : /api/users/{apiParamValue || "123"}</div>
<div className="text-sm text-primary"> : /api/users/{apiParamValue || "123"}</div>
</div>
)}
{apiParamType === "query" && (
<div className="rounded-lg bg-green-50 p-3">
<div className="text-sm font-medium text-green-800"> </div>
<div className="mt-1 text-sm text-green-700">
<div className="rounded-lg bg-emerald-50 p-3">
<div className="text-sm font-medium text-emerald-800"> </div>
<div className="mt-1 text-sm text-emerald-700">
: {mappings[0]?.from_table_name || "/api/users"}?{apiParamName || "userId"}=
{apiParamValue || "123"}
</div>
@@ -1000,7 +1020,11 @@ export default function BatchEditPage() {
<div>
<Label></Label>
<Select
value={toConnection?.type === "internal" ? "internal" : toConnection?.id?.toString() || ""}
value={
toConnection?.type === "internal"
? "internal"
: toConnection?.id?.toString() || ""
}
onValueChange={handleToConnectionChange}
>
<SelectTrigger>
@@ -1021,7 +1045,11 @@ export default function BatchEditPage() {
<div>
<Label></Label>
<Select value={toTable} onValueChange={handleToTableChange} disabled={!toConnection}>
<Select
value={toTable}
onValueChange={handleToTableChange}
disabled={!toConnection}
>
<SelectTrigger>
<SelectValue placeholder="대상 테이블을 선택하세요" />
</SelectTrigger>
@@ -1042,7 +1070,11 @@ export default function BatchEditPage() {
<div>
<Label> *</Label>
<Select
value={toConnection?.type === "internal" ? "internal" : toConnection?.id?.toString() || ""}
value={
toConnection?.type === "internal"
? "internal"
: toConnection?.id?.toString() || ""
}
onValueChange={handleToConnectionChange}
>
<SelectTrigger>
@@ -1063,7 +1095,11 @@ export default function BatchEditPage() {
<div>
<Label> *</Label>
<Select value={toTable} onValueChange={handleToTableChange} disabled={!toConnection}>
<Select
value={toTable}
onValueChange={handleToTableChange}
disabled={!toConnection}
>
<SelectTrigger>
<SelectValue placeholder={toConnection ? "테이블을 선택하세요" : "먼저 연결을 선택하세요"} />
</SelectTrigger>
@@ -1115,7 +1151,9 @@ export default function BatchEditPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="INSERT">INSERT ( )</SelectItem>
<SelectItem value="UPSERT">UPSERT ( , )</SelectItem>
<SelectItem value="UPSERT">
UPSERT ( , )
</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1.5 text-xs">
@@ -1146,7 +1184,9 @@ export default function BatchEditPage() {
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1.5 text-xs">UPSERT .</p>
<p className="text-muted-foreground mt-1.5 text-xs">
UPSERT .
</p>
</div>
</CardContent>
</Card>
@@ -1155,7 +1195,11 @@ export default function BatchEditPage() {
{/* API 데이터 미리보기 버튼 */}
{batchType === "restapi-to-db" && (
<div className="flex justify-center">
<Button variant="outline" onClick={previewRestApiData} disabled={mappings.length === 0}>
<Button
variant="outline"
onClick={previewRestApiData}
disabled={mappings.length === 0}
>
<RefreshCw className="mr-2 h-4 w-4" />
API
</Button>
@@ -1186,7 +1230,9 @@ export default function BatchEditPage() {
<div className="space-y-2">
{apiPreviewData.slice(0, 3).map((item, index) => (
<div key={index} className="bg-background rounded border p-2">
<pre className="font-mono text-xs whitespace-pre-wrap">{JSON.stringify(item, null, 2)}</pre>
<pre className="whitespace-pre-wrap font-mono text-xs">
{JSON.stringify(item, null, 2)}
</pre>
</div>
))}
</div>
@@ -1359,15 +1405,24 @@ export default function BatchEditPage() {
) : (
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
{mappings.map((mapping, index) => (
<div key={index} className="bg-background flex items-center gap-2 rounded-lg border p-3">
<div
key={index}
className="bg-background flex items-center gap-2 rounded-lg border p-3"
>
<div className="flex-1">
<Select
value={mapping.from_column_name || ""}
onValueChange={(value) => {
updateMapping(index, "from_column_name", value);
const selectedColumn = fromColumns.find((col) => col.column_name === value);
const selectedColumn = fromColumns.find(
(col) => col.column_name === value
);
if (selectedColumn) {
updateMapping(index, "from_column_type", selectedColumn.data_type);
updateMapping(
index,
"from_column_type",
selectedColumn.data_type
);
}
}}
>
@@ -1376,7 +1431,10 @@ export default function BatchEditPage() {
</SelectTrigger>
<SelectContent>
{fromColumns.map((column) => (
<SelectItem key={column.column_name} value={column.column_name}>
<SelectItem
key={column.column_name}
value={column.column_name}
>
{column.column_name}
</SelectItem>
))}
@@ -1389,9 +1447,15 @@ export default function BatchEditPage() {
value={mapping.to_column_name || ""}
onValueChange={(value) => {
updateMapping(index, "to_column_name", value);
const selectedColumn = toColumns.find((col) => col.column_name === value);
const selectedColumn = toColumns.find(
(col) => col.column_name === value
);
if (selectedColumn) {
updateMapping(index, "to_column_type", selectedColumn.data_type);
updateMapping(
index,
"to_column_type",
selectedColumn.data_type
);
}
}}
>
@@ -1400,14 +1464,22 @@ export default function BatchEditPage() {
</SelectTrigger>
<SelectContent>
{toColumns.map((column) => (
<SelectItem key={column.column_name} value={column.column_name}>
<SelectItem
key={column.column_name}
value={column.column_name}
>
{column.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeMapping(index)}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeMapping(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -1427,13 +1499,24 @@ export default function BatchEditPage() {
) : (
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
{mappings.map((mapping, index) => (
<div key={index} className="bg-background flex items-center gap-2 rounded-lg border p-3">
<div
key={index}
className="bg-background flex items-center gap-2 rounded-lg border p-3"
>
<div className="flex-1">
<Input value={mapping.from_column_name || ""} readOnly className="h-9 text-xs" />
<Input
value={mapping.from_column_name || ""}
readOnly
className="h-9 text-xs"
/>
</div>
<span className="text-muted-foreground text-xs">-&gt;</span>
<div className="flex-1">
<Input value={mapping.to_column_name || ""} readOnly className="h-9 text-xs" />
<Input
value={mapping.to_column_name || ""}
readOnly
className="h-9 text-xs"
/>
</div>
</div>
))}
@@ -1455,7 +1538,11 @@ export default function BatchEditPage() {
onClick={saveBatchConfig}
disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)}
>
{loading ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{loading ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{loading ? "저장 중..." : "배치 설정 저장"}
</Button>
</div>
@@ -3,11 +3,20 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, Search, RefreshCw, Database } from "lucide-react";
import {
Plus,
Search,
RefreshCw,
Database
} from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation";
import { BatchAPI, BatchConfig, BatchMapping } from "@/lib/api/batch";
import {
BatchAPI,
BatchConfig,
BatchMapping,
} from "@/lib/api/batch";
import BatchCard from "@/components/admin/BatchCard";
import { ScrollToTop } from "@/components/common/ScrollToTop";
@@ -61,9 +70,7 @@ export default function BatchManagementPage() {
try {
const response = await BatchAPI.executeBatchConfig(batchId);
if (response.success) {
toast.success(
`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`,
);
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
} else {
toast.error("배치 실행에 실패했습니다.");
}
@@ -82,13 +89,13 @@ export default function BatchManagementPage() {
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
try {
const newStatus = currentStatus === "Y" ? "N" : "Y";
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
console.log("📝 새로운 상태:", newStatus);
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
console.log("✅ API 호출 성공:", result);
toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`);
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
loadBatchConfigs(); // 목록 새로고침
} catch (error) {
console.error("❌ 배치 상태 변경 실패:", error);
@@ -125,12 +132,14 @@ export default function BatchManagementPage() {
}
const tableGroups = new Map<string, number>();
mappings.forEach((mapping) => {
mappings.forEach(mapping => {
const key = `${mapping.from_table_name}${mapping.to_table_name}`;
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
});
const summaries = Array.from(tableGroups.entries()).map(([key, count]) => `${key} (${count}개 컬럼)`);
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
`${key} (${count}개 컬럼)`
);
return summaries.join(", ");
};
@@ -141,35 +150,35 @@ export default function BatchManagementPage() {
};
// 배치 타입 선택 핸들러
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => {
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
console.log("배치 타입 선택:", type);
setIsBatchTypeModalOpen(false);
if (type === "db-to-db") {
if (type === 'db-to-db') {
// 기존 DB → DB 배치 생성 페이지로 이동
console.log("DB → DB 페이지로 이동:", "/admin/batchmng/create");
router.push("/admin/batchmng/create");
} else if (type === "restapi-to-db") {
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
router.push('/admin/batchmng/create');
} else if (type === 'restapi-to-db') {
// 새로운 REST API 배치 페이지로 이동
console.log("REST API → DB 페이지로 이동:", "/admin/batch-management-new");
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
try {
router.push("/admin/batch-management-new");
router.push('/admin/batch-management-new');
console.log("라우터 push 실행 완료");
} catch (error) {
console.error("라우터 push 오류:", error);
// 대안: window.location 사용
window.location.href = "/admin/batch-management-new";
window.location.href = '/admin/batch-management-new';
}
}
};
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="flex min-h-screen flex-col bg-background">
<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>
<p className="text-sm text-muted-foreground"> .</p>
</div>
{/* 검색 및 액션 영역 */}
@@ -178,7 +187,7 @@ export default function BatchManagementPage() {
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="배치명 또는 설명으로 검색..."
value={searchTerm}
@@ -194,17 +203,24 @@ export default function BatchManagementPage() {
disabled={loading}
className="h-10 gap-2 text-sm font-medium"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 액션 버튼 영역 */}
<div className="flex items-center gap-4">
<div className="text-muted-foreground text-sm">
<span className="text-foreground font-semibold">{batchConfigs.length.toLocaleString()}</span>
<div className="text-sm text-muted-foreground">
{" "}
<span className="font-semibold text-foreground">
{batchConfigs.length.toLocaleString()}
</span>{" "}
</div>
<Button onClick={handleCreateBatch} className="h-10 gap-2 text-sm font-medium">
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
@@ -213,18 +229,22 @@ export default function BatchManagementPage() {
{/* 배치 목록 */}
{batchConfigs.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<Database className="text-muted-foreground h-12 w-12" />
<Database className="h-12 w-12 text-muted-foreground" />
<div className="space-y-2">
<h3 className="text-lg font-semibold"> </h3>
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
</p>
</div>
{!searchTerm && (
<Button onClick={handleCreateBatch} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
@@ -253,7 +273,7 @@ export default function BatchManagementPage() {
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="h-10 text-sm font-medium"
>
@@ -278,7 +298,7 @@ export default function BatchManagementPage() {
<Button
variant="outline"
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-10 text-sm font-medium"
>
@@ -289,41 +309,41 @@ export default function BatchManagementPage() {
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="bg-background/80 fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
<div className="bg-card w-full max-w-2xl rounded-lg border p-6 shadow-lg">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
<div className="space-y-6">
<h2 className="text-center text-xl font-semibold"> </h2>
<h2 className="text-xl font-semibold text-center"> </h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* DB → DB */}
<button
className="bg-card hover:border-primary hover:bg-accent flex flex-col items-center gap-4 rounded-lg border p-6 shadow-sm transition-all"
onClick={() => handleBatchTypeSelect("db-to-db")}
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
onClick={() => handleBatchTypeSelect('db-to-db')}
>
<div className="flex items-center gap-2">
<Database className="text-primary h-8 w-8" />
<Database className="h-8 w-8 text-primary" />
<span className="text-muted-foreground"></span>
<Database className="text-primary h-8 w-8" />
<Database className="h-8 w-8 text-primary" />
</div>
<div className="space-y-1 text-center">
<div className="text-lg font-medium">DB DB</div>
<div className="text-muted-foreground text-sm"> </div>
<div className="text-sm text-muted-foreground"> </div>
</div>
</button>
{/* REST API → DB */}
<button
className="bg-card hover:border-primary hover:bg-accent flex flex-col items-center gap-4 rounded-lg border p-6 shadow-sm transition-all"
onClick={() => handleBatchTypeSelect("restapi-to-db")}
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
onClick={() => handleBatchTypeSelect('restapi-to-db')}
>
<div className="flex items-center gap-2">
<span className="text-2xl">🌐</span>
<span className="text-muted-foreground"></span>
<Database className="text-primary h-8 w-8" />
<Database className="h-8 w-8 text-primary" />
</div>
<div className="space-y-1 text-center">
<div className="text-lg font-medium">REST API DB</div>
<div className="text-muted-foreground text-sm">REST API에서 </div>
<div className="text-sm text-muted-foreground">REST API에서 </div>
</div>
</button>
</div>
@@ -3,10 +3,8 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, Search, Edit, Trash2, TestTube, Filter } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Search, Edit, Trash2, TestTube } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import {
@@ -29,9 +27,16 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
import { ScrollToTop } from "@/components/common/ScrollToTop";
// API 응답에 실제로 포함되는 필드를 위한 확장 타입
type ExternalCallConfigWithDate = ExternalCallConfig & {
created_date?: string;
};
export default function ExternalCallConfigsPage() {
const [configs, setConfigs] = useState<ExternalCallConfig[]>([]);
const [configs, setConfigs] = useState<ExternalCallConfigWithDate[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [filter, setFilter] = useState<ExternalCallConfigFilter>({
@@ -50,15 +55,17 @@ export default function ExternalCallConfigsPage() {
const fetchConfigs = async () => {
try {
setLoading(true);
const response = await ExternalCallConfigAPI.getConfigs({
...filter,
search: searchQuery.trim() || undefined,
});
const filterWithSearch: Record<string, string | undefined> = { ...filter };
const trimmed = searchQuery.trim();
if (trimmed) {
filterWithSearch.search = trimmed;
}
const response = await ExternalCallConfigAPI.getConfigs(filterWithSearch as ExternalCallConfigFilter);
if (response.success) {
setConfigs(response.data || []);
setConfigs((response.data || []) as ExternalCallConfigWithDate[]);
} else {
showErrorToast("외부 호출 설정 조회에 실패했습니다", response.message, {
showErrorToast("외부 호출 설정 조회에 실패했습니다", response.error, {
guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.",
});
}
@@ -72,9 +79,10 @@ export default function ExternalCallConfigsPage() {
}
};
// 초기 로드 및 필터/검색 변경 시 재조회
// 초기 로드 및 필터 변경 시 재조회
useEffect(() => {
fetchConfigs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter]);
// 검색 실행
@@ -118,7 +126,7 @@ export default function ExternalCallConfigsPage() {
toast.success("외부 호출 설정이 삭제되었습니다.");
fetchConfigs();
} else {
showErrorToast("외부 호출 설정 삭제에 실패했습니다", response.message, {
showErrorToast("외부 호출 설정 삭제에 실패했습니다", response.error, {
guidance: "잠시 후 다시 시도해 주세요.",
});
}
@@ -140,10 +148,10 @@ export default function ExternalCallConfigsPage() {
try {
const response = await ExternalCallConfigAPI.testConfig(config.id);
if (response.success && response.data?.success) {
toast.success(`테스트 성공: ${response.data.message}`);
if (response.success) {
toast.success(`테스트 성공: ${response.message || "정상"}`);
} else {
toast.error(`테스트 실패: ${response.data?.message || response.message}`);
toast.error(`테스트 실패: ${response.message || response.error || "알 수 없는 오류"}`);
}
} catch (error) {
console.error("외부 호출 설정 테스트 오류:", error);
@@ -171,23 +179,103 @@ export default function ExternalCallConfigsPage() {
return API_TYPE_OPTIONS.find((option) => option.value === apiType)?.label || apiType;
};
// ResponsiveDataView 컬럼 정의
const columns: RDVColumn<ExternalCallConfigWithDate>[] = [
{
key: "config_name",
label: "설정명",
render: (_v, row) => <span className="font-medium">{row.config_name}</span>,
},
{
key: "call_type",
label: "호출 타입",
width: "120px",
render: (_v, row) => <Badge variant="outline">{getCallTypeLabel(row.call_type)}</Badge>,
},
{
key: "api_type",
label: "API 타입",
width: "120px",
render: (_v, row) =>
row.api_type ? (
<Badge variant="secondary">{getApiTypeLabel(row.api_type)}</Badge>
) : (
<span className="text-muted-foreground">-</span>
),
},
{
key: "description",
label: "설명",
render: (_v, row) =>
row.description ? (
<span className="block max-w-xs truncate text-muted-foreground" title={row.description}>
{row.description}
</span>
) : (
<span className="text-muted-foreground">-</span>
),
},
{
key: "is_active",
label: "상태",
width: "80px",
render: (_v, row) => (
<Badge variant={row.is_active === "Y" ? "default" : "destructive"}>
{row.is_active === "Y" ? "활성" : "비활성"}
</Badge>
),
},
{
key: "created_date",
label: "생성일",
width: "120px",
render: (_v, row) =>
row.created_date ? new Date(row.created_date).toLocaleDateString() : "-",
},
];
// 모바일 카드 필드 정의
const cardFields: RDVCardField<ExternalCallConfigWithDate>[] = [
{
label: "호출 타입",
render: (c) => <Badge variant="outline">{getCallTypeLabel(c.call_type)}</Badge>,
},
{
label: "API 타입",
render: (c) =>
c.api_type ? (
<Badge variant="secondary">{getApiTypeLabel(c.api_type)}</Badge>
) : (
<span className="text-muted-foreground">-</span>
),
},
{
label: "설명",
render: (c) => (
<span className="max-w-[200px] truncate">{c.description || "-"}</span>
),
},
{
label: "생성일",
render: (c) =>
c.created_date ? new Date(c.created_date).toLocaleDateString() : "-",
},
];
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-4 sm: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">Discord, Slack, .</p>
<p className="text-sm text-muted-foreground">Discord, Slack, .</p>
</div>
{/* 검색 및 필터 영역 */}
<div className="space-y-4">
{/* 첫 번째 줄: 검색 + 추가 버튼 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* 검색 및 필터 영역 (반응형) */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="w-full sm:w-[320px]">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="설정 이름 또는 설명으로 검색..."
value={searchQuery}
@@ -196,18 +284,18 @@ export default function ExternalCallConfigsPage() {
className="h-10 pl-10 text-sm"
/>
</div>
</div>
<Button onClick={handleSearch} variant="outline" className="h-10 gap-2 text-sm font-medium">
<Search className="h-4 w-4" />
</Button>
</div>
<Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 두 번째 줄: 필터 */}
{/* 필터 영역 */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Select
value={filter.call_type || "all"}
@@ -274,107 +362,64 @@ export default function ExternalCallConfigsPage() {
</SelectContent>
</Select>
</div>
</div>
{/* 설정 목록 */}
<div className="bg-card rounded-lg border shadow-sm">
{loading ? (
// 로딩 상태
<div className="flex h-64 items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
) : configs.length === 0 ? (
// 빈 상태
<div className="flex h-64 flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</div>
) : (
// 설정 테이블 목록
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<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">API </TableHead>
<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 text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{configs.map((config) => (
<TableRow key={config.id} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-sm font-medium">{config.config_name}</TableCell>
<TableCell className="h-16 text-sm">
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge>
</TableCell>
<TableCell className="h-16 text-sm">
{config.api_type ? (
<Badge variant="secondary">{getApiTypeLabel(config.api_type)}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="h-16 text-sm">
<div className="max-w-xs">
{config.description ? (
<span className="text-muted-foreground block truncate" title={config.description}>
{config.description}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</TableCell>
<TableCell className="h-16 text-sm">
<Badge variant={config.is_active === "Y" ? "default" : "destructive"}>
{config.is_active === "Y" ? "활성" : "비활성"}
{/* 설정 목록 (ResponsiveDataView) */}
<ResponsiveDataView<ExternalCallConfigWithDate>
data={configs}
columns={columns}
keyExtractor={(c) => String(c.id || c.config_name)}
isLoading={loading}
emptyMessage="등록된 외부 호출 설정이 없습니다."
skeletonCount={5}
cardTitle={(c) => c.config_name}
cardSubtitle={(c) => c.description || "설명 없음"}
cardHeaderRight={(c) => (
<Badge variant={c.is_active === "Y" ? "default" : "destructive"}>
{c.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
</TableCell>
<TableCell className="h-16 text-sm">
<div className="flex justify-center gap-1">
)}
cardFields={cardFields}
renderActions={(c) => (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleTestConfig(config)}
title="테스트"
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={(e) => {
e.stopPropagation();
handleTestConfig(c);
}}
>
<TestTube className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEditConfig(config)}
title="편집"
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={(e) => {
e.stopPropagation();
handleEditConfig(c);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-8 w-8"
onClick={() => handleDeleteConfig(config)}
title="삭제"
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteConfig(c);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</div>
actionsWidth="200px"
/>
{/* 외부 호출 설정 모달 */}
<ExternalCallConfigModal
@@ -390,15 +435,17 @@ export default function ExternalCallConfigsPage() {
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{configToDelete?.config_name}" ?
&quot;{configToDelete?.config_name}&quot; ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></AlertDialogCancel>
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteConfig}
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
@@ -406,6 +453,9 @@ export default function ExternalCallConfigsPage() {
</AlertDialogContent>
</AlertDialog>
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}
@@ -4,10 +4,8 @@ import React, { useState, useEffect } from "react";
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
AlertDialog,
@@ -24,11 +22,12 @@ import {
ExternalDbConnectionAPI,
ExternalDbConnection,
ExternalDbConnectionFilter,
ConnectionTestRequest,
} from "@/lib/api/externalDbConnection";
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
import { RestApiConnectionList } from "@/components/admin/RestApiConnectionList";
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
import { ScrollToTop } from "@/components/common/ScrollToTop";
type ConnectionTabType = "database" | "rest-api";
@@ -102,7 +101,6 @@ export default function ExternalConnectionsPage() {
setSupportedDbTypes([{ value: "ALL", label: "전체" }, ...types]);
} catch (error) {
console.error("지원 DB 타입 로딩 오류:", error);
// 실패 시 기본값 사용
setSupportedDbTypes([
{ value: "ALL", label: "전체" },
{ value: "mysql", label: "MySQL" },
@@ -114,45 +112,36 @@ export default function ExternalConnectionsPage() {
}
};
// 초기 데이터 로딩
useEffect(() => {
loadConnections();
loadSupportedDbTypes();
}, []);
// 필터 변경 시 데이터 재로딩
useEffect(() => {
loadConnections();
}, [searchTerm, dbTypeFilter, activeStatusFilter]);
// 새 연결 추가
const handleAddConnection = () => {
setEditingConnection(undefined);
setIsModalOpen(true);
};
// 연결 편집
const handleEditConnection = (connection: ExternalDbConnection) => {
setEditingConnection(connection);
setIsModalOpen(true);
};
// 연결 삭제 확인 다이얼로그 열기
const handleDeleteConnection = (connection: ExternalDbConnection) => {
setConnectionToDelete(connection);
setDeleteDialogOpen(true);
};
// 연결 삭제 실행
const confirmDeleteConnection = async () => {
if (!connectionToDelete?.id) return;
try {
await ExternalDbConnectionAPI.deleteConnection(connectionToDelete.id);
toast({
title: "성공",
description: "연결이 삭제되었습니다.",
});
toast({ title: "성공", description: "연결이 삭제되었습니다." });
loadConnections();
} catch (error) {
console.error("연결 삭제 오류:", error);
@@ -167,13 +156,11 @@ export default function ExternalConnectionsPage() {
}
};
// 연결 삭제 취소
const cancelDeleteConnection = () => {
setDeleteDialogOpen(false);
setConnectionToDelete(null);
};
// 연결 테스트
const handleTestConnection = async (connection: ExternalDbConnection) => {
if (!connection.id) return;
@@ -181,14 +168,10 @@ export default function ExternalConnectionsPage() {
try {
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
if (result.success) {
toast({
title: "연결 성공",
description: `${connection.connection_name} 연결이 성공했습니다.`,
});
toast({ title: "연결 성공", description: `${connection.connection_name} 연결이 성공했습니다.` });
} else {
toast({
title: "연결 실패",
@@ -199,11 +182,7 @@ export default function ExternalConnectionsPage() {
} catch (error) {
console.error("연결 테스트 오류:", error);
setTestResults((prev) => new Map(prev).set(connection.id!, false));
toast({
title: "연결 테스트 오류",
description: "연결 테스트 중 오류가 발생했습니다.",
variant: "destructive",
});
toast({ title: "연결 테스트 오류", description: "연결 테스트 중 오류가 발생했습니다.", variant: "destructive" });
} finally {
setTestingConnections((prev) => {
const newSet = new Set(prev);
@@ -213,31 +192,89 @@ export default function ExternalConnectionsPage() {
}
};
// 모달 저장 처리
const handleModalSave = () => {
setIsModalOpen(false);
setEditingConnection(undefined);
loadConnections();
};
// 모달 취소 처리
const handleModalCancel = () => {
setIsModalOpen(false);
setEditingConnection(undefined);
};
// 테이블 컬럼 정의
const columns: RDVColumn<ExternalDbConnection>[] = [
{ key: "connection_name", label: "연결명",
render: (v) => <span className="font-medium">{v}</span> },
{ key: "company_code", label: "회사", width: "100px",
render: (_v, row) => (row as any).company_name || row.company_code },
{ key: "db_type", label: "DB 타입", width: "120px",
render: (v) => <Badge variant="outline">{DB_TYPE_LABELS[v] || v}</Badge> },
{ key: "host", label: "호스트:포트", width: "180px", hideOnMobile: true,
render: (_v, row) => <span className="font-mono">{row.host}:{row.port}</span> },
{ key: "database_name", label: "데이터베이스", width: "140px", hideOnMobile: true,
render: (v) => <span className="font-mono">{v}</span> },
{ key: "username", label: "사용자", width: "100px", hideOnMobile: true,
render: (v) => <span className="font-mono">{v}</span> },
{ key: "is_active", label: "상태", width: "80px",
render: (v) => (
<Badge variant={v === "Y" ? "default" : "secondary"}>
{v === "Y" ? "활성" : "비활성"}
</Badge>
) },
{ key: "created_date", label: "생성일", width: "100px", hideOnMobile: true,
render: (v) => (
<span className="text-muted-foreground">
{v ? new Date(v).toLocaleDateString() : "N/A"}
</span>
) },
{ key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true,
render: (_v, row) => (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
disabled={testingConnections.has(row.id!)}
className="h-9 text-sm">
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
</Button>
{testResults.has(row.id!) && (
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"}>
{testResults.get(row.id!) ? "성공" : "실패"}
</Badge>
)}
</div>
) },
];
// 모바일 카드 필드 정의
const cardFields: RDVCardField<ExternalDbConnection>[] = [
{ label: "DB 타입",
render: (c) => <Badge variant="outline">{DB_TYPE_LABELS[c.db_type] || c.db_type}</Badge> },
{ label: "호스트",
render: (c) => <span className="font-mono text-xs">{c.host}:{c.port}</span> },
{ label: "데이터베이스",
render: (c) => <span className="font-mono text-xs">{c.database_name}</span> },
{ label: "상태",
render: (c) => (
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
{c.is_active === "Y" ? "활성" : "비활성"}
</Badge>
) },
];
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="flex min-h-screen flex-col bg-background">
<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"> REST API </p>
<p className="text-sm text-muted-foreground"> REST API </p>
</div>
{/* 탭 */}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
<TabsList className="grid w-[400px] grid-cols-2">
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
<TabsTrigger value="database" className="flex items-center gap-2">
<Database className="h-4 w-4" />
@@ -252,10 +289,9 @@ export default function ExternalConnectionsPage() {
<TabsContent value="database" className="space-y-6">
{/* 검색 및 필터 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* 검색 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="연결명 또는 설명으로 검색..."
value={searchTerm}
@@ -263,8 +299,6 @@ export default function ExternalConnectionsPage() {
className="h-10 pl-10 text-sm"
/>
</div>
{/* DB 타입 필터 */}
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
<SelectTrigger className="h-10 w-full sm:w-[160px]">
<SelectValue placeholder="DB 타입" />
@@ -277,8 +311,6 @@ export default function ExternalConnectionsPage() {
))}
</SelectContent>
</Select>
{/* 활성 상태 필터 */}
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="h-10 w-full sm:w-[120px]">
<SelectValue placeholder="상태" />
@@ -292,123 +324,63 @@ export default function ExternalConnectionsPage() {
</SelectContent>
</Select>
</div>
{/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 연결 목록 */}
{loading ? (
<div className="bg-card flex h-64 items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
) : connections.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
) : (
<div className="bg-card">
<Table>
<TableHeader>
<TableRow className="bg-background">
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">DB </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">호스트:포트</TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connections.map((connection) => (
<TableRow key={connection.id} className="bg-background hover:bg-muted/50 transition-colors">
<TableCell className="h-16 px-6 py-3 text-sm">
<div className="font-medium">{connection.connection_name}</div>
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
{(connection as any).company_name || connection.company_code}
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
<Badge variant="outline">{DB_TYPE_LABELS[connection.db_type] || connection.db_type}</Badge>
</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
{connection.host}:{connection.port}
</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{connection.database_name}</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{connection.username}</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
{connection.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
{connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"}
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection(connection)}
disabled={testingConnections.has(connection.id!)}
className="h-9 text-sm"
>
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
</Button>
{testResults.has(connection.id!) && (
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
{testResults.get(connection.id!) ? "성공" : "실패"}
{/* 연결 목록 - ResponsiveDataView */}
<ResponsiveDataView
data={connections}
columns={columns}
keyExtractor={(c) => String(c.id || c.connection_name)}
isLoading={loading}
emptyMessage="등록된 연결이 없습니다"
skeletonCount={5}
cardTitle={(c) => c.connection_name}
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
cardHeaderRight={(c) => (
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
{c.is_active === "Y" ? "활성" : "비활성"}
</Badge>
)}
</div>
</TableCell>
<TableCell className="h-16 px-6 py-3 text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => {
console.log("SQL 쿼리 실행 버튼 클릭 - connection:", connection);
setSelectedConnection(connection);
cardFields={cardFields}
renderActions={(c) => (
<>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
disabled={testingConnections.has(c.id!)}
className="h-9 flex-1 gap-2 text-sm">
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
</Button>
<Button variant="outline" size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedConnection(c);
setSqlModalOpen(true);
}}
className="h-8 w-8"
title="SQL 쿼리 실행"
>
className="h-9 flex-1 gap-2 text-sm">
<Terminal className="h-4 w-4" />
SQL
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleEditConnection(connection)}
className="h-8 w-8"
>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
className="h-9 flex-1 gap-2 text-sm">
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteConnection(connection)}
className="text-destructive hover:bg-destructive/10 h-8 w-8"
>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
actionsLabel="작업"
actionsWidth="180px"
/>
{/* 연결 설정 모달 */}
{isModalOpen && (
@@ -427,8 +399,9 @@ export default function ExternalConnectionsPage() {
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{connectionToDelete?.connection_name}" ?
<br /> .
&ldquo;{connectionToDelete?.connection_name}&rdquo; ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
@@ -440,7 +413,7 @@ export default function ExternalConnectionsPage() {
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteConnection}
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
@@ -468,6 +441,7 @@ export default function ExternalConnectionsPage() {
</TabsContent>
</Tabs>
</div>
<ScrollToTop />
</div>
);
}

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