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:
@@ -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
|
||||
@@ -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으로만 구성
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 적용
|
||||
|
||||
---
|
||||
|
||||
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
||||
@@ -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. **항상 한글로 답변**
|
||||
@@ -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` - 스티키 헤더 테이블
|
||||
@@ -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 컨트롤러
|
||||
@@ -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` 사용
|
||||
- [ ] 모바일 반응형 대응 (카드 뷰)
|
||||
- [ ] 다크 모드 호환 색상 사용
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 컴포넌트로 직접 구현하는 것 금지
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ dist/
|
||||
build/
|
||||
build/Release
|
||||
|
||||
# Gradle
|
||||
.gradle/
|
||||
**/backend/.gradle/
|
||||
|
||||
# Cache
|
||||
.npm
|
||||
.eslintcache
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+2
-12
@@ -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();
|
||||
@@ -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: "감사 로그 기록 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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++;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보 조회
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"; // 레이블 위치
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 페이지 시스템
|
||||
@@ -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 | 사용자 검증 완료, 전체 작업 완료 |
|
||||
@@ -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: 컬럼 순서
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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개 (또는 명확 분리) |
|
||||
@@ -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`)*
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
"{deleteTarget?.template_name}"을(를) 삭제하시겠습니까?
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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">-></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}" 설정을 삭제하시겠습니까?
|
||||
"{configToDelete?.config_name}" 설정을 삭제하시겠습니까?
|
||||
<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 />이 작업은 되돌릴 수 없습니다.
|
||||
“{connectionToDelete?.connection_name}” 연결을 삭제하시겠습니까?
|
||||
<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
Reference in New Issue
Block a user