Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
af6e4252c9
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"setup-worktree-unix": [
|
||||
"cd backend-node && npm ci --prefer-offline --no-audit 2>/dev/null || npm install --prefer-offline --no-audit",
|
||||
"cd frontend && npm ci --prefer-offline --no-audit 2>/dev/null || npm install --prefer-offline --no-audit",
|
||||
"cp $ROOT_WORKTREE_PATH/backend-node/.env backend-node/.env 2>/dev/null || true",
|
||||
"cp $ROOT_WORKTREE_PATH/frontend/.env.local frontend/.env.local 2>/dev/null || true"
|
||||
]
|
||||
}
|
||||
66
.cursorrules
66
.cursorrules
|
|
@ -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 컴포넌트로 직접 구현하는 것 금지
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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); // 카테고리 값 연쇄관계
|
||||
|
|
|
|||
|
|
@ -3563,29 +3563,36 @@ export async function getTableSchema(
|
|||
|
||||
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
||||
|
||||
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
|
||||
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보 + 회사별 제약조건 함께 가져오기
|
||||
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
|
||||
const schemaQuery = `
|
||||
SELECT
|
||||
ic.column_name,
|
||||
ic.data_type,
|
||||
ic.is_nullable,
|
||||
ic.is_nullable AS db_is_nullable,
|
||||
ic.column_default,
|
||||
ic.character_maximum_length,
|
||||
ic.numeric_precision,
|
||||
ic.numeric_scale,
|
||||
ttc.column_label,
|
||||
ttc.display_order
|
||||
COALESCE(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
|
||||
FROM information_schema.columns ic
|
||||
LEFT JOIN table_type_columns ttc
|
||||
ON ttc.table_name = ic.table_name
|
||||
AND ttc.column_name = ic.column_name
|
||||
AND ttc.company_code = '*'
|
||||
LEFT JOIN table_type_columns ttc_common
|
||||
ON ttc_common.table_name = ic.table_name
|
||||
AND ttc_common.column_name = ic.column_name
|
||||
AND ttc_common.company_code = '*'
|
||||
LEFT JOIN table_type_columns ttc_company
|
||||
ON ttc_company.table_name = ic.table_name
|
||||
AND ttc_company.column_name = ic.column_name
|
||||
AND ttc_company.company_code = $2
|
||||
WHERE ic.table_schema = 'public'
|
||||
AND ic.table_name = $1
|
||||
ORDER BY COALESCE(ttc.display_order, ic.ordinal_position), ic.ordinal_position
|
||||
ORDER BY COALESCE(ttc_company.display_order, ttc_common.display_order, ic.ordinal_position), ic.ordinal_position
|
||||
`;
|
||||
|
||||
const columns = await query<any>(schemaQuery, [tableName]);
|
||||
const columns = await query<any>(schemaQuery, [tableName, companyCode]);
|
||||
|
||||
if (columns.length === 0) {
|
||||
res.status(404).json({
|
||||
|
|
@ -3595,17 +3602,28 @@ export async function getTableSchema(
|
|||
return;
|
||||
}
|
||||
|
||||
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
|
||||
const columnList = columns.map((col: any) => ({
|
||||
// 컬럼 정보를 간단한 형태로 변환 (회사별 제약조건 반영)
|
||||
const columnList = columns.map((col: any) => {
|
||||
// DB level nullable + 회사별 table_type_columns 제약조건 통합
|
||||
// table_type_columns에서 is_nullable = 'N'이면 필수 (DB가 nullable이어도)
|
||||
const dbNullable = col.db_is_nullable === "YES";
|
||||
const ttcNotNull = col.ttc_is_nullable === "N";
|
||||
const effectiveNullable = ttcNotNull ? false : dbNullable;
|
||||
|
||||
const ttcUnique = col.ttc_is_unique === "Y";
|
||||
|
||||
return {
|
||||
name: col.column_name,
|
||||
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
|
||||
label: col.column_label || col.column_name,
|
||||
type: col.data_type,
|
||||
nullable: col.is_nullable === "YES",
|
||||
nullable: effectiveNullable,
|
||||
unique: ttcUnique,
|
||||
default: col.column_default,
|
||||
maxLength: col.character_maximum_length,
|
||||
precision: col.numeric_precision,
|
||||
scale: col.numeric_scale,
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`);
|
||||
|
||||
|
|
|
|||
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 : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
/**
|
||||
|
|
@ -86,13 +87,20 @@ export class AuthController {
|
|||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||
}
|
||||
|
||||
// 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함)
|
||||
sendSmartFactoryLog({
|
||||
userId: userInfo.userId,
|
||||
remoteAddr,
|
||||
useType: "접속",
|
||||
}).catch(() => {});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "로그인 성공",
|
||||
data: {
|
||||
userInfo,
|
||||
token: loginResult.token,
|
||||
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
|
||||
firstMenuPath,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Request, Response } from "express";
|
|||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { TableManagementService } from "../services/tableManagementService";
|
||||
|
||||
/**
|
||||
* 데이터 액션 실행
|
||||
|
|
@ -81,6 +82,19 @@ async function executeMainDatabaseAction(
|
|||
company_code: companyCode,
|
||||
};
|
||||
|
||||
// UNIQUE 제약조건 검증 (INSERT/UPDATE/UPSERT 전)
|
||||
if (["insert", "update", "upsert"].includes(actionType.toLowerCase())) {
|
||||
const tms = new TableManagementService();
|
||||
const uniqueViolations = await tms.validateUniqueConstraints(
|
||||
tableName,
|
||||
dataWithCompany,
|
||||
companyCode
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
throw new Error(`중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
switch (actionType.toLowerCase()) {
|
||||
case "insert":
|
||||
return await executeInsert(tableName, dataWithCompany);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Response } from "express";
|
||||
import { dynamicFormService } from "../services/dynamicFormService";
|
||||
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
|
||||
import { TableManagementService } from "../services/tableManagementService";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { formatPgError } from "../utils/pgErrorUtil";
|
||||
|
||||
// 폼 데이터 저장 (기존 버전 - 레거시 지원)
|
||||
export const saveFormData = async (
|
||||
|
|
@ -47,6 +49,21 @@ export const saveFormData = async (
|
|||
formDataWithMeta.company_code = companyCode;
|
||||
}
|
||||
|
||||
// UNIQUE 제약조건 검증 (INSERT 전)
|
||||
const tms = new TableManagementService();
|
||||
const uniqueViolations = await tms.validateUniqueConstraints(
|
||||
tableName,
|
||||
formDataWithMeta,
|
||||
companyCode || "*"
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 클라이언트 IP 주소 추출
|
||||
const ipAddress =
|
||||
req.ip ||
|
||||
|
|
@ -68,9 +85,12 @@ export const saveFormData = async (
|
|||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ 폼 데이터 저장 실패:", error);
|
||||
res.status(500).json({
|
||||
const { companyCode } = req.user as any;
|
||||
const friendlyMsg = await formatPgError(error, companyCode);
|
||||
const statusCode = error.code?.startsWith("23") ? 400 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: error.message || "데이터 저장에 실패했습니다.",
|
||||
message: friendlyMsg,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -108,6 +128,21 @@ export const saveFormDataEnhanced = async (
|
|||
formDataWithMeta.company_code = companyCode;
|
||||
}
|
||||
|
||||
// UNIQUE 제약조건 검증 (INSERT 전)
|
||||
const tmsEnhanced = new TableManagementService();
|
||||
const uniqueViolations = await tmsEnhanced.validateUniqueConstraints(
|
||||
tableName,
|
||||
formDataWithMeta,
|
||||
companyCode || "*"
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 개선된 서비스 사용
|
||||
const result = await enhancedDynamicFormService.saveFormData(
|
||||
screenId,
|
||||
|
|
@ -118,9 +153,12 @@ export const saveFormDataEnhanced = async (
|
|||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
|
||||
res.status(500).json({
|
||||
const { companyCode } = req.user as any;
|
||||
const friendlyMsg = await formatPgError(error, companyCode);
|
||||
const statusCode = error.code?.startsWith("23") ? 400 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: error.message || "데이터 저장에 실패했습니다.",
|
||||
message: friendlyMsg,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -146,12 +184,28 @@ export const updateFormData = async (
|
|||
const formDataWithMeta = {
|
||||
...data,
|
||||
updated_by: userId,
|
||||
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
writer: data.writer || userId,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
// UNIQUE 제약조건 검증 (UPDATE 시 자기 자신 제외)
|
||||
const tmsUpdate = new TableManagementService();
|
||||
const uniqueViolations = await tmsUpdate.validateUniqueConstraints(
|
||||
tableName,
|
||||
formDataWithMeta,
|
||||
companyCode || "*",
|
||||
id
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dynamicFormService.updateFormData(
|
||||
id, // parseInt 제거 - 문자열 ID 지원
|
||||
id,
|
||||
tableName,
|
||||
formDataWithMeta
|
||||
);
|
||||
|
|
@ -163,9 +217,12 @@ export const updateFormData = async (
|
|||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ 폼 데이터 업데이트 실패:", error);
|
||||
res.status(500).json({
|
||||
const { companyCode } = req.user as any;
|
||||
const friendlyMsg = await formatPgError(error, companyCode);
|
||||
const statusCode = error.code?.startsWith("23") ? 400 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: error.message || "데이터 업데이트에 실패했습니다.",
|
||||
message: friendlyMsg,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -199,11 +256,27 @@ export const updateFormDataPartial = async (
|
|||
const newDataWithMeta = {
|
||||
...newData,
|
||||
updated_by: userId,
|
||||
writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정
|
||||
writer: newData.writer || userId,
|
||||
};
|
||||
|
||||
// UNIQUE 제약조건 검증 (부분 UPDATE 시 자기 자신 제외)
|
||||
const tmsPartial = new TableManagementService();
|
||||
const uniqueViolations = await tmsPartial.validateUniqueConstraints(
|
||||
tableName,
|
||||
newDataWithMeta,
|
||||
companyCode || "*",
|
||||
id
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dynamicFormService.updateFormDataPartial(
|
||||
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
|
||||
id,
|
||||
tableName,
|
||||
originalData,
|
||||
newDataWithMeta
|
||||
|
|
@ -216,9 +289,12 @@ export const updateFormDataPartial = async (
|
|||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ 부분 업데이트 실패:", error);
|
||||
res.status(500).json({
|
||||
const { companyCode } = req.user as any;
|
||||
const friendlyMsg = await formatPgError(error, companyCode);
|
||||
const statusCode = error.code?.startsWith("23") ? 400 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: error.message || "부분 업데이트에 실패했습니다.",
|
||||
message: friendlyMsg,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -181,16 +181,87 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// DISTINCT 쿼리 실행
|
||||
const query = `
|
||||
// 1단계: DISTINCT 값 조회
|
||||
const distinctQuery = `
|
||||
SELECT DISTINCT "${columnName}" as value, "${effectiveLabelColumn}" as label
|
||||
FROM "${tableName}"
|
||||
${whereClause}
|
||||
ORDER BY "${effectiveLabelColumn}" ASC
|
||||
LIMIT 500
|
||||
`;
|
||||
const result = await pool.query(distinctQuery, params);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
// 2단계: 카테고리/코드 라벨 변환 (값이 있을 때만)
|
||||
if (result.rows.length > 0) {
|
||||
const rawValues = result.rows.map((r: any) => r.value);
|
||||
const labelMap: Record<string, string> = {};
|
||||
|
||||
// category_values에서 라벨 조회
|
||||
try {
|
||||
const cvCompanyCondition = companyCode !== "*"
|
||||
? `AND (company_code = $4 OR company_code = '*')`
|
||||
: "";
|
||||
const cvParams = companyCode !== "*"
|
||||
? [tableName, columnName, rawValues, companyCode]
|
||||
: [tableName, columnName, rawValues];
|
||||
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_code, value_label FROM category_values
|
||||
WHERE table_name = $1 AND column_name = $2
|
||||
AND value_code = ANY($3) AND is_active = true
|
||||
${cvCompanyCondition}`,
|
||||
cvParams
|
||||
);
|
||||
cvResult.rows.forEach((r: any) => {
|
||||
labelMap[r.value_code] = r.value_label;
|
||||
});
|
||||
} catch (e) {
|
||||
// category_values 조회 실패 시 무시
|
||||
}
|
||||
|
||||
// code_info에서 라벨 조회 (code_category 기반)
|
||||
try {
|
||||
const ttcResult = await pool.query(
|
||||
`SELECT code_category FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 AND code_category IS NOT NULL
|
||||
LIMIT 1`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
const codeCategory = ttcResult.rows[0]?.code_category;
|
||||
|
||||
if (codeCategory) {
|
||||
const ciCompanyCondition = companyCode !== "*"
|
||||
? `AND (company_code = $3 OR company_code = '*')`
|
||||
: "";
|
||||
const ciParams = companyCode !== "*"
|
||||
? [codeCategory, rawValues, companyCode]
|
||||
: [codeCategory, rawValues];
|
||||
|
||||
const ciResult = await pool.query(
|
||||
`SELECT code_value, code_name FROM code_info
|
||||
WHERE code_category = $1 AND code_value = ANY($2) AND is_active = 'Y'
|
||||
${ciCompanyCondition}`,
|
||||
ciParams
|
||||
);
|
||||
ciResult.rows.forEach((r: any) => {
|
||||
if (!labelMap[r.code_value]) {
|
||||
labelMap[r.code_value] = r.code_name;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// code_info 조회 실패 시 무시
|
||||
}
|
||||
|
||||
// 라벨 매핑 적용
|
||||
if (Object.keys(labelMap).length > 0) {
|
||||
result.rows.forEach((row: any) => {
|
||||
if (labelMap[row.value]) {
|
||||
row.label = labelMap[row.value];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("컬럼 DISTINCT 값 조회 성공", {
|
||||
tableName,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -2087,6 +2087,23 @@ export async function multiTableSave(
|
|||
return;
|
||||
}
|
||||
|
||||
// UNIQUE 제약조건 검증 (트랜잭션 전에)
|
||||
const tmsMulti = new TableManagementService();
|
||||
const uniqueViolations = await tmsMulti.validateUniqueConstraints(
|
||||
mainTable.tableName,
|
||||
mainData,
|
||||
companyCode,
|
||||
isUpdate ? mainData[mainTable.primaryKeyColumn] : undefined
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
client.release();
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 메인 테이블 저장
|
||||
|
|
@ -3088,3 +3105,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: "데이터 검증 중 오류가 발생했습니다." });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,13 +41,27 @@ export const errorHandler = (
|
|||
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||
if (pgError.code === "23505") {
|
||||
// unique_violation
|
||||
error = new AppError("중복된 데이터가 존재합니다.", 400);
|
||||
const constraint = pgError.constraint || "";
|
||||
const tbl = pgError.table || "";
|
||||
let col = "";
|
||||
if (constraint && tbl) {
|
||||
const prefix = `${tbl}_`;
|
||||
const suffix = "_key";
|
||||
if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) {
|
||||
col = constraint.slice(prefix.length, -suffix.length);
|
||||
}
|
||||
}
|
||||
const detail = col ? ` [${col}]` : "";
|
||||
error = new AppError(`중복된 데이터가 존재합니다.${detail}`, 400);
|
||||
} else if (pgError.code === "23503") {
|
||||
// foreign_key_violation
|
||||
error = new AppError("참조 무결성 제약 조건 위반입니다.", 400);
|
||||
} else if (pgError.code === "23502") {
|
||||
// not_null_violation
|
||||
error = new AppError("필수 입력값이 누락되었습니다.", 400);
|
||||
const colName = pgError.column || "";
|
||||
const tableName = pgError.table || "";
|
||||
const detail = colName ? ` [${tableName}.${colName}]` : "";
|
||||
error = new AppError(`필수 입력값이 누락되었습니다.${detail}`, 400);
|
||||
} else if (pgError.code.startsWith("23")) {
|
||||
// 기타 무결성 제약 조건 위반
|
||||
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
||||
|
|
@ -84,6 +98,7 @@ export const errorHandler = (
|
|||
// 응답 전송
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: message,
|
||||
error: {
|
||||
message: message,
|
||||
...(process.env.NODE_ENV === "development" && { stack: error.stack }),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { authenticateToken } from "../middleware/authMiddleware";
|
|||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { auditLogService } from "../services/auditLogService";
|
||||
import { TableManagementService } from "../services/tableManagementService";
|
||||
import { formatPgError } from "../utils/pgErrorUtil";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -1047,6 +1049,20 @@ router.post(
|
|||
console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`);
|
||||
}
|
||||
|
||||
// UNIQUE 제약조건 검증
|
||||
const tms = new TableManagementService();
|
||||
const uniqueViolations = await tms.validateUniqueConstraints(
|
||||
tableName,
|
||||
enrichedData,
|
||||
req.user?.companyCode || "*"
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 레코드 생성
|
||||
const result = await dataService.createRecord(tableName, enrichedData);
|
||||
|
||||
|
|
@ -1116,6 +1132,21 @@ router.put(
|
|||
|
||||
console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data);
|
||||
|
||||
// UNIQUE 제약조건 검증 (자기 자신 제외)
|
||||
const tmsUpdate = new TableManagementService();
|
||||
const uniqueViolations = await tmsUpdate.validateUniqueConstraints(
|
||||
tableName,
|
||||
data,
|
||||
req.user?.companyCode || "*",
|
||||
String(id)
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 레코드 수정
|
||||
const result = await dataService.updateRecord(tableName, id, data);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// TODO: 포장/적재정보 관리 API 구현 예정
|
||||
|
||||
export default router;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -970,10 +970,11 @@ class MultiTableExcelService {
|
|||
const result = await pool.query(
|
||||
`SELECT
|
||||
c.column_name,
|
||||
c.is_nullable,
|
||||
c.is_nullable AS db_is_nullable,
|
||||
c.column_default,
|
||||
COALESCE(ttc.column_label, cl.column_label) AS column_label,
|
||||
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table
|
||||
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
|
||||
LEFT JOIN table_type_columns cl
|
||||
ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*'
|
||||
|
|
@ -991,13 +992,13 @@ class MultiTableExcelService {
|
|||
// 시스템 컬럼 제외
|
||||
if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue;
|
||||
|
||||
// FK 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조)
|
||||
// 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로,
|
||||
// _id로 끝나면서 reference_table이 있는 경우만 제외
|
||||
// FK 컬럼 제외
|
||||
if (row.reference_table && colName.endsWith("_id")) continue;
|
||||
|
||||
const hasDefault = row.column_default !== null;
|
||||
const isNullable = row.is_nullable === "YES";
|
||||
const dbNullable = row.db_is_nullable === "YES";
|
||||
const ttcNotNull = row.ttc_is_nullable === "N";
|
||||
const isNullable = ttcNotNull ? false : dbNullable;
|
||||
const isRequired = !isNullable && !hasDefault;
|
||||
|
||||
columns.push({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { query } from "../database/db";
|
||||
|
||||
/**
|
||||
* PostgreSQL 에러를 사용자 친절한 메시지로 변환
|
||||
* table_type_columns의 column_label을 조회하여 한글 라벨로 표시
|
||||
*/
|
||||
export async function formatPgError(
|
||||
error: any,
|
||||
companyCode?: string
|
||||
): Promise<string> {
|
||||
if (!error || !error.code) {
|
||||
return error?.message || "데이터 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
switch (error.code) {
|
||||
case "23502": {
|
||||
// not_null_violation
|
||||
const colName = error.column || "";
|
||||
const tblName = error.table || "";
|
||||
|
||||
if (colName && tblName && companyCode) {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT column_label FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 AND company_code = $3
|
||||
LIMIT 1`,
|
||||
[tblName, colName, companyCode]
|
||||
);
|
||||
const label = rows[0]?.column_label;
|
||||
if (label) {
|
||||
return `필수 입력값이 누락되었습니다: ${label}`;
|
||||
}
|
||||
} catch {
|
||||
// 라벨 조회 실패 시 컬럼명으로 폴백
|
||||
}
|
||||
}
|
||||
const detail = colName ? ` [${colName}]` : "";
|
||||
return `필수 입력값이 누락되었습니다.${detail}`;
|
||||
}
|
||||
case "23505": {
|
||||
// unique_violation
|
||||
const constraint = error.constraint || "";
|
||||
const tblName = error.table || "";
|
||||
// constraint 이름에서 컬럼명 추출 시도 (예: item_mst_item_code_key → item_code)
|
||||
let colName = "";
|
||||
if (constraint && tblName) {
|
||||
const prefix = `${tblName}_`;
|
||||
const suffix = "_key";
|
||||
if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) {
|
||||
colName = constraint.slice(prefix.length, -suffix.length);
|
||||
}
|
||||
}
|
||||
if (colName && tblName && companyCode) {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT column_label FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 AND company_code = $3
|
||||
LIMIT 1`,
|
||||
[tblName, colName, companyCode]
|
||||
);
|
||||
const label = rows[0]?.column_label;
|
||||
if (label) {
|
||||
return `중복된 데이터가 존재합니다: ${label}`;
|
||||
}
|
||||
} catch {
|
||||
// 폴백
|
||||
}
|
||||
}
|
||||
const detail = colName ? ` [${colName}]` : "";
|
||||
return `중복된 데이터가 존재합니다.${detail}`;
|
||||
}
|
||||
case "23503":
|
||||
return "참조 무결성 제약 조건 위반입니다.";
|
||||
default:
|
||||
if (error.code.startsWith("23")) {
|
||||
return "데이터 무결성 제약 조건 위반입니다.";
|
||||
}
|
||||
return error.message || "데이터 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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,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,340 @@
|
|||
# [계획서] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
|
||||
|
||||
> 관련 문서: [맥락노트](./BIC[맥락]-버튼-아이콘화.md) | [체크리스트](./BIC[체크]-버튼-아이콘화.md)
|
||||
|
||||
## 개요
|
||||
|
||||
화면 디자이너에서 버튼을 텍스트 모드(현행), 아이콘 모드, 아이콘+텍스트 모드 중 선택할 수 있도록 확장한다.
|
||||
아이콘 모드 선택 시 버튼 액션에 맞는 아이콘 후보군이 제시되고, 관리자가 원하는 아이콘을 선택한다.
|
||||
아이콘 크기 비율(버튼 높이 대비 4단계 프리셋), 아이콘 색상, 텍스트 위치(4방향), 아이콘-텍스트 간격 설정을 제공한다.
|
||||
관리자가 lucide 검색 또는 외부 SVG 붙여넣기로 커스텀 아이콘을 추가/삭제할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
- 버튼은 항상 **텍스트 모드**로만 표시됨
|
||||
- `ButtonConfigPanel.tsx`에서 "버튼 텍스트" 입력 → 실제 화면에서 해당 텍스트가 버튼에 표시
|
||||
- 아이콘 표시 기능 없음
|
||||
|
||||
### 현재 코드 위치
|
||||
|
||||
| 구분 | 파일 | 설명 |
|
||||
|------|------|------|
|
||||
| 설정 패널 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 버튼 텍스트, 액션 설정 (784~854행) |
|
||||
| 뷰어 렌더링 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | 실제 버튼 렌더링 (961~983행) |
|
||||
| 뷰어 렌더링 | `frontend/components/screen/InteractiveScreenViewer.tsx` | 실제 버튼 렌더링 (2041~2059행) |
|
||||
| 위젯 렌더링 | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
|
||||
| 최적화 컴포넌트 | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 컴포넌트 (643~674행) |
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. 표시 모드 선택 (라디오 그룹)
|
||||
|
||||
ButtonConfigPanel에 "버튼 텍스트" 입력 위에 표시 모드 선택 UI 추가:
|
||||
|
||||
- **텍스트 모드** (기본값, 현행 유지): 버튼에 텍스트만 표시
|
||||
- **아이콘 모드**: 버튼에 아이콘만 표시
|
||||
- **아이콘+텍스트 모드**: 버튼에 아이콘과 텍스트를 함께 표시
|
||||
|
||||
```
|
||||
[ 텍스트 | 아이콘 | 아이콘+텍스트 ] ← 라디오 그룹 (토글 형태)
|
||||
```
|
||||
|
||||
### 2. 텍스트 모드 선택 시
|
||||
|
||||
- 현재와 동일하게 "버튼 텍스트" 입력 필드 표시
|
||||
- 변경 사항 없음
|
||||
|
||||
### 2-1. 아이콘+텍스트 모드 선택 시
|
||||
|
||||
- 아이콘 선택 UI (3장과 동일) + 버튼 텍스트 입력 필드 **둘 다 표시**
|
||||
- 렌더링: 텍스트 위치에 따라 아이콘과 텍스트 배치 방향이 달라짐
|
||||
- 텍스트 위치 4방향: 오른쪽(기본), 왼쪽, 위쪽, 아래쪽
|
||||
- 예시: `[ ✓ 저장 ]` (오른쪽), `[ 저장 ✓ ]` (왼쪽), 세로 배치 (위쪽/아래쪽)
|
||||
- 아이콘과 텍스트 사이 간격: 기본 6px, 관리자가 0~무제한 조절 가능 (슬라이더 0~32px + 직접 입력)
|
||||
|
||||
### 3. 아이콘 모드 선택 시
|
||||
|
||||
#### 3-1. 버튼 액션별 추천 아이콘 목록
|
||||
|
||||
버튼 액션(`action.type`)에 따라 해당 액션에 어울리는 아이콘 후보군을 그리드로 표시:
|
||||
|
||||
| 버튼 액션 | 값 | 추천 아이콘 (lucide-react) |
|
||||
|-----------|-----|---------------------------|
|
||||
| 저장 | `save` | Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck |
|
||||
| 삭제 | `delete` | Trash2, Trash, XCircle, X, Eraser, CircleX |
|
||||
| 편집 | `edit` | Pencil, PenLine, Edit, SquarePen, FilePen, PenTool |
|
||||
| 페이지 이동 | `navigate` | ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link |
|
||||
| 모달 열기 | `modal` | Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen |
|
||||
| 데이터 전달 | `transferData` | SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2 |
|
||||
| 엑셀 다운로드 | `excel_download` | Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput |
|
||||
| 엑셀 업로드 | `excel_upload` | Upload, FileUp, FileSpreadsheet, Sheet, ImportIcon, FileInput |
|
||||
| 즉시 저장 | `quickInsert` | Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus |
|
||||
| 제어 흐름 | `control` | Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Cog |
|
||||
| 바코드 스캔 | `barcode_scan` | ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus |
|
||||
| 운행알림 및 종료 | `operation_control` | Truck, Car, MapPin, Navigation2, Route, Bell |
|
||||
| 이벤트 발송 | `event` | Send, Bell, Radio, Megaphone, Podcast, BellRing |
|
||||
| 복사 | `copy` | Copy, ClipboardCopy, Files, CopyPlus, Duplicate, ClipboardList |
|
||||
|
||||
**적절한 아이콘이 없는 액션 (숨김 처리된 deprecated 액션들):**
|
||||
|
||||
| 버튼 액션 | 값 | 안내 문구 |
|
||||
|-----------|-----|----------|
|
||||
| 연관 데이터 버튼 모달 열기 | `openRelatedModal` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| (deprecated) 데이터 전달 + 모달 | `openModalWithData` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| 테이블 이력 보기 | `view_table_history` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| 코드 병합 | `code_merge` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
| 공차등록 | `empty_vehicle` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
|
||||
|
||||
> 안내 문구: "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요."
|
||||
> 안내 문구 아래에 커스텀 아이콘 목록 + lucide 검색/SVG 붙여넣기 버튼이 표시됨
|
||||
|
||||
#### 3-2. 아이콘 선택 UI
|
||||
|
||||
- 액션별 추천 아이콘을 4~6열 그리드로 표시
|
||||
- 각 아이콘은 32x32 크기, 호버 시 하이라이트, 선택 시 ring 표시
|
||||
- 아이콘 아래에 이름 표시 (`text-[10px]`)
|
||||
- 관리자가 추가한 커스텀 아이콘이 있으면 "커스텀 아이콘" 구분선 아래 함께 표시
|
||||
|
||||
#### 3-3. 아이콘 크기 비율 설정
|
||||
|
||||
버튼 높이 대비 비율로 아이콘 크기를 설정 (정사각형 유지):
|
||||
|
||||
**프리셋 (ToggleGroup, 4단계):**
|
||||
|
||||
| 이름 | 버튼 높이 대비 | 설명 |
|
||||
|------|--------------|------|
|
||||
| 작게 | 40% | 컴팩트한 아이콘 |
|
||||
| 보통 | 55% | 기본값, 대부분의 버튼에 적합 |
|
||||
| 크게 | 70% | 존재감 있는 크기 |
|
||||
| 매우 크게 | 85% | 아이콘 강조, 버튼에 꽉 차는 느낌 |
|
||||
|
||||
- px 직접 입력은 제거 (비율 기반이므로 버튼 크기 변경 시 아이콘도 자동 비례)
|
||||
- 저장: `icon.size`에 프리셋 문자열(`"보통"`) 저장
|
||||
- 렌더링: `height: N%` + `aspect-ratio: 1/1`로 정사각형 유지
|
||||
|
||||
#### 3-4. 아이콘 색상 설정
|
||||
|
||||
아이콘 크기 아래에 아이콘 전용 색상 설정:
|
||||
|
||||
- **컬러 피커**: 기존 버튼 색상 설정과 동일한 UI 사용
|
||||
- **기본값**: 미설정 (= `textColor` 상속, 기존 동작과 동일)
|
||||
- **설정 시**: lucide 아이콘은 지정한 색상으로 덮어쓰기
|
||||
- **외부 SVG**: 고유 색상이 하드코딩된 SVG는 이 설정의 영향을 받지 않음 (원본 유지)
|
||||
- **초기화 버튼**: "텍스트 색상과 동일" 버튼으로 별도 색상 해제 가능
|
||||
|
||||
| 상황 | iconColor 설정 | 결과 |
|
||||
|------|---------------|------|
|
||||
| lucide 아이콘, iconColor 미설정 | 없음 | textColor 상속 (기존 동작) |
|
||||
| lucide 아이콘, iconColor 설정 | `#22c55e` | 초록색 아이콘 |
|
||||
| 외부 SVG (고유 색상), iconColor 설정 | `#22c55e` | SVG 원본 색상 유지 (무시) |
|
||||
| 외부 SVG (currentColor), iconColor 설정 | `#22c55e` | 초록색 아이콘 |
|
||||
|
||||
#### 3-5. 텍스트 위치 설정 (아이콘+텍스트 모드 전용)
|
||||
|
||||
아이콘 대비 텍스트의 배치 방향을 4방향으로 설정:
|
||||
|
||||
| 위치 | 값 | 레이아웃 | 설명 |
|
||||
|------|-----|---------|------|
|
||||
| 왼쪽 | `left` | `텍스트 ← 아이콘` | 텍스트가 아이콘 왼쪽 (가로) |
|
||||
| 오른쪽 | `right` | `아이콘 → 텍스트` | 기본값, 아이콘 뒤에 텍스트 (가로) |
|
||||
| 위쪽 | `top` | 텍스트 위, 아이콘 아래 | 세로 배치 |
|
||||
| 아래쪽 | `bottom` | 아이콘 위, 텍스트 아래 | 세로 배치 |
|
||||
|
||||
- 기본값: `"right"` (아이콘 오른쪽에 텍스트)
|
||||
- 저장: `componentConfig.iconTextPosition`
|
||||
- 아이콘 모드에서는 이 옵션이 숨겨짐 (텍스트가 없으므로 불필요)
|
||||
|
||||
#### 3-6. 아이콘-텍스트 간격 설정 (아이콘+텍스트 모드 전용)
|
||||
|
||||
아이콘+텍스트 모드에서 아이콘과 텍스트 사이 간격을 조절:
|
||||
|
||||
- **슬라이더**: 0~32px 범위 시각적 조절
|
||||
- **직접 입력**: px 수치 직접 입력 (최솟값 0, 최댓값 제한 없음)
|
||||
- **기본값**: 6px
|
||||
- 아이콘 모드에서는 이 옵션이 숨겨짐 (텍스트가 없으므로 불필요)
|
||||
|
||||
#### 3-7. 아이콘 모드 레이아웃 안내
|
||||
|
||||
아이콘만 표시하면 텍스트보다 좁은 공간으로 충분하므로 안내 문구 표시:
|
||||
|
||||
```
|
||||
ℹ 아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다.
|
||||
```
|
||||
|
||||
- `bg-blue-50 dark:bg-blue-950/20` 배경의 안내 박스
|
||||
- 아이콘 모드(`"icon"`)에서만 표시, 아이콘+텍스트 모드에서는 숨김
|
||||
|
||||
#### 3-8. 디폴트 아이콘 자동 부여
|
||||
|
||||
아이콘/아이콘+텍스트 모드 전환 시 아이콘이 미선택 상태이면 **디폴트 아이콘을 자동으로 부여**한다.
|
||||
|
||||
| 상황 | 디폴트 아이콘 |
|
||||
|------|-------------|
|
||||
| 추천 아이콘이 있는 액션 (save, delete 등) | 해당 액션의 **첫 번째 추천 아이콘** (예: save → Check) |
|
||||
| 추천 아이콘이 없는 액션 (deprecated 등) | 범용 폴백 아이콘: `SquareMousePointer` |
|
||||
|
||||
**커스텀 아이콘 삭제 시:**
|
||||
- 현재 선택된 커스텀 아이콘을 삭제하면 **디폴트 아이콘으로 자동 복귀** (텍스트 모드로 빠지지 않음)
|
||||
- 아이콘 모드를 유지한 채 디폴트 아이콘이 캔버스에 즉시 반영됨
|
||||
|
||||
#### 3-9. 커스텀 아이콘 추가/삭제
|
||||
|
||||
**방법 1: lucide 아이콘 검색으로 추가**
|
||||
- "아이콘 추가" 버튼 클릭 시 lucide 아이콘 전체 검색 가능한 모달/팝오버 표시
|
||||
- 검색 입력 → 아이콘 이름으로 필터링 → 선택하면 커스텀 목록에 추가
|
||||
|
||||
**방법 2: 외부 SVG 붙여넣기로 추가**
|
||||
- "SVG 붙여넣기" 버튼 클릭 시 텍스트 입력 영역(textarea) 표시
|
||||
- 외부에서 복사한 SVG 코드를 붙여넣기 → 미리보기로 확인 → "추가" 버튼으로 등록
|
||||
- SVG 유효성 검사: `<svg` 태그가 포함된 올바른 SVG인지 확인, 아니면 에러 메시지
|
||||
- 추가 시 관리자가 아이콘 이름을 직접 입력 (목록에서 구분용)
|
||||
- 저장 형태: SVG 문자열을 `customSvgIcons` 배열에 `{ name, svg }` 객체로 저장
|
||||
|
||||
**공통 규칙:**
|
||||
- 추가된 커스텀 아이콘(lucide/SVG 모두)은 **모든 버튼 액션의 아이콘 후보에 공통으로 노출**
|
||||
- 커스텀 아이콘에 X 버튼으로 삭제 가능
|
||||
|
||||
---
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
### componentConfig 확장
|
||||
|
||||
```typescript
|
||||
interface ButtonComponentConfig {
|
||||
text: string; // 기존: 버튼 텍스트
|
||||
displayMode: "text" | "icon" | "icon-text"; // 신규: 표시 모드 (기본값: "text")
|
||||
icon?: {
|
||||
name: string; // lucide 아이콘 이름 또는 커스텀 SVG 아이콘 이름
|
||||
type: "lucide" | "svg"; // 아이콘 출처 구분 (기본값: "lucide")
|
||||
size: "작게" | "보통" | "크게" | "매우 크게"; // 버튼 높이 대비 비율 프리셋 (기본값: "보통")
|
||||
color?: string; // 아이콘 색상 (미설정 시 textColor 상속)
|
||||
};
|
||||
iconGap?: number; // 아이콘-텍스트 간격 px (기본값: 6, 아이콘+텍스트 모드 전용)
|
||||
iconTextPosition?: "right" | "left" | "top" | "bottom"; // 텍스트 위치 (기본값: "right", 아이콘+텍스트 모드 전용)
|
||||
customIcons?: string[]; // 관리자가 추가한 lucide 커스텀 아이콘 이름 목록
|
||||
customSvgIcons?: Array<{ // 관리자가 붙여넣기한 외부 SVG 아이콘 목록
|
||||
name: string; // 관리자가 지정한 아이콘 이름
|
||||
svg: string; // SVG 문자열 원본
|
||||
}>;
|
||||
action: {
|
||||
type: string; // 기존: 버튼 액션 타입
|
||||
// ...기존 action 속성들 유지
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 저장 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "저장",
|
||||
"displayMode": "icon",
|
||||
"icon": {
|
||||
"name": "Check",
|
||||
"type": "lucide",
|
||||
"size": "보통",
|
||||
"color": "#22c55e"
|
||||
},
|
||||
"customIcons": ["Rocket", "Star"],
|
||||
"customSvgIcons": [
|
||||
{
|
||||
"name": "회사로고",
|
||||
"svg": "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>...</svg>"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"type": "save"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 시각적 동작 예시
|
||||
|
||||
### ButtonConfigPanel (디자이너 편집 모드)
|
||||
|
||||
```
|
||||
표시 모드: [ 텍스트 | (아이콘) | 아이콘+텍스트 ] ← 아이콘 선택됨
|
||||
|
||||
아이콘 선택:
|
||||
┌──────────────────────────────────┐
|
||||
│ 추천 아이콘 (저장) │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
|
||||
│ │ ✓ │ │ 💾 │ │ ✓○ │ │ ○✓ │ │
|
||||
│ │Check│ │Save│ │Chk○│ │○Chk│ │
|
||||
│ └────┘ └────┘ └────┘ └────┘ │
|
||||
│ ┌────┐ ┌────┐ │
|
||||
│ │📄✓│ │🛡✓│ │
|
||||
│ │FChk│ │ShCk│ │
|
||||
│ └────┘ └────┘ │
|
||||
│ │
|
||||
│ ── 커스텀 아이콘 ── │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ │
|
||||
│ │ 🚀 │ │ ⭐ │ │[로고]│ │
|
||||
│ │Rckt │ │Star│ │회사 │ │
|
||||
│ │ ✕ │ │ ✕│ │ ✕ │ │
|
||||
│ └────┘ └────┘ └────┘ │
|
||||
│ [+ lucide 검색] [+ SVG 붙여넣기]│
|
||||
└──────────────────────────────────┘
|
||||
|
||||
아이콘 크기 비율: [ 작게 | (보통) | 크게 | 매우 크게 ]
|
||||
텍스트 위치: [ 왼쪽 | (오른쪽) | 위쪽 | 아래쪽 ] ← 아이콘+텍스트 모드에서만 표시
|
||||
아이콘-텍스트 간격: [━━━━━○━━] [6] px ← 아이콘+텍스트 모드에서만 표시
|
||||
아이콘 색상: [■ #22c55e] [텍스트 색상과 동일]
|
||||
|
||||
ℹ 아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다.
|
||||
```
|
||||
|
||||
### 실제 화면 렌더링
|
||||
|
||||
| 모드 | 표시 |
|
||||
|------|------|
|
||||
| 텍스트 모드 | `[ 저장 ]` |
|
||||
| 아이콘 모드 (보통, 55%) | `[ ✓ ]` |
|
||||
| 아이콘 모드 (매우 크게, 85%) | `[ ✓ ]` |
|
||||
| 아이콘+텍스트 (텍스트 오른쪽) | `[ ✓ 저장 ]` (간격 6px) |
|
||||
| 아이콘+텍스트 (텍스트 왼쪽) | `[ 저장 ✓ ]` |
|
||||
| 아이콘+텍스트 (텍스트 아래쪽) | 아이콘 위, 텍스트 아래 (세로) |
|
||||
| 아이콘+텍스트 (색상 분리) | `[ 초록✓ 검정저장 ]` |
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상
|
||||
|
||||
### 수정 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `ButtonConfigPanel.tsx` | 표시 모드 3종 라디오, 아이콘 그리드, 크기, 색상, 간격 설정, 레이아웃 안내, 커스텀 아이콘 UI |
|
||||
| `InteractiveScreenViewerDynamic.tsx` | `displayMode` 3종 분기 → 아이콘/아이콘+텍스트/텍스트 렌더링 |
|
||||
| `InteractiveScreenViewer.tsx` | 동일 분기 추가 |
|
||||
| `ButtonWidget.tsx` | 동일 분기 추가 |
|
||||
| `OptimizedButtonComponent.tsx` | 동일 분기 추가 |
|
||||
| `ScreenDesigner.tsx` | 입력 필드 포커스 시 키보드 단축키 기본 동작 허용 (Ctrl+A/C/V/Z) |
|
||||
| `RealtimePreviewDynamic.tsx` | 버튼 컴포넌트 position wrapper에서 border 속성 분리 (이중 테두리 방지) |
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- 기본값은 `"text"` 모드 → 기존 모든 버튼은 변경 없이 동작
|
||||
- `displayMode`가 없거나 `"text"`이면 현행 텍스트 렌더링 유지
|
||||
- 아이콘/아이콘+텍스트 모드 전환 시 아이콘 미선택이면 **디폴트 아이콘 자동 부여** (빈 상태 방지)
|
||||
- 커스텀 아이콘 삭제 시 텍스트 모드로 빠지지 않고 **디폴트 아이콘으로 자동 복귀**
|
||||
- 아이콘 모드에서도 `text` 값은 유지 (접근성 aria-label로 활용)
|
||||
- 기본 아이콘은 lucide-react 사용 (프로젝트 일관성)
|
||||
- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능
|
||||
- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장
|
||||
- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
# [맥락노트] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
|
||||
|
||||
> 관련 문서: [계획서](./BIC[계획]-버튼-아이콘화.md) | [체크리스트](./BIC[체크]-버튼-아이콘화.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 현재 모든 버튼은 텍스트로만 표시 → 버튼 영역이 넓어야 하고, 모바일/태블릿에서 공간 효율이 낮음
|
||||
- "저장", "삭제", "추가" 같은 자주 쓰는 버튼은 아이콘만으로 충분히 인식 가능
|
||||
- 관리자가 화면 레이아웃을 더 컴팩트하게 구성할 수 있도록 선택권 제공
|
||||
- 단, "출하 계획" 같이 아이콘화가 어려운 특수 버튼이 존재하므로 텍스트 모드도 반드시 유지
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. 표시 모드는 3종 라디오 그룹(토글 형태)으로 구현
|
||||
|
||||
- **결정**: `ToggleGroup` 형태의 세 개 옵션 (텍스트 / 아이콘 / 아이콘+텍스트)
|
||||
- **근거**: 세 모드는 상호 배타적. 아이콘+텍스트 병합 모드가 있어야 `[ + 추가 ]`, `[ 💾 저장 ]` 같은 실무 패턴을 지원. 아이콘만으로 의미 전달이 부족한 경우 텍스트를 병기하면 사용자 인식 속도가 빨라짐
|
||||
- **대안 검토**: Switch(토글) → 기각 ("무엇이 켜지는지" 직관적이지 않음, 3종 불가)
|
||||
|
||||
### 2. 기본값은 텍스트 모드
|
||||
|
||||
- **결정**: `displayMode` 기본값 = `"text"`
|
||||
- **근거**: 기존 모든 버튼은 텍스트로 동작 중. 아이콘 모드는 명시적으로 선택해야만 적용되어야 하위 호환성이 보장됨
|
||||
- **중요**: `displayMode`가 `undefined`이거나 `"text"`이면 현행 동작 그대로 유지
|
||||
|
||||
### 3. 아이콘은 버튼 액션(action.type)에 연동
|
||||
|
||||
- **결정**: 버튼 액션을 변경하면 해당 액션에 맞는 추천 아이콘 목록이 자동으로 갱신됨
|
||||
- **근거**: 관리자가 "저장" 아이콘을 고른 뒤 액션을 "삭제"로 바꾸면 혼란 발생. 액션별로 적절한 아이콘 후보를 보여주는 것이 자연스러움
|
||||
- **주의**: 액션 변경 시 이전에 선택한 아이콘이 새 액션의 추천 목록에 없으면 선택 초기화
|
||||
|
||||
### 4. 액션별 아이콘은 6개씩 제공, 적절한 아이콘이 없으면 안내 문구
|
||||
|
||||
- **결정**: 활성 액션 14개 각각에 6개의 lucide-react 아이콘 후보 제공
|
||||
- **근거**: 너무 적으면 선택지 부족, 너무 많으면 선택 피로. 6개가 2행 그리드로 깔끔하게 표시됨
|
||||
- **deprecated/숨김 액션**: UI에서 숨김 처리된 액션은 추천 아이콘 없이 안내 문구만 표시
|
||||
|
||||
### 5. 커스텀 아이콘 추가는 2가지 방법 제공
|
||||
|
||||
- **결정**: (1) lucide 아이콘 검색 + (2) 외부 SVG 붙여넣기 두 가지 경로 제공
|
||||
- **근거**: lucide 내장 아이콘만으로는 부족한 경우 존재 (회사 로고, 업종별 특수 아이콘 등). 외부에서 가져온 SVG를 직접 붙여넣기로 등록할 수 있어야 실무 유연성 확보
|
||||
- **lucide 추가**: "lucide 검색" 버튼 → 팝오버에서 검색 → 선택 → `customIcons` 배열에 이름 추가
|
||||
- **SVG 추가**: "SVG 붙여넣기" 버튼 → textarea에 SVG 코드 붙여넣기 → 미리보기 확인 → 이름 입력 → `customSvgIcons` 배열에 `{ name, svg }` 저장
|
||||
- **SVG 유효성**: `<svg` 태그 포함 여부로 기본 검증, XSS 방지를 위해 DOMPurify로 정화 후 저장
|
||||
- **범위**: 모든 커스텀 아이콘은 **해당 버튼 컴포넌트에 저장** (lucide: `customIcons`, SVG: `customSvgIcons`)
|
||||
- **노출**: 커스텀 아이콘(lucide/SVG 모두)은 어떤 버튼 액션에서도 추천 아이콘 아래에 함께 노출됨
|
||||
- **삭제**: 커스텀 아이콘 위에 X 버튼으로 개별 삭제 가능
|
||||
|
||||
### 5-1. 외부 SVG 붙여넣기의 보안 고려
|
||||
|
||||
- **결정**: SVG 문자열을 DOMPurify로 정화(sanitize)한 뒤 저장
|
||||
- **근거**: SVG에 `<script>`, `onload` 같은 악성 코드가 포함될 수 있으므로 XSS 방지 필수
|
||||
- **렌더링**: 정화된 SVG를 `dangerouslySetInnerHTML`로 렌더링 (정화 후이므로 안전)
|
||||
- **대안 검토**: SVG를 이미지 파일로 업로드 → 기각 (관리자 입장에서 복사-붙여넣기가 훨씬 간편)
|
||||
|
||||
### 6. 아이콘 색상은 별도 설정, 기본값은 textColor 상속
|
||||
|
||||
- **결정**: `icon.color` 옵션 추가. 미설정 시 `textColor`를 상속, 설정하면 아이콘만 해당 색상 적용
|
||||
- **근거**: 아이콘+텍스트 모드에서 `[ 초록✓ 검정저장 ]` 같이 아이콘과 텍스트 색을 분리하고 싶은 경우 존재. 삭제 버튼에 빨간 아이콘 + 흰 텍스트 같은 세밀한 디자인도 가능
|
||||
- **기본값**: 미설정 (= `textColor` 상속) → 설정하지 않으면 기존 동작과 100% 동일
|
||||
- **외부 SVG**: `fill`이 하드코딩된 SVG는 이 설정 무시 (SVG 원본 색상 유지가 의도). `currentColor`를 사용하는 SVG만 영향받음
|
||||
- **구현**: 아이콘을 `<span style={{ color: icon.color }}>`으로 감싸서 아이콘만 색상 분리
|
||||
- **초기화**: "텍스트 색상과 동일" 버튼으로 별도 색상 해제 → `icon.color` 삭제
|
||||
|
||||
### 7. 아이콘 크기는 버튼 높이 대비 비율(%) 프리셋 4단계
|
||||
|
||||
- **결정**: 작게(40%) / 보통(55%) / 크게(70%) / 매우 크게(85%) — 버튼 높이 대비 비율
|
||||
- **근거**: 절대 px 값은 버튼 크기가 바뀌면 비율이 깨짐. 비율 기반이면 버튼 크기를 조정해도 아이콘이 자동으로 비례하여 일관된 시각적 균형 유지
|
||||
- **기본값**: `"보통"` (55%) — 대부분의 버튼 크기에 적합
|
||||
- **px 직접 입력 제거**: 관리자에게 과도한 선택지를 주면 오히려 일관성이 깨짐. 4단계 프리셋만으로 충분
|
||||
- **구현**: CSS `height: N%` + `aspect-ratio: 1/1`로 정사각형 유지, lucide 아이콘은 래핑 span으로 크기 제어
|
||||
- **레거시 호환**: 기존 `"sm"`, `"md"` 등 레거시 값은 55%(보통)로 자동 폴백
|
||||
|
||||
### 8. 아이콘 동적 렌더링은 매핑 객체 방식
|
||||
|
||||
- **결정**: lucide-react 아이콘 이름(string) → 실제 컴포넌트 매핑 객체를 별도 파일로 관리
|
||||
- **근거**: `import * from 'lucide-react'`는 번들 크기에 영향. 사용하는 아이콘만 명시적으로 매핑
|
||||
- **파일**: `frontend/lib/button-icon-map.ts`
|
||||
- **구현**: `Record<string, React.ComponentType>` 형태의 매핑 + `renderIcon(name, size)` 유틸 함수
|
||||
|
||||
### 9. 아이콘 모드에서도 text 값은 유지
|
||||
|
||||
- **결정**: `displayMode === "icon"`이어도 `text` 필드는 삭제하지 않음
|
||||
- **근거**: 접근성(`aria-label`), 검색/필터링 등에 텍스트가 필요할 수 있음
|
||||
- **렌더링**: 아이콘 모드에서는 `text`를 `aria-label` 용도로만 보존
|
||||
- **아이콘+텍스트 모드**: `text`가 아이콘 오른쪽에 함께 렌더링됨
|
||||
|
||||
### 10. 아이콘-텍스트 간격 설정 추가
|
||||
|
||||
- **결정**: 아이콘+텍스트 모드에서 아이콘과 텍스트 사이 간격을 관리자가 조절 가능 (`iconGap`)
|
||||
- **근거**: 고정 `gap-1.5`(6px)로는 다양한 버튼 크기/디자인에 대응 불가. 간격이 좁으면 답답하고, 넓으면 분리되어 보이는 경우가 있어 관리자에게 조절 권한 제공
|
||||
- **기본값**: 6px (기존 `gap-1.5`와 동일)
|
||||
- **UI**: 슬라이더(0~32px) + 숫자 직접 입력(최댓값 제한 없음)
|
||||
- **저장**: `componentConfig.iconGap` (숫자)
|
||||
|
||||
### 11. 키보드 단축키 입력 필드 충돌 해결
|
||||
|
||||
- **결정**: `ScreenDesigner`의 글로벌 키보드 핸들러에서 입력 필드 포커스 시 앱 단축키를 무시하도록 수정
|
||||
- **근거**: SVG 붙여넣기 textarea에서 Ctrl+V/A/C/Z가 작동하지 않는 치명적 UX 문제 발견. 글로벌 `keydown` 핸들러가 `{ capture: true }`로 모든 키보드 이벤트를 가로채고 있었음
|
||||
- **수정**: `browserShortcuts` 일괄 차단과 앱 전용 단축키 처리 앞에 `e.target`/`document.activeElement` 기반 입력 필드 감지 가드 추가
|
||||
- **영향**: input, textarea, select, contentEditable 요소에서 텍스트 편집 단축키가 정상 동작
|
||||
|
||||
### 12. noIconAction에서 커스텀 아이콘 추가 허용
|
||||
|
||||
- **결정**: 추천 아이콘이 없는 deprecated 액션에서도 커스텀 아이콘(lucide 검색, SVG 붙여넣기) 추가 가능
|
||||
- **근거**: "적절한 아이콘이 없습니다" 문구만 표시하고 아이콘 추가를 완전 차단하면 관리자가 필요한 아이콘을 직접 등록할 방법이 없음. 추천은 없지만 직접 추가는 허용해야 유연성 확보
|
||||
- **안내 문구**: "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요."
|
||||
|
||||
### 13. 아이콘 모드 레이아웃 안내 문구
|
||||
|
||||
- **결정**: 아이콘 모드(`"icon"`) 선택 시 "버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다" 안내 표시
|
||||
- **근거**: 아이콘 자체는 항상 정사각형(24x24 viewBox)이지만, 디자이너에서 버튼 컨테이너는 가로로 넓은 직사각형이 기본. 아이콘만 넣으면 좌우 여백이 과다해 보이므로 버튼 영역을 줄이라는 안내가 필요. 자동 크기 조정은 기존 레이아웃을 깨뜨릴 위험이 있어 도입하지 않되, 관리자에게 팁을 제공하면 스스로 최적화할 수 있음
|
||||
- **표시 조건**: `displayMode === "icon"`일 때만 (아이콘+텍스트 모드는 가로 공간이 필요하므로 해당 안내 불필요)
|
||||
- **대안 검토**: 자동 정사각형 조정 → 기각 (관리자 수동 레이아웃 파괴 위험)
|
||||
|
||||
### 14. 디폴트 아이콘 자동 부여
|
||||
|
||||
- **결정**: 아이콘/아이콘+텍스트 모드 전환 시 아이콘이 미선택이면 디폴트 아이콘을 자동으로 부여. 커스텀 아이콘 삭제 시에도 텍스트 모드로 빠지지 않고 디폴트 아이콘으로 복귀
|
||||
- **근거**: 아이콘 모드로 전환했는데 아무것도 안 보이면 "기능이 작동하지 않는다"는 착각을 유발. 또한 커스텀 아이콘을 삭제했을 때 갑자기 텍스트로 빠지면 관리자가 의도치 않은 모드 변경을 경험하게 됨
|
||||
- **디폴트 선택 기준**: 해당 액션의 첫 번째 추천 아이콘 (예: save → Check). 추천 아이콘이 없는 액션은 범용 폴백 `SquareMousePointer` 사용
|
||||
- **구현**: `getDefaultIconForAction(actionType)` 유틸 함수로 중앙화 (`button-icon-map.tsx`)
|
||||
- **폴백 아이콘**: `SquareMousePointer` — 마우스 포인터 + 사각형 형태로 "버튼 클릭 동작"을 범용적으로 표현
|
||||
|
||||
### 15. 아이콘+텍스트 모드에서 텍스트 위치 4방향 지원
|
||||
|
||||
- **결정**: 아이콘 대비 텍스트 위치를 왼쪽/오른쪽/위쪽/아래쪽 4방향으로 설정 가능
|
||||
- **근거**: 기존에는 아이콘 오른쪽에 텍스트 고정이었으나, 세로 배치(위/아래)가 필요한 경우도 존재 (좁고 높은 버튼, 툴바 스타일). 4방향을 제공하면 관리자가 버튼 모양에 맞게 레이아웃 선택 가능
|
||||
- **기본값**: `"right"` (아이콘 오른쪽에 텍스트) — 가장 자연스러운 좌→우 읽기 방향
|
||||
- **구현**: `flexDirection` (row/column) + 요소 순서 (textFirst) 조합으로 4방향 구현
|
||||
- **저장**: `componentConfig.iconTextPosition`
|
||||
- **표시 조건**: 아이콘+텍스트 모드에서만 표시 (아이콘 모드, 텍스트 모드에서는 숨김)
|
||||
|
||||
### 16. 버튼 컴포넌트 테두리 이중 적용 문제 해결
|
||||
|
||||
- **결정**: `RealtimePreviewDynamic`의 position wrapper에서 버튼 컴포넌트의 border 속성을 분리(strip)
|
||||
- **근거**: StyleEditor에서 설정한 border가 (1) position wrapper와 (2) 내부 버튼 요소 두 곳에 모두 적용되어 이중 테두리 발생. border는 내부 버튼(`buttonElementStyle`)에서만 렌더링해야 함
|
||||
- **수정 파일**: `RealtimePreviewDynamic.tsx` — `isButtonComponent` 조건에 `v2-button-primary` 추가하여 border strip 대상에 포함
|
||||
- **수정 파일**: `ButtonPrimaryComponent.tsx` — 외부 wrapper(`componentStyle`)에서 border 속성 destructure로 제거, `border: "none"` shorthand 대신 개별 longhand 속성으로 변경 (borderStyle 미설정 시 기본 `"solid"` 적용)
|
||||
|
||||
### 17. 커스텀 아이콘 검색은 lucide 전체 목록 기반
|
||||
|
||||
- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능
|
||||
- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수
|
||||
- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링
|
||||
- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 설정 패널 (수정) | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 버튼 텍스트/액션 설정 (784~854행에 모드 선택 추가) |
|
||||
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | 버튼 렌더링 분기 (961~983행) |
|
||||
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) |
|
||||
| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
|
||||
| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) |
|
||||
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 |
|
||||
| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### lucide-react 아이콘 동적 렌더링
|
||||
|
||||
```typescript
|
||||
// button-icon-map.ts
|
||||
import { Check, Save, Trash2, Pencil, ... } from "lucide-react";
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Check, Save, Trash2, Pencil, ...
|
||||
};
|
||||
|
||||
export function renderButtonIcon(name: string, size: string | number) {
|
||||
const IconComponent = iconMap[name];
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent style={getIconSizeStyle(size)} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 아이콘 크기 비율 매핑 (버튼 높이 대비 %)
|
||||
|
||||
```typescript
|
||||
const iconSizePresets: Record<string, number> = {
|
||||
"작게": 40,
|
||||
"보통": 55,
|
||||
"크게": 70,
|
||||
"매우 크게": 85,
|
||||
};
|
||||
|
||||
// 프리셋 문자열 → 비율(%) 반환. 레거시 값은 55(보통)로 폴백
|
||||
export function getIconPercent(size: string | number): number {
|
||||
if (typeof size === "number") return size;
|
||||
return iconSizePresets[size] ?? 55;
|
||||
}
|
||||
|
||||
// 버튼 높이 대비 비율 + 정사각형 유지
|
||||
export function getIconSizeStyle(size: string | number): React.CSSProperties {
|
||||
const pct = getIconPercent(size);
|
||||
return { height: `${pct}%`, width: "auto", aspectRatio: "1 / 1" };
|
||||
}
|
||||
```
|
||||
|
||||
### 외부 SVG 아이콘 렌더링
|
||||
|
||||
```typescript
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export function renderSvgIcon(svgString: string, size: string | number) {
|
||||
const clean = DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } });
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center"
|
||||
style={getIconSizeStyle(size)}
|
||||
dangerouslySetInnerHTML={{ __html: clean }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 버튼 액션별 추천 아이콘 구조
|
||||
|
||||
```typescript
|
||||
const actionIconMap: Record<string, string[]> = {
|
||||
save: ["Check", "Save", "CheckCircle", "CircleCheck", "FileCheck", "ShieldCheck"],
|
||||
delete: ["Trash2", "Trash", "XCircle", "X", "Eraser", "CircleX"],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 현재 버튼 액션 목록 (활성)
|
||||
|
||||
| 값 | 표시명 | 아이콘화 가능 |
|
||||
|-----|--------|-------------|
|
||||
| `save` | 저장 | O |
|
||||
| `delete` | 삭제 | O |
|
||||
| `edit` | 편집 | O |
|
||||
| `navigate` | 페이지 이동 | O |
|
||||
| `modal` | 모달 열기 | O |
|
||||
| `transferData` | 데이터 전달 | O |
|
||||
| `excel_download` | 엑셀 다운로드 | O |
|
||||
| `excel_upload` | 엑셀 업로드 | O |
|
||||
| `quickInsert` | 즉시 저장 | O |
|
||||
| `control` | 제어 흐름 | O |
|
||||
| `barcode_scan` | 바코드 스캔 | O |
|
||||
| `operation_control` | 운행알림 및 종료 | O |
|
||||
| `event` | 이벤트 발송 | O |
|
||||
| `copy` | 복사 (품목코드 초기화) | O |
|
||||
|
||||
### 현재 버튼 액션 목록 (숨김/deprecated)
|
||||
|
||||
| 값 | 표시명 | 아이콘화 가능 |
|
||||
|-----|--------|-------------|
|
||||
| `openRelatedModal` | 연관 데이터 버튼 모달 열기 | X (적절한 아이콘 없음) |
|
||||
| `openModalWithData` | (deprecated) 데이터 전달 + 모달 | X |
|
||||
| `view_table_history` | 테이블 이력 보기 | X |
|
||||
| `code_merge` | 코드 병합 | X |
|
||||
| `empty_vehicle` | 공차등록 | X |
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
# [체크리스트] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
|
||||
|
||||
> 관련 문서: [계획서](./BIC[계획]-버튼-아이콘화.md) | [맥락노트](./BIC[맥락]-버튼-아이콘화.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (전 단계 구현 및 검증 완료)
|
||||
- 현재 단계: 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 아이콘 매핑 파일 생성
|
||||
|
||||
- [x] `frontend/lib/button-icon-map.tsx` 생성
|
||||
- [x] 버튼 액션별 추천 아이콘 매핑 (`actionIconMap`) 정의 (14개 액션 x 6개 아이콘)
|
||||
- [x] 아이콘 크기 비율 매핑 (`iconSizePresets`) 정의 (작게/보통/크게/매우 크게, 버튼 높이 대비 %) + `getIconSizeStyle()` 유틸
|
||||
- [x] lucide 아이콘 동적 렌더링 포함 `getButtonDisplayContent()` 구현
|
||||
- [x] SVG 아이콘 렌더링 (DOMPurify 정화 via `isomorphic-dompurify`)
|
||||
- [x] 아이콘 이름 → 컴포넌트 매핑 객체 (`iconMap`) + `addToIconMap()` 동적 추가
|
||||
- [x] deprecated 액션용 안내 문구 상수 (`NO_ICON_MESSAGE`) 정의
|
||||
- [x] `isomorphic-dompurify` 기존 설치 확인 (추가 설치 불필요)
|
||||
- [x] `ButtonIconRenderer` 공용 컴포넌트 추가 (모든 렌더러에서 재사용)
|
||||
- [x] `getDefaultIconForAction()` 디폴트 아이콘 유틸 함수 추가 (액션별 첫 번째 추천 / 범용 폴백)
|
||||
- [x] `FALLBACK_ICON_NAME` 상수 + `SquareMousePointer` import/매핑 추가
|
||||
|
||||
### 2단계: ButtonConfigPanel 수정
|
||||
|
||||
- [x] 표시 모드 버튼 그룹 UI 추가 (텍스트 / 아이콘 / 아이콘+텍스트)
|
||||
- [x] `displayMode` 상태 관리 및 `onUpdateProperty` 연동
|
||||
- [x] 아이콘 모드 선택 시 조건부 UI 분기 (텍스트 입력 숨김 → 아이콘 선택 표시)
|
||||
- [x] 아이콘+텍스트 모드 선택 시 아이콘 선택 + 텍스트 입력 **동시** 표시
|
||||
- [x] 버튼 액션별 추천 아이콘 그리드 렌더링 (4열 그리드)
|
||||
- [x] 선택된 아이콘 하이라이트 (`ring-2 ring-primary/30 border-primary`)
|
||||
- [x] 아이콘 크기 비율 프리셋 버튼 그룹 (작게/보통/크게/매우 크게, 한글 라벨)
|
||||
- [x] px 직접 입력 필드 제거 (비율 프리셋만 제공)
|
||||
- [x] `icon.name`, `icon.size` 를 `onUpdateProperty`로 저장
|
||||
- [x] 아이콘 색상 컬러 피커 구현 (`ColorPickerWithTransparent` 재사용)
|
||||
- [x] "텍스트 색상과 동일" 초기화 버튼 구현
|
||||
- [x] 텍스트 위치 4방향 설정 추가 (`iconTextPosition`, 왼쪽/오른쪽/위쪽/아래쪽)
|
||||
- [x] 아이콘-텍스트 간격 설정 추가 (`iconGap`, 슬라이더 + 직접 입력, 아이콘+텍스트 모드 전용)
|
||||
- [x] 아이콘 모드 레이아웃 안내 문구 표시 (Info 아이콘 + bg-blue-50 박스)
|
||||
- [x] 액션 변경 시 선택 아이콘 자동 초기화 로직 (추천 목록에 없으면 해제)
|
||||
- [x] deprecated 액션에서 안내 문구 + 커스텀 아이콘 추가 버튼 표시
|
||||
- [x] 아이콘/아이콘+텍스트 모드 전환 시 아이콘 미선택이면 디폴트 아이콘 자동 부여
|
||||
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 자동 복귀 (텍스트 모드 전환 방지)
|
||||
|
||||
### 3단계: 커스텀 아이콘 추가/삭제 (lucide 검색)
|
||||
|
||||
- [x] "lucide 검색" 버튼 UI
|
||||
- [x] lucide 아이콘 검색 팝오버 (Popover + Command + CommandInput)
|
||||
- [x] `import { icons } from "lucide-react"` 기반 전체 아이콘 검색/필터링
|
||||
- [x] 선택 시 `componentConfig.customIcons` 배열 추가 + `addToIconMap` 동적 등록
|
||||
- [x] lucide 커스텀 아이콘 그리드 렌더링 (추천 아이콘 아래, 구분선 포함)
|
||||
- [x] lucide 커스텀 아이콘 X 버튼으로 개별 삭제
|
||||
|
||||
### 3-1단계: 커스텀 아이콘 추가/삭제 (SVG 붙여넣기)
|
||||
|
||||
- [x] "SVG 붙여넣기" 버튼 UI (Popover)
|
||||
- [x] SVG 입력 textarea + DOMPurify 실시간 미리보기
|
||||
- [x] SVG 유효성 검사 (`<svg` 태그 포함 여부)
|
||||
- [x] 아이콘 이름 입력 필드 (관리자가 구분용 이름 지정)
|
||||
- [x] DOMPurify로 SVG 정화(sanitize) 후 저장
|
||||
- [x] `componentConfig.customSvgIcons` 배열에 `{ name, svg }` 추가
|
||||
- [x] SVG 커스텀 아이콘 그리드 렌더링 (lucide 커스텀 아이콘과 함께 표시)
|
||||
- [x] SVG 커스텀 아이콘 X 버튼으로 개별 삭제
|
||||
- [x] 커스텀 아이콘(lucide + SVG 모두)이 모든 버튼 액션에서 공통 노출
|
||||
|
||||
### 4단계: 버튼 렌더링 수정 (뷰어/위젯)
|
||||
|
||||
- [x] `InteractiveScreenViewerDynamic.tsx` - `ButtonIconRenderer` 적용
|
||||
- [x] `InteractiveScreenViewer.tsx` - `ButtonIconRenderer` 적용
|
||||
- [x] `ButtonWidget.tsx` - `ButtonIconRenderer` 적용 (디자인/실행 모드 모두)
|
||||
- [x] `OptimizedButtonComponent.tsx` - `ButtonIconRenderer` 적용 (실행 중 "처리 중..." 유지)
|
||||
- [x] `ButtonPrimaryComponent.tsx` - `ButtonIconRenderer` 적용 (v2-button-primary 캔버스 렌더링)
|
||||
- [x] lucide 아이콘 렌더링 (`icon.type === "lucide"`, `getLucideIcon` 조회)
|
||||
- [x] SVG 아이콘 렌더링 (`icon.type === "svg"`, DOMPurify 정화 후 innerHTML)
|
||||
- [x] 아이콘+텍스트 모드: `inline-flex items-center` + 동적 `gap` (iconGap px)
|
||||
- [x] `icon.color` 설정 시 아이콘만 별도 색상 적용 (inline style)
|
||||
- [x] `icon.color` 미설정 시 textColor 상속 (currentColor 기본)
|
||||
- [x] 아이콘 크기 비율 프리셋 `getIconSizeStyle()` 처리 (버튼 높이 대비 %)
|
||||
- [x] 텍스트 위치 4방향 렌더링 (`flexDirection` + 요소 순서 조합)
|
||||
|
||||
### 4-2단계: 버튼 테두리 이중 적용 수정
|
||||
|
||||
- [x] `RealtimePreviewDynamic.tsx` — position wrapper에서 버튼 컴포넌트 border strip 추가
|
||||
- [x] `ButtonPrimaryComponent.tsx` — 외부 wrapper에서 border 속성 destructure 제거
|
||||
- [x] `ButtonPrimaryComponent.tsx` — `border: "none"` shorthand 제거, 개별 longhand 속성으로 변경
|
||||
- [x] `isButtonComponent` 조건에 `"v2-button-primary"` 추가
|
||||
|
||||
### 4-1단계: 키보드 단축키 충돌 수정
|
||||
|
||||
- [x] `ScreenDesigner.tsx` 글로벌 keydown 핸들러에 입력 필드 감지 가드 추가
|
||||
- [x] `browserShortcuts` 배열에서 `Ctrl+V` 제거
|
||||
- [x] 입력 필드(input/textarea/select/contentEditable) 포커스 시 Ctrl+A/C/V/Z 기본 동작 허용
|
||||
- [x] SVG 붙여넣기 textarea에 `onPaste`/`onKeyDown` stopPropagation 핸들러 추가
|
||||
|
||||
### 5단계: 검증
|
||||
|
||||
- [x] 텍스트 모드: 기존 동작 변화 없음 확인 (하위 호환성)
|
||||
- [x] `displayMode` 없는 기존 버튼: 텍스트 모드로 정상 동작
|
||||
- [x] 아이콘 모드 선택 → 추천 아이콘 6개 그리드 표시
|
||||
- [x] 아이콘 선택 → 캔버스(오른쪽 프리뷰) 및 실제 화면에서 아이콘 렌더링 확인
|
||||
- [x] 아이콘 크기 비율 프리셋 변경 → 버튼 높이 대비 비율 반영 확인
|
||||
- [x] 텍스트 위치 4방향(왼/오른/위/아래) 변경 → 레이아웃 방향 반영 확인
|
||||
- [x] 버튼 테두리 설정 → 내부 버튼에만 적용, 외부 wrapper에 이중 적용 없음 확인
|
||||
- [x] 버튼 액션 변경 → 추천 아이콘 목록 갱신 확인
|
||||
- [x] lucide 커스텀 아이콘 추가 → 모든 액션에서 노출 확인
|
||||
- [x] SVG 커스텀 아이콘 붙여넣기 → 미리보기 → 추가 → 모든 액션에서 노출 확인
|
||||
- [x] SVG에 악성 코드 삽입 시도 → DOMPurify 정화 후 안전 렌더링 확인
|
||||
- [x] 커스텀 아이콘 삭제 → 목록에서 제거 확인
|
||||
- [x] deprecated 액션에서 안내 문구 + 커스텀 아이콘 추가 가능 확인
|
||||
- [x] 아이콘+텍스트 모드: 아이콘 + 텍스트 나란히 렌더링 확인
|
||||
- [x] 아이콘+텍스트 간격 조절: 슬라이더/직접 입력으로 간격 변경 → 실시간 반영 확인
|
||||
- [x] 아이콘 색상 미설정 → textColor와 동일한 색상 확인
|
||||
- [x] 아이콘 색상 설정 → 아이콘만 해당 색상, 텍스트는 textColor 유지 확인
|
||||
- [x] 외부 SVG (고유 색상) → icon.color 설정해도 SVG 원본 색상 유지 확인
|
||||
- [x] "텍스트 색상과 동일" 버튼 → icon.color 해제되고 textColor 상속 복원 확인
|
||||
- [x] 레이아웃 안내 문구: 아이콘 모드에서만 표시, 다른 모드에서 숨김 확인
|
||||
- [x] 입력 필드에서 Ctrl+A/C/V/Z 단축키 정상 동작 확인
|
||||
- [x] 아이콘 모드 전환 시 디폴트 아이콘 자동 선택 → 캔버스에 즉시 반영 확인
|
||||
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인
|
||||
- [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인
|
||||
|
||||
### 6단계: 정리
|
||||
|
||||
- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러)
|
||||
- [x] 불필요한 import 없음 확인
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-04 | 계획서, 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-04 | 외부 SVG 붙여넣기 기능 추가 (3개 문서 모두 반영) |
|
||||
| 2026-03-04 | 아이콘+텍스트 모드, 레이아웃 안내 추가 |
|
||||
| 2026-03-04 | 설정 패널 내 미리보기 제거 (오른쪽 캔버스 프리뷰로 대체) |
|
||||
| 2026-03-04 | 아이콘 색상 설정 추가 (icon.color, 기본값 textColor 상속) |
|
||||
| 2026-03-04 | 3개 문서 교차 검토 — 개요 누락 보완, 시각 예시 문구 통일, 렌더 함수 px 대응, 용어 명확화 |
|
||||
| 2026-03-04 | 구현 완료 — 1~4단계 코드 작성, 6단계 린트/타입 검증 통과 |
|
||||
| 2026-03-04 | 아이콘-텍스트 간격 설정 추가 (iconGap, 슬라이더+직접 입력) |
|
||||
| 2026-03-04 | noIconAction에서 커스텀 아이콘 추가 허용 + 안내 문구 변경 |
|
||||
| 2026-03-04 | ScreenDesigner 키보드 단축키 수정 — 입력 필드에서 텍스트 편집 단축키 허용 |
|
||||
| 2026-03-04 | SVG 붙여넣기 textarea에 onPaste/onKeyDown 핸들러 추가 |
|
||||
| 2026-03-04 | SVG 커스텀 아이콘 이름 중복 방지 (자동 넘버링) |
|
||||
| 2026-03-04 | 디폴트 아이콘 자동 부여 — 모드 전환 시 자동 선택, 커스텀 삭제 시 디폴트 복귀 |
|
||||
| 2026-03-04 | `getDefaultIconForAction()` 유틸 + `SquareMousePointer` 폴백 아이콘 추가 |
|
||||
| 2026-03-04 | 3개 문서 변경사항 동기화 및 코드 정리 |
|
||||
| 2026-03-04 | 아이콘 크기: 절대 px → 버튼 높이 대비 비율(%) 4단계 프리셋으로 변경, px 직접 입력 제거 |
|
||||
| 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) |
|
||||
| 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 |
|
||||
| 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# [계획서] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
|
||||
|
||||
> 관련 문서: [맥락노트](./MST[맥락노트]-v2select-multiselect-tooltip.md) | [체크리스트](./MST[체크리스트]-v2select-multiselect-tooltip.md)
|
||||
|
||||
## 개요
|
||||
|
||||
모든 화면에서 다중 선택 가능한 드롭다운(`V2Select` - `DropdownSelect`)의 선택 항목 표시 방식을 개선합니다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 동작
|
||||
|
||||
- 다중 선택 시 `"3개 선택됨"` 같은 텍스트만 표시
|
||||
- 어떤 항목이 선택되었는지 드롭다운을 열어야만 확인 가능
|
||||
|
||||
### 현재 코드 (V2Select.tsx - DropdownSelect, 174~178행)
|
||||
|
||||
```tsx
|
||||
{selectedLabels.length > 0
|
||||
? multiple
|
||||
? `${selectedLabels.length}개 선택됨`
|
||||
: selectedLabels[0]
|
||||
: placeholder}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 후 동작
|
||||
|
||||
### 1. 선택된 항목 라벨을 쉼표로 연결하여 한 줄로 표시
|
||||
|
||||
- 예: `"구매품, 판매품, 재고품"`
|
||||
- `truncate` (text-overflow: ellipsis)로 필드 너비를 넘으면 말줄임(`...`) 처리
|
||||
- 무조건 한 줄 표시, 넘치면 `...`으로 숨김
|
||||
|
||||
### 2. 텍스트가 말줄임(`...`) 처리될 때만 호버 툴팁 표시
|
||||
|
||||
- 필드 너비를 넘어서 `...`으로 잘릴 때만 툴팁 활성화
|
||||
- 필드 내에 전부 보이면 툴팁 불필요
|
||||
- 툴팁 내용은 세로 나열로 각 항목을 한눈에 확인 가능
|
||||
- 툴팁은 딜레이 없이 즉시 표시
|
||||
|
||||
---
|
||||
|
||||
## 시각적 동작 예시
|
||||
|
||||
| 상태 | 필드 내 표시 | 호버 시 툴팁 |
|
||||
|------|-------------|-------------|
|
||||
| 미선택 | `선택` (placeholder) | 없음 |
|
||||
| 1개 선택 | `구매품` | 없음 |
|
||||
| 3개 선택 (필드 내 수용) | `구매품, 판매품, 재고품` | 없음 (잘리지 않으므로) |
|
||||
| 5개 선택 (필드 넘침) | `구매품, 판매품, 재고품, 외...` | 구매품 / 판매품 / 재고품 / 외주품 / 반제품 (세로 나열) |
|
||||
|
||||
---
|
||||
|
||||
## 변경 대상
|
||||
|
||||
- **파일**: `frontend/components/v2/V2Select.tsx`
|
||||
- **컴포넌트**: `DropdownSelect` 내부 표시 텍스트 부분 (170~178행)
|
||||
- **적용 범위**: `DropdownSelect`를 사용하는 모든 화면 (품목정보, 기타 모든 모달 포함)
|
||||
- **변경 규모**: 약 30줄 내외 소규모 변경
|
||||
|
||||
---
|
||||
|
||||
## 코드 설계
|
||||
|
||||
### 추가 import
|
||||
|
||||
```tsx
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
```
|
||||
|
||||
### 말줄임 감지 로직
|
||||
|
||||
```tsx
|
||||
// 텍스트가 잘리는지(truncated) 감지
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = textRef.current;
|
||||
if (el) {
|
||||
setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
}
|
||||
}, [selectedLabels]);
|
||||
```
|
||||
|
||||
### 수정 코드 (DropdownSelect 내부, 170~178행 대체)
|
||||
|
||||
```tsx
|
||||
const displayText = selectedLabels.length > 0
|
||||
? (multiple ? selectedLabels.join(", ") : selectedLabels[0])
|
||||
: placeholder;
|
||||
|
||||
const isPlaceholder = selectedLabels.length === 0;
|
||||
|
||||
// 렌더링 부분
|
||||
{isTruncated && multiple ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn("truncate flex-1 text-left", isPlaceholder && "text-muted-foreground")}
|
||||
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px]">
|
||||
<div className="space-y-0.5 text-xs">
|
||||
{selectedLabels.map((label, i) => (
|
||||
<div key={i}>{label}</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn("truncate flex-1 text-left", isPlaceholder && "text-muted-foreground")}
|
||||
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- 기존 단일 선택 동작은 변경하지 않음
|
||||
- `DropdownSelect` 공통 컴포넌트 수정이므로 모든 화면에 자동 적용
|
||||
- 무조건 한 줄 표시, 넘치면 `...`으로 말줄임
|
||||
- 툴팁은 텍스트가 실제로 잘릴 때(`scrollWidth > clientWidth`)만 표시
|
||||
- 툴팁 내용은 세로 나열로 각 항목 확인 용이
|
||||
- 툴팁 딜레이 없음 (`delayDuration={0}`)
|
||||
- shadcn 표준 Tooltip 컴포넌트 사용으로 프로젝트 일관성 유지
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# [맥락노트] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
|
||||
|
||||
> 관련 문서: [계획서](./MST[계획서]-v2select-multiselect-tooltip.md) | [체크리스트](./MST[체크리스트]-v2select-multiselect-tooltip.md)
|
||||
|
||||
---
|
||||
|
||||
## 왜 이 작업을 하는가
|
||||
|
||||
- 사용자가 수정 모달에서 다중 선택 드롭다운을 사용할 때 `"3개 선택됨"` 만 보임
|
||||
- 드롭다운을 다시 열어봐야만 무엇이 선택됐는지 확인 가능 → UX 불편
|
||||
- 선택 항목을 직접 보여주고, 넘치면 툴팁으로 확인할 수 있게 개선
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 사항과 근거
|
||||
|
||||
### 1. "n개 선택됨" → 라벨 쉼표 나열
|
||||
|
||||
- **결정**: `"구매품, 판매품, 재고품"` 형태로 표시
|
||||
- **근거**: 사용자가 드롭다운을 열지 않아도 선택 내용을 바로 확인 가능
|
||||
|
||||
### 2. 무조건 한 줄, 넘치면 말줄임(`...`)
|
||||
|
||||
- **결정**: 여러 줄 줄바꿈 없이 한 줄 고정, `truncate`로 오버플로우 처리
|
||||
- **근거**: 드롭다운 필드 높이가 고정되어 있어 여러 줄 표시 시 레이아웃이 깨짐
|
||||
|
||||
### 3. 텍스트가 잘릴 때만 툴팁 표시
|
||||
|
||||
- **결정**: `scrollWidth > clientWidth` 비교로 실제 잘림 여부 감지 후 툴팁 활성화
|
||||
- **근거**: 전부 보이는데 툴팁이 뜨면 오히려 방해. 필요할 때만 보여야 함
|
||||
- **대안 검토**: "2개 이상이면 항상 툴팁" → 기각 (불필요한 툴팁 발생)
|
||||
|
||||
### 4. 툴팁 내용은 세로 나열
|
||||
|
||||
- **결정**: 툴팁 안에서 항목을 줄바꿈으로 세로 나열
|
||||
- **근거**: 가로 나열 시 툴팁도 길어져서 읽기 어려움. 세로가 한눈에 파악하기 좋음
|
||||
|
||||
### 5. 툴팁 딜레이 0ms
|
||||
|
||||
- **결정**: `delayDuration={0}` 즉시 표시
|
||||
- **근거**: 사용자가 "무엇을 선택했는지" 확인하려는 의도적 행동이므로 즉시 응답해야 함
|
||||
|
||||
### 6. Radix Tooltip 대신 커스텀 호버 툴팁 사용
|
||||
|
||||
- **결정**: Radix Tooltip을 사용하지 않고 `onMouseEnter`/`onMouseLeave`로 직접 제어
|
||||
- **근거**: Radix Tooltip + Popover 조합은 이벤트 충돌 발생. 내부 배치든 외부 래핑이든 Popover가 호버를 가로챔
|
||||
- **시도 1**: Tooltip을 Button 안에 배치 → Popover가 이벤트 가로챔 (실패)
|
||||
- **시도 2**: Radix 공식 패턴 (TooltipTrigger > PopoverTrigger > Button 체이닝) → 여전히 동작 안 함 (실패)
|
||||
- **최종**: wrapper div에 마우스 이벤트 + 절대 위치 div로 툴팁 렌더링 (성공)
|
||||
- **추가**: Popover 열릴 때 `setHoverTooltip(false)`로 툴팁 자동 숨김
|
||||
|
||||
### 8. 선택 항목 표시 순서는 드롭다운 옵션 순서 기준
|
||||
|
||||
- **결정**: 사용자가 클릭한 순서가 아닌 드롭다운 옵션 목록 순서대로 표시
|
||||
- **근거**: 선택 순서대로 보여주면 매번 순서가 달라져서 혼란. 옵션 순서 기준이 일관적이고 예측 가능
|
||||
- **구현**: `selectedValues.map(...)` → `safeOptions.filter(...).map(...)` 으로 변경
|
||||
|
||||
### 9. DropdownSelect 공통 컴포넌트 수정
|
||||
|
||||
- **결정**: 특정 화면이 아닌 `DropdownSelect` 자체를 수정
|
||||
- **근거**: 품목정보뿐 아니라 모든 화면에서 동일한 문제가 있으므로 공통 해결
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 위치
|
||||
|
||||
| 구분 | 파일 경로 | 설명 |
|
||||
|------|----------|------|
|
||||
| 수정 대상 | `frontend/components/v2/V2Select.tsx` | DropdownSelect 컴포넌트 (170~178행) |
|
||||
| 타입 정의 | `frontend/types/v2-components.ts` | V2SelectProps, SelectOption 타입 |
|
||||
| UI 컴포넌트 | `frontend/components/ui/tooltip.tsx` | shadcn Tooltip 컴포넌트 |
|
||||
| 렌더러 | `frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx` | V2Select를 레지스트리에 연결 |
|
||||
| 수정 모달 | `frontend/components/screen/EditModal.tsx` | 공통 편집 모달 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 참고
|
||||
|
||||
### truncate 감지 방식
|
||||
|
||||
```
|
||||
scrollWidth: 텍스트의 실제 전체 너비 (보이지 않는 부분 포함)
|
||||
clientWidth: 요소의 보이는 너비
|
||||
|
||||
scrollWidth > clientWidth → 텍스트가 잘리고 있음 (... 표시 중)
|
||||
```
|
||||
|
||||
### selectedLabels 계산 흐름
|
||||
|
||||
```
|
||||
value (string[]) → selectedValues → safeOptions에서 label 매칭 → selectedLabels (string[])
|
||||
```
|
||||
|
||||
- `selectedLabels`는 이미 `DropdownSelect` 내부에서 `useMemo`로 계산됨 (126~130행)
|
||||
- 추가 데이터 fetching 불필요, 기존 값을 `.join(", ")`로 결합하면 됨
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# [체크리스트] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
|
||||
|
||||
> 관련 문서: [계획서](./MST[계획서]-v2select-multiselect-tooltip.md) | [맥락노트](./MST[맥락노트]-v2select-multiselect-tooltip.md)
|
||||
|
||||
---
|
||||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **100%** (완료)
|
||||
- 현재 단계: 전체 완료
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### 1단계: 코드 수정
|
||||
|
||||
- [x] `V2Select.tsx`에 Tooltip 관련 import 추가
|
||||
- [x] `DropdownSelect` 내부에 `textRef`, `isTruncated` 상태 추가
|
||||
- [x] `useEffect`로 `scrollWidth > clientWidth` 감지 로직 추가
|
||||
- [x] 표시 텍스트를 `selectedLabels.join(", ")`로 변경
|
||||
- [x] `isTruncated && multiple` 조건으로 Tooltip 래핑
|
||||
- [x] 툴팁 내용을 세로 나열 (`space-y-0.5`)로 구성
|
||||
- [x] `delayDuration={0}` 설정
|
||||
- [x] Radix Tooltip → 커스텀 호버 툴팁으로 변경 (onMouseEnter/onMouseLeave + 절대 위치 div)
|
||||
- [x] 선택 항목 표시 순서를 드롭다운 옵션 순서 기준으로 변경
|
||||
|
||||
### 2단계: 검증
|
||||
|
||||
- [x] 단일 선택 모드: 기존 동작 변화 없음 확인
|
||||
- [x] 다중 선택 1개: 라벨 정상 표시, 툴팁 없음
|
||||
- [x] 다중 선택 3개 (필드 내 수용): 쉼표 나열 표시, 툴팁 없음
|
||||
- [x] 다중 선택 5개+ (필드 넘침): 말줄임 표시, 호버 시 툴팁 세로 나열
|
||||
- [x] 품목정보 수정 모달에서 동작 확인
|
||||
- [x] 다른 화면의 다중 선택 드롭다운에서도 동작 확인
|
||||
|
||||
### 3단계: 정리
|
||||
|
||||
- [x] 린트 에러 없음 확인
|
||||
- [x] 이 체크리스트 완료 표시 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-03-04 | 설계 문서 작성 완료 |
|
||||
| 2026-03-04 | 맥락노트, 체크리스트 작성 완료 |
|
||||
| 2026-03-04 | 파일명 MST 접두사 적용 |
|
||||
| 2026-03-04 | 1단계 코드 수정 완료 (V2Select.tsx) |
|
||||
| 2026-03-04 | Radix Tooltip이 Popover와 충돌 → 커스텀 호버 툴팁으로 변경 |
|
||||
| 2026-03-04 | 사용자 검증 완료, 전체 작업 완료 |
|
||||
| 2026-03-04 | 선택 항목 표시 순서를 옵션 순서 기준으로 변경 |
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
# 탭 시스템 아키텍처 및 구현 계획
|
||||
|
||||
## 1. 개요
|
||||
|
||||
사이드바 메뉴 클릭 시 `router.push()` 페이지 이동 방식에서 **탭 기반 멀티 화면 시스템**으로 전환한다.
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ Tab Data Layer (중앙) │
|
||||
API 응답 ────────→│ │
|
||||
│ 탭별 상태 저장소 │
|
||||
│ ├─ formData │
|
||||
│ ├─ selectedRows │
|
||||
│ ├─ scrollPosition │
|
||||
│ ├─ modalState │
|
||||
│ ├─ sortState │
|
||||
│ └─ cacheState │
|
||||
│ │
|
||||
│ 공통 규칙 엔진 │
|
||||
│ ├─ 날짜 포맷 규칙 │
|
||||
│ ├─ 숫자/통화 포맷 규칙 │
|
||||
│ ├─ 로케일 처리 규칙 │
|
||||
│ ├─ 유효성 검증 규칙 │
|
||||
│ └─ 데이터 타입 변환 규칙 │
|
||||
│ │
|
||||
│ F5 복원 / 캐시 관리 │
|
||||
│ (sessionStorage 중앙관리) │
|
||||
└────────────┬─────────────┘
|
||||
│
|
||||
가공 완료된 데이터
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
화면 A (경량) 화면 B (경량) 화면 C (경량)
|
||||
렌더링만 담당 렌더링만 담당 렌더링만 담당
|
||||
```
|
||||
|
||||
## 2. 레이어 구조
|
||||
|
||||
| 레이어 | 책임 |
|
||||
|---|---|
|
||||
| **Tab Data Layer** | 탭별 상태 보관, 캐시, 복원, 데이터 가공 |
|
||||
| **공통 규칙 엔진** | 날짜/숫자/로케일 포맷, 유효성 검증 |
|
||||
| **화면 컴포넌트** | 가공된 데이터를 받아서 렌더링만 담당 |
|
||||
|
||||
## 3. 파일 구성
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `stores/tabStore.ts` | Zustand 기반 탭 상태 관리 |
|
||||
| `components/layout/TabBar.tsx` | 탭 바 UI (드래그, 우클릭, 오버플로우) |
|
||||
| `components/layout/TabContent.tsx` | 탭별 콘텐츠 렌더링 (컨테이너) |
|
||||
| `components/layout/EmptyDashboard.tsx` | 탭 없을 때 안내 화면 |
|
||||
| `components/layout/AppLayout.tsx` | 전체 레이아웃 (사이드바 + 탭 + 콘텐츠) |
|
||||
| `lib/tabStateCache.ts` | 탭별 상태 캐싱 엔진 |
|
||||
| `lib/formatting/rules.ts` | 포맷 규칙 정의 |
|
||||
| `lib/formatting/index.ts` | formatDate, formatNumber, formatCurrency |
|
||||
| `app/(main)/screens/[screenId]/page.tsx` | 화면별 렌더링 |
|
||||
|
||||
## 4. 기술 스택
|
||||
|
||||
- Next.js 15, React 19, Zustand
|
||||
- Tailwind CSS, shadcn/ui
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 1: 탭 껍데기
|
||||
|
||||
### 5-1. Zustand 탭 Store (`stores/tabStore.ts`)
|
||||
- [ ] zustand 직접 의존성 추가
|
||||
- [ ] Tab 인터페이스: id, type, title, screenId, menuObjid, adminUrl
|
||||
- [ ] 탭 목록, 활성 탭 ID
|
||||
- [ ] openTab, closeTab, switchTab, refreshTab
|
||||
- [ ] closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs
|
||||
- [ ] updateTabOrder (드래그 순서 변경)
|
||||
- [ ] 중복 방지: 같은 탭이면 해당 탭으로 이동
|
||||
- [ ] 닫기 후 왼쪽 탭으로 이동, 왼쪽 없으면 오른쪽
|
||||
- [ ] sessionStorage 영속화 (persist middleware)
|
||||
- [ ] 탭 ID 생성 규칙: V2 화면 `tab-{screenId}-{menuObjid}`, URL 탭 `tab-url-{menuObjid}`
|
||||
|
||||
### 5-2. TabBar 컴포넌트 (`components/layout/TabBar.tsx`)
|
||||
- [ ] 고정 너비 탭, 화면 너비에 맞게 동적 개수
|
||||
- [ ] 활성 탭: 새로고침 버튼 + X 버튼
|
||||
- [ ] 비활성 탭: X 버튼만
|
||||
- [ ] 오버플로우 시 +N 드롭다운 (ResizeObserver 감시)
|
||||
- [ ] 드래그 순서 변경 (mousedown/move/up, DOM transform 직접 조작)
|
||||
- [ ] 사이드바 메뉴 드래그 드롭 수신 (`application/tab-menu` 커스텀 데이터, 마우스 위치에 삽입)
|
||||
- [ ] 우클릭 컨텍스트 메뉴 (새로고침/왼쪽닫기/오른쪽닫기/다른탭닫기/모든탭닫기)
|
||||
- [ ] 휠 클릭: 탭 즉시 닫기
|
||||
|
||||
### 5-3. TabContent 컴포넌트 (`components/layout/TabContent.tsx`)
|
||||
- [ ] display:none 방식 (비활성 탭 DOM 유지, 상태 보존)
|
||||
- [ ] 지연 마운트 (한 번 활성화된 탭만 마운트)
|
||||
- [ ] 안정적 순서 유지 (탭 순서 변경 시 리마운트 방지)
|
||||
- [ ] 탭별 모달 격리 (DialogPortalContainerContext)
|
||||
- [ ] tab.type === "screen" -> ScreenViewPageWrapper 임베딩
|
||||
- [ ] tab.type === "admin" -> 동적 import로 관리자 페이지 렌더링
|
||||
|
||||
### 5-4. EmptyDashboard 컴포넌트 (`components/layout/EmptyDashboard.tsx`)
|
||||
- [ ] 탭이 없을 때 "사이드바에서 메뉴를 선택하여 탭을 추가하세요" 표시
|
||||
|
||||
### 5-5. AppLayout 수정 (`components/layout/AppLayout.tsx`)
|
||||
- [ ] handleMenuClick: router.push -> tabStore.openTab 호출
|
||||
- [ ] 레이아웃: main 영역을 TabBar + TabContent로 교체
|
||||
- [ ] children prop 제거 (탭이 콘텐츠 관리)
|
||||
- [ ] 사이드바 메뉴 드래그 가능하게 (draggable)
|
||||
|
||||
### 5-6. 라우팅 연동
|
||||
- [ ] `app/(main)/layout.tsx` 수정 - children 대신 탭 시스템
|
||||
- [ ] URL 직접 접근 시 탭으로 열기 (북마크/공유 링크 대응)
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2: F5 최대 복원
|
||||
|
||||
### 6-1. 탭 상태 캐싱 엔진 (`lib/tabStateCache.ts`)
|
||||
- [ ] 탭별 상태 저장/복원 (sessionStorage)
|
||||
- [ ] 저장 대상: formData, selectedRows, sortState, scrollPosition, modalState, checkboxState
|
||||
- [ ] debounce 적용 (상태 변경마다 저장하지 않음)
|
||||
|
||||
### 6-2. 복원 로직
|
||||
- [ ] 활성 탭: fresh API 호출 (캐시 데이터 무시)
|
||||
- [ ] 비활성 탭: 캐시에서 복원
|
||||
- [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제
|
||||
|
||||
### 6-3. 캐시 키 관리 (clearTabStateCache)
|
||||
|
||||
탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거:
|
||||
- `tab-cache-{screenId}-{menuObjid}`
|
||||
- `page-scroll-{screenId}-{menuObjid}`
|
||||
- `tsp-{screenId}-*`, `table-state-{screenId}-*`
|
||||
- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*`
|
||||
- `bom-tree-{screenId}-*`
|
||||
- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}`
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 3: 포맷팅 중앙화
|
||||
|
||||
### 7-1. 포맷팅 규칙 엔진
|
||||
|
||||
```typescript
|
||||
// lib/formatting/rules.ts
|
||||
|
||||
interface FormatRules {
|
||||
date: {
|
||||
display: string; // "YYYY-MM-DD"
|
||||
datetime: string; // "YYYY-MM-DD HH:mm:ss"
|
||||
input: string; // "YYYY-MM-DD"
|
||||
};
|
||||
number: {
|
||||
locale: string; // 사용자 로케일 기반
|
||||
decimals: number; // 기본 소수점 자릿수
|
||||
};
|
||||
currency: {
|
||||
code: string; // 회사 설정 기반
|
||||
locale: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function formatValue(value: any, dataType: string, rules: FormatRules): string;
|
||||
export function formatDate(value: any, format?: string): string;
|
||||
export function formatNumber(value: any, locale?: string): string;
|
||||
export function formatCurrency(value: any, currencyCode?: string): string;
|
||||
```
|
||||
|
||||
### 7-2. 하드코딩 교체 대상
|
||||
- [ ] V2DateRenderer.tsx
|
||||
- [ ] EditModal.tsx
|
||||
- [ ] InteractiveDataTable.tsx
|
||||
- [ ] FlowWidget.tsx
|
||||
- [ ] AggregationWidgetComponent.tsx
|
||||
- [ ] aggregation.ts (피벗)
|
||||
- [ ] 기타 하드코딩 파일들
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 4: ScreenViewPage 경량화
|
||||
- [ ] 탭 데이터 레이어에서 받은 데이터로 렌더링만 담당
|
||||
- [ ] API 호출, 캐시, 복원 로직 제거 (탭 레이어가 담당)
|
||||
- [ ] 관리자 페이지도 동일한 데이터 레이어 패턴 적용
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 구현 완료: 다중 스크롤 영역 F5 복원
|
||||
|
||||
### 개요
|
||||
|
||||
split panel 등 한 탭 안에 **스크롤 영역이 여러 개**인 화면에서, F5 새로고침 후에도 각 영역의 스크롤 위치가 복원된다.
|
||||
|
||||
탭 전환 시에는 `display: none` 방식으로 DOM이 유지되므로 브라우저가 스크롤을 자연 보존한다. 이 기능은 **F5 새로고침** 전용이다.
|
||||
|
||||
### 동작 방식
|
||||
|
||||
탭 내 모든 스크롤 가능한 요소를 DOM 경로(`"0/1/0/2"` 형태)와 함께 저장한다.
|
||||
|
||||
```
|
||||
scrollPositions: [
|
||||
{ path: "0/1/0/2", top: 150, left: 0 }, // 예: 좌측 패널
|
||||
{ path: "0/1/1/3/1", top: 420, left: 0 }, // 예: 우측 패널
|
||||
]
|
||||
```
|
||||
|
||||
- **실시간 추적**: 스크롤 이벤트 발생 시 해당 요소의 경로와 위치를 Map에 기록
|
||||
- **저장 시점**: 탭 전환 시 + `beforeunload`(F5/닫기) 시 sessionStorage에 저장
|
||||
- **복원 시점**: 탭 활성화 시 경로를 기반으로 각 요소를 찾아 개별 복원
|
||||
|
||||
### 관련 파일 및 주요 함수
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `lib/tabStateCache.ts` | 스크롤 캡처/복원 핵심 로직 |
|
||||
| `components/layout/TabContent.tsx` | 스크롤 이벤트 감지, 저장/복원 호출 |
|
||||
|
||||
**`tabStateCache.ts` 핵심 함수**:
|
||||
|
||||
| 함수 | 설명 |
|
||||
|---|---|
|
||||
| `getElementPath(element, container)` | 요소의 DOM 경로를 자식 인덱스 문자열로 생성 |
|
||||
| `captureAllScrollPositions(container)` | TreeWalker로 컨테이너 하위 모든 스크롤 요소의 위치를 일괄 캡처 |
|
||||
| `restoreAllScrollPositions(container, positions)` | 경로 기반으로 각 요소를 찾아 스크롤 위치 복원 (콘텐츠 렌더링 대기 폴링 포함) |
|
||||
|
||||
**`TabContent.tsx` 핵심 Ref**:
|
||||
|
||||
| Ref | 설명 |
|
||||
|---|---|
|
||||
| `lastScrollMapRef` | `Map<tabId, Map<path, {top, left}>>` - 탭 내 요소별 최신 스크롤 위치 |
|
||||
| `pathCacheRef` | `WeakMap<HTMLElement, string>` - 동일 요소의 경로 재계산 방지용 캐시 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 파일
|
||||
|
||||
| 파일 | 비고 |
|
||||
|---|---|
|
||||
| `frontend/components/layout/AppLayout.tsx` | 사이드바 + 콘텐츠 레이아웃 |
|
||||
| `frontend/app/(main)/screens/[screenId]/page.tsx` | 화면 렌더링 (건드리지 않음) |
|
||||
| `frontend/stores/modalDataStore.ts` | Zustand store 참고 패턴 |
|
||||
| `frontend/lib/adminPageRegistry.tsx` | 관리자 페이지 레지스트리 |
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
# 모달 필수 입력 검증 설계
|
||||
|
||||
## 1. 목표
|
||||
|
||||
모든 모달에서 필수 입력값이 빈 상태로 저장 버튼을 클릭하면:
|
||||
- 첫 번째 빈 필수 필드로 포커스 이동 + 하이라이트
|
||||
- 우측 상단에 토스트 알림 ("○○ 항목을 입력해주세요")
|
||||
- 버튼은 항상 활성 상태 (비활성화하지 않음)
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ DialogContent (모든 모달의 공통 래퍼) │
|
||||
│ │
|
||||
│ useDialogAutoValidation(contentRef) │
|
||||
│ │ │
|
||||
│ ├─ 0단계: 모드 확인 │
|
||||
│ │ └─ useTabStore.mode === "user" 일 때만 실행 │
|
||||
│ │ │
|
||||
│ ├─ 1단계: 필수 필드 탐지 │
|
||||
│ │ └─ Label 내부 <span> 안에 * 문자 존재 여부 │
|
||||
│ │ (라벨 텍스트 직접 매칭 X → span 태그 안의 * 만 감지) │
|
||||
│ │ │
|
||||
│ └─ 2단계: 저장 버튼 클릭 인터셉트 │
|
||||
│ │ │
|
||||
│ ├─ 저장/수정/확인 버튼 클릭 감지 │
|
||||
│ │ (data-action-type="save"/"submit" │
|
||||
│ │ 또는 data-variant="default") │
|
||||
│ │ │
|
||||
│ ├─ 빈 필수 필드 있음: │
|
||||
│ │ ├─ 클릭 이벤트 차단 (stopPropagation + preventDefault) │
|
||||
│ │ ├─ 첫 번째 빈 필드로 포커스 이동 │
|
||||
│ │ ├─ 해당 필드 빨간 테두리 + 하이라이트 애니메이션 │
|
||||
│ │ └─ 토스트 알림: "{필드명} 항목을 입력해주세요" │
|
||||
│ │ │
|
||||
│ └─ 모든 필수 필드 입력됨: │
|
||||
│ └─ 클릭 이벤트 통과 (정상 저장 진행) │
|
||||
│ │
|
||||
│ 제외 조건: │
|
||||
│ └─ 필수 필드가 0개인 모달 → 인터셉트 없음 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 필수 필드 감지: span 기반 * 감지
|
||||
|
||||
### 원리
|
||||
|
||||
화면 관리에서 필드를 "필수"로 체크하면 `component.required = true`가 저장된다.
|
||||
V2 컴포넌트가 렌더링할 때 `required = true`이면 Label 안에 `<span>*</span>`을 추가한다.
|
||||
훅은 이 span 안의 `*`를 감지하여 필수 필드를 식별한다.
|
||||
|
||||
### 오탐 방지
|
||||
|
||||
관리자가 라벨 텍스트에 직접 `*`를 입력해도 span 안에 들어가지 않으므로 오탐이 발생하지 않는다.
|
||||
|
||||
```
|
||||
required = true → <label>품목코드<span class="text-orange-500">*</span></label>
|
||||
→ span 안에 * 있음 → 감지 O
|
||||
|
||||
required = false → <label>품목코드</label>
|
||||
→ span 없음 → 감지 X
|
||||
|
||||
라벨에 * 직접 입력 → <label>품목코드*</label>
|
||||
→ span 없이 텍스트에 * → 감지 X (오탐 방지)
|
||||
```
|
||||
|
||||
### 지원 필드 타입
|
||||
|
||||
| V2 컴포넌트 | 렌더링 요소 | 빈값 판정 |
|
||||
|---|---|---|
|
||||
| V2Input | `<input>`, `<textarea>` | `value.trim() === ""` |
|
||||
| V2Select | `<button role="combobox">` | `querySelector("[data-placeholder]")` 존재 |
|
||||
| V2Date | `<input>` (날짜/시간) | `value.trim() === ""` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 저장 버튼 클릭 인터셉트
|
||||
|
||||
### 원리
|
||||
|
||||
버튼을 비활성화하지 않고, 클릭 이벤트를 캡처링 단계에서 가로챈다.
|
||||
빈 필수 필드가 있으면 이벤트를 차단하고, 없으면 통과시킨다.
|
||||
|
||||
### 인터셉트 대상 버튼
|
||||
|
||||
| 조건 | 예시 |
|
||||
|------|------|
|
||||
| `data-action-type="save"` | ButtonPrimary 저장 버튼 |
|
||||
| `data-action-type="submit"` | ButtonPrimary 제출 버튼 |
|
||||
| `data-variant="default"` | shadcn Button 기본 (저장/확인/등록) |
|
||||
|
||||
### 인터셉트하지 않는 버튼
|
||||
|
||||
| 조건 | 예시 |
|
||||
|------|------|
|
||||
| `data-variant` = outline/ghost/destructive/secondary | 취소, 닫기, 삭제 |
|
||||
| `role` = combobox/tab/switch 등 | 폼 컨트롤 |
|
||||
| `data-action-type` != save/submit | 기타 액션 버튼 |
|
||||
| `data-dialog-close` | 모달 닫기 X 버튼 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 시각적 피드백
|
||||
|
||||
### 포커스 이동
|
||||
|
||||
첫 번째 빈 필수 필드로 커서를 이동한다.
|
||||
- `<input>`, `<textarea>`: `input.focus()`
|
||||
- `<button role="combobox">` (V2Select): `button.click()` → 드롭다운 열기
|
||||
|
||||
### 하이라이트 애니메이션
|
||||
|
||||
빈 필수 필드에 빨간 테두리 + 흔들림 효과를 준다.
|
||||
|
||||
```css
|
||||
@keyframes validationShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20%, 60% { transform: translateX(-4px); }
|
||||
40%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
[data-validation-highlight] {
|
||||
border-color: hsl(var(--destructive)) !important;
|
||||
animation: validationShake 400ms ease-in-out;
|
||||
}
|
||||
```
|
||||
|
||||
애니메이션 종료 후 `data-validation-highlight` 속성 제거 (일회성).
|
||||
|
||||
### 토스트 알림
|
||||
|
||||
우측 상단에 토스트 메시지를 표시한다.
|
||||
|
||||
```typescript
|
||||
toast.error(`${fieldLabel} 항목을 입력해주세요`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 동작 흐름
|
||||
|
||||
```
|
||||
모달 열림
|
||||
│
|
||||
▼
|
||||
DialogContent 마운트
|
||||
│
|
||||
▼
|
||||
useDialogAutoValidation 실행
|
||||
│
|
||||
▼
|
||||
모드 확인 (useTabStore.mode)
|
||||
│
|
||||
├─ mode !== "user"? → return
|
||||
│
|
||||
▼
|
||||
필수 필드 탐지 (Label 내 span에서 * 감지)
|
||||
│
|
||||
├─ 필수 필드 0개? → return
|
||||
│
|
||||
▼
|
||||
클릭 이벤트 리스너 등록 (캡처링 단계)
|
||||
│
|
||||
▼
|
||||
사용자가 저장 버튼 클릭
|
||||
│
|
||||
▼
|
||||
인터셉트 대상 버튼인가?
|
||||
│
|
||||
├─ 아니오 → 클릭 통과
|
||||
│
|
||||
▼
|
||||
빈 필수 필드 검사
|
||||
│
|
||||
├─ 모두 입력됨 → 클릭 통과 (정상 저장)
|
||||
│
|
||||
├─ 빈 필드 있음:
|
||||
│ ├─ e.stopPropagation() + e.preventDefault()
|
||||
│ ├─ 첫 번째 빈 필드에 포커스 이동
|
||||
│ ├─ 해당 필드에 data-validation-highlight 속성 추가
|
||||
│ ├─ 애니메이션 종료 후 속성 제거
|
||||
│ └─ toast.error("{필드명} 항목을 입력해주세요")
|
||||
│
|
||||
▼
|
||||
모달 닫힘
|
||||
│
|
||||
▼
|
||||
클린업
|
||||
├─ 이벤트 리스너 제거
|
||||
└─ 하이라이트 속성 제거
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 관련 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `frontend/lib/hooks/useDialogAutoValidation.ts` | 검증 훅 본체 |
|
||||
| `frontend/components/ui/dialog.tsx` | DialogContent에서 훅 호출 |
|
||||
| `frontend/components/ui/button.tsx` | data-variant 속성 노출 |
|
||||
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | data-action-type 속성 노출 |
|
||||
| `frontend/app/globals.css` | 하이라이트 애니메이션 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 적용 범위
|
||||
|
||||
### 현재 (1단계): 사용자 모드만
|
||||
|
||||
| 모달 유형 | 동작 여부 | 이유 |
|
||||
|---------------------------------------|:---:|-------------------------------|
|
||||
| 사용자 모드 모달 (SaveModal 등) | O | mode === "user" + span * 있음 |
|
||||
| 관리자 모드 모달 (CodeFormModal 등) | X | mode !== "user" → return |
|
||||
| 확인/삭제 다이얼로그 (필수 필드 없음) | X | 필수 필드 0개 → 자동 제외 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 이전 방식과 비교
|
||||
|
||||
| 항목 | 이전 (버튼 비활성화) | 현재 (클릭 인터셉트) |
|
||||
|------|---|---|
|
||||
| 버튼 상태 | 빈 필드 있으면 비활성화 | 항상 활성 |
|
||||
| 피드백 시점 | 모달 열릴 때부터 | 저장 버튼 클릭 시 |
|
||||
| 피드백 방식 | 빨간 테두리 + 에러 문구 | 포커스 이동 + 하이라이트 + 토스트 |
|
||||
| 복잡도 | 높음 (MutationObserver, 폴링, CSS 지연) | 낮음 (클릭 이벤트 하나) |
|
||||
|
|
@ -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`)*
|
||||
|
|
@ -5,17 +5,13 @@ 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();
|
||||
|
||||
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
|
||||
|
|
|
|||
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,33 @@ 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" },
|
||||
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
PERMISSION: { 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 +203,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 +234,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 +245,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 +460,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 +475,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 +497,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 +520,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 +604,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 +685,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 +695,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 +705,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>
|
||||
|
|
|
|||
|
|
@ -311,10 +311,10 @@ export default function BatchCreatePage() {
|
|||
{/* 매핑 설정 */}
|
||||
<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>
|
||||
|
|
@ -365,7 +365,7 @@ export default function BatchCreatePage() {
|
|||
{/* FROM 컬럼 목록 */}
|
||||
{fromTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-blue-600 font-semibold">{fromTable} 테이블</Label>
|
||||
<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
|
||||
|
|
@ -373,16 +373,16 @@ export default function BatchCreatePage() {
|
|||
onClick={() => handleFromColumnClick(column)}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedFromColumn?.column_name === column.column_name
|
||||
? 'bg-green-100 border-green-300'
|
||||
: 'hover:bg-gray-50 border-gray-200'
|
||||
? '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="text-center text-gray-500 py-4">
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
컬럼을 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -393,10 +393,10 @@ 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">
|
||||
<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>
|
||||
|
|
@ -447,7 +447,7 @@ export default function BatchCreatePage() {
|
|||
{/* TO 컬럼 목록 */}
|
||||
{toTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-blue-600 font-semibold">{toTable} 테이블</Label>
|
||||
<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
|
||||
|
|
@ -455,16 +455,16 @@ export default function BatchCreatePage() {
|
|||
onClick={() => handleToColumnClick(column)}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedFromColumn
|
||||
? 'hover:bg-red-50 border-gray-200'
|
||||
: 'bg-gray-100 border-gray-300 cursor-not-allowed'
|
||||
? '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="text-center text-gray-500 py-4">
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
컬럼을 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -484,22 +484,22 @@ export default function BatchCreatePage() {
|
|||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 border rounded-lg bg-yellow-50">
|
||||
<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">
|
||||
<div className="text-muted-foreground">
|
||||
{mapping.from_column_type}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
<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">
|
||||
<div className="text-muted-foreground">
|
||||
{mapping.to_column_type}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -508,7 +508,7 @@ export default function BatchCreatePage() {
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -815,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 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
|
||||
|
|
@ -874,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
|
||||
|
|
@ -902,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>
|
||||
)}
|
||||
|
||||
|
|
@ -910,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>
|
||||
|
|
@ -967,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>
|
||||
|
|
|
|||
|
|
@ -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,22 +179,102 @@ 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="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
<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-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">
|
||||
<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="설정 이름 또는 설명으로 검색..."
|
||||
|
|
@ -196,7 +284,6 @@ 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" />
|
||||
검색
|
||||
|
|
@ -208,7 +295,7 @@ export default function ExternalCallConfigsPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 두 번째 줄: 필터 */}
|
||||
{/* 필터 영역 */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Select
|
||||
value={filter.call_type || "all"}
|
||||
|
|
@ -275,107 +362,64 @@ export default function ExternalCallConfigsPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 목록 */}
|
||||
<div className="rounded-lg border bg-card shadow-sm">
|
||||
{loading ? (
|
||||
// 로딩 상태
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">로딩 중...</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-sm text-muted-foreground">등록된 외부 호출 설정이 없습니다.</p>
|
||||
<p className="text-xs text-muted-foreground">새 외부 호출을 추가해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 설정 테이블 목록
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="h-12 text-sm font-semibold">설정명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">호출 타입</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">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="border-b transition-colors hover:bg-muted/50">
|
||||
<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="block truncate text-muted-foreground" 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="h-16 text-sm text-muted-foreground">
|
||||
{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="h-8 w-8 text-destructive hover:text-destructive"
|
||||
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
|
||||
|
|
@ -391,7 +435,7 @@ 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>
|
||||
|
|
@ -409,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,19 +192,77 @@ 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="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
|
|
@ -237,7 +274,7 @@ export default function ExternalConnectionsPage() {
|
|||
|
||||
{/* 탭 */}
|
||||
<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,8 +289,7 @@ 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="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
|
|
@ -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,126 +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" />
|
||||
새 연결 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 연결 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center bg-card">
|
||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
) : connections.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center bg-card">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">등록된 연결이 없습니다</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 transition-colors hover:bg-muted/50">
|
||||
<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="h-8 w-8 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<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 && (
|
||||
|
|
@ -430,7 +399,7 @@ export default function ExternalConnectionsPage() {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">연결 삭제 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
"{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
|
||||
“{connectionToDelete?.connection_name}” 연결을 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
|
|
@ -472,6 +441,7 @@ export default function ExternalConnectionsPage() {
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ export default function FlowEditorPage() {
|
|||
onNodeDragStop={handleNodeDragStop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-gray-50"
|
||||
className="bg-muted"
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Edit2, Trash2, Workflow, Table, Calendar, User, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Plus, Edit2, Trash2, Workflow, Search, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
|
|
@ -33,8 +33,9 @@ import { cn } from "@/lib/utils";
|
|||
import { formatErrorMessage } from "@/lib/utils/errorUtils";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection";
|
||||
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||
|
||||
export default function FlowManagementPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -55,9 +56,7 @@ export default function FlowManagementPage() {
|
|||
const [openTableCombobox, setOpenTableCombobox] = useState(false);
|
||||
// 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API)
|
||||
const [selectedDbSource, setSelectedDbSource] = useState<string>("internal");
|
||||
const [externalConnections, setExternalConnections] = useState<
|
||||
Array<{ id: number; connection_name: string; db_type: string }>
|
||||
>([]);
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalDbConnection[]>([]);
|
||||
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
||||
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
||||
|
||||
|
|
@ -254,7 +253,7 @@ export default function FlowManagementPage() {
|
|||
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
|
||||
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
|
||||
)
|
||||
.filter(Boolean);
|
||||
.filter((v): v is string => Boolean(v));
|
||||
setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames }));
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -446,7 +445,7 @@ export default function FlowManagementPage() {
|
|||
}
|
||||
|
||||
console.log("✅ Calling createFlowDefinition with:", requestData);
|
||||
const response = await createFlowDefinition(requestData as Parameters<typeof createFlowDefinition>[0]);
|
||||
const response = await createFlowDefinition(requestData as unknown as Parameters<typeof createFlowDefinition>[0]);
|
||||
if (response.success && response.data) {
|
||||
toast({
|
||||
title: "생성 완료",
|
||||
|
|
@ -513,6 +512,81 @@ export default function FlowManagementPage() {
|
|||
router.push(`/admin/flow-management/${flowId}`);
|
||||
};
|
||||
|
||||
// 검색 필터 상태
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 검색 필터링된 플로우 목록
|
||||
const filteredFlows = searchText
|
||||
? flows.filter(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
f.tableName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
f.description?.toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
: flows;
|
||||
|
||||
// ResponsiveDataView 컬럼 정의
|
||||
const columns: RDVColumn<FlowDefinition>[] = [
|
||||
{
|
||||
key: "name",
|
||||
label: "플로우명",
|
||||
render: (_v, row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(row.id)}
|
||||
className="hover:text-primary truncate text-left font-medium transition-colors hover:underline"
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
{row.isActive && (
|
||||
<Badge variant="default" className="shrink-0">활성</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "설명",
|
||||
render: (_v, row) => (
|
||||
<span className="text-muted-foreground line-clamp-1">
|
||||
{row.description || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tableName",
|
||||
label: "연결 테이블",
|
||||
render: (_v, row) => (
|
||||
<span className="text-muted-foreground font-mono text-xs">{row.tableName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdBy",
|
||||
label: "생성자",
|
||||
width: "120px",
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "수정일",
|
||||
width: "120px",
|
||||
render: (_v, row) => new Date(row.updatedAt).toLocaleDateString("ko-KR"),
|
||||
},
|
||||
];
|
||||
|
||||
// 모바일 카드 필드 정의
|
||||
const cardFields: RDVCardField<FlowDefinition>[] = [
|
||||
{ label: "설명", render: (f) => f.description || "-" },
|
||||
{
|
||||
label: "테이블",
|
||||
render: (f) => <span className="font-mono text-xs">{f.tableName}</span>,
|
||||
},
|
||||
{ label: "생성자", render: (f) => f.createdBy },
|
||||
{
|
||||
label: "수정일",
|
||||
render: (f) => new Date(f.updatedAt).toLocaleDateString("ko-KR"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
|
|
@ -522,101 +596,53 @@ export default function FlowManagementPage() {
|
|||
<p className="text-muted-foreground text-sm">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 영역 */}
|
||||
<div className="flex items-center justify-end">
|
||||
{/* 검색 툴바 (반응형) */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<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" />
|
||||
<Input
|
||||
placeholder="플로우명, 테이블, 설명으로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground hidden text-sm sm:block">
|
||||
총 <span className="text-foreground font-semibold">{filteredFlows.length}</span> 건
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 플로우 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 플로우 카드 목록 */}
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="bg-card rounded-lg border p-6 shadow-sm">
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-4 w-full animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-4 w-3/4 animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="bg-muted h-4 w-4 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-4 flex-1 animate-pulse rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-9 w-9 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Workflow className="text-muted-foreground h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">생성된 플로우가 없습니다</h3>
|
||||
<p className="text-muted-foreground max-w-sm text-sm">
|
||||
새 플로우를 생성하여 업무 프로세스를 관리해보세요.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)} className="mt-4 h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />첫 플로우 만들기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{flows.map((flow) => (
|
||||
<div
|
||||
key={flow.id}
|
||||
className="bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-6 shadow-sm transition-colors"
|
||||
onClick={() => handleEdit(flow.id)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-base font-semibold">{flow.name}</h3>
|
||||
{flow.isActive && (
|
||||
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-600">활성</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{flow.description || "설명 없음"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Table className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<span className="text-muted-foreground truncate">{flow.tableName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<span className="text-muted-foreground truncate">생성자: {flow.createdBy}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 */}
|
||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||
{/* 플로우 목록 (ResponsiveDataView) */}
|
||||
<ResponsiveDataView<FlowDefinition>
|
||||
data={filteredFlows}
|
||||
columns={columns}
|
||||
keyExtractor={(f) => String(f.id)}
|
||||
isLoading={loading}
|
||||
emptyMessage="생성된 플로우가 없습니다."
|
||||
skeletonCount={6}
|
||||
cardTitle={(f) => f.name}
|
||||
cardSubtitle={(f) => f.description || "설명 없음"}
|
||||
cardHeaderRight={(f) =>
|
||||
f.isActive ? (
|
||||
<Badge variant="default" className="shrink-0">활성</Badge>
|
||||
) : null
|
||||
}
|
||||
cardFields={cardFields}
|
||||
onRowClick={(f) => handleEdit(f.id)}
|
||||
renderActions={(f) => (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(flow.id);
|
||||
handleEdit(f.id);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
|
|
@ -628,17 +654,16 @@ export default function FlowManagementPage() {
|
|||
className="h-9 w-9 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFlow(flow);
|
||||
setSelectedFlow(f);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
actionsWidth="160px"
|
||||
/>
|
||||
|
||||
{/* 생성 다이얼로그 */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
|
|
@ -996,7 +1021,7 @@ export default function FlowManagementPage() {
|
|||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.description && (
|
||||
<span className="text-[10px] text-gray-500">{table.description}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
|
|
|||
|
|
@ -409,7 +409,7 @@ example2@example.com,김철수,XYZ회사`;
|
|||
{recipients.length > 0 && (
|
||||
<div className="rounded-md border bg-muted p-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
<span className="font-medium">{recipients.length}명의 수신자</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -101,8 +101,8 @@ export default function MailDashboardPage() {
|
|||
value: stats.totalAccounts,
|
||||
icon: Users,
|
||||
color: "blue",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-600",
|
||||
bgColor: "bg-primary/10",
|
||||
iconColor: "text-primary",
|
||||
href: "/admin/mail/accounts",
|
||||
},
|
||||
{
|
||||
|
|
@ -110,8 +110,8 @@ export default function MailDashboardPage() {
|
|||
value: stats.totalTemplates,
|
||||
icon: FileText,
|
||||
color: "green",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-600",
|
||||
bgColor: "bg-emerald-100",
|
||||
iconColor: "text-emerald-600",
|
||||
href: "/admin/mail/templates",
|
||||
},
|
||||
{
|
||||
|
|
@ -119,8 +119,8 @@ export default function MailDashboardPage() {
|
|||
value: stats.sentToday,
|
||||
icon: Send,
|
||||
color: "orange",
|
||||
bgColor: "bg-orange-100",
|
||||
iconColor: "text-orange-600",
|
||||
bgColor: "bg-amber-100",
|
||||
iconColor: "text-amber-600",
|
||||
href: "/admin/mail/sent",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -438,8 +438,8 @@ export default function MailReceivePage() {
|
|||
<div
|
||||
className={`mt-4 p-3 rounded-lg flex items-center gap-2 ${
|
||||
testResult.success
|
||||
? "bg-green-50 text-green-800 border border-green-200"
|
||||
: "bg-red-50 text-red-800 border border-red-200"
|
||||
? "bg-emerald-50 text-emerald-800 border border-emerald-200"
|
||||
: "bg-destructive/10 text-red-800 border border-destructive/20"
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
|
|
@ -460,7 +460,7 @@ export default function MailReceivePage() {
|
|||
<div className="flex flex-col md:flex-row gap-3">
|
||||
{/* 검색 */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/70" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
|
|
@ -511,7 +511,7 @@ export default function MailReceivePage() {
|
|||
{filteredAndSortedMails.length}개의 메일이 검색되었습니다
|
||||
{searchTerm && (
|
||||
<span className="ml-2">
|
||||
(검색어: <span className="font-medium text-orange-600">{searchTerm}</span>)
|
||||
(검색어: <span className="font-medium text-amber-600">{searchTerm}</span>)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -527,14 +527,14 @@ export default function MailReceivePage() {
|
|||
{loading ? (
|
||||
<Card className="">
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
<Loader2 className="w-8 h-8 animate-spin text-amber-500" />
|
||||
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredAndSortedMails.length === 0 ? (
|
||||
<Card className="text-center py-16 bg-card ">
|
||||
<CardContent className="pt-6">
|
||||
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<Mail className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{!selectedAccountId
|
||||
? "메일 계정을 선택하세요"
|
||||
|
|
@ -560,9 +560,9 @@ export default function MailReceivePage() {
|
|||
</Card>
|
||||
) : (
|
||||
<Card className="">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-muted border-b">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Inbox className="w-5 h-5 text-orange-500" />
|
||||
<Inbox className="w-5 h-5 text-amber-500" />
|
||||
받은 메일함 ({filteredAndSortedMails.length}/{mails.length}개)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -573,14 +573,14 @@ export default function MailReceivePage() {
|
|||
key={mail.id}
|
||||
onClick={() => handleMailClick(mail)}
|
||||
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
|
||||
!mail.isRead ? "bg-blue-50/30" : ""
|
||||
!mail.isRead ? "bg-primary/10/30" : ""
|
||||
} ${selectedMailId === mail.id ? "bg-accent border-l-4 border-l-primary" : ""}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 읽음 표시 */}
|
||||
<div className="flex-shrink-0 w-2 h-2 mt-2">
|
||||
{!mail.isRead && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -598,7 +598,7 @@ export default function MailReceivePage() {
|
|||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{mail.hasAttachments && (
|
||||
<Paperclip className="w-4 h-4 text-gray-400" />
|
||||
<Paperclip className="w-4 h-4 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(mail.date)}
|
||||
|
|
@ -882,14 +882,14 @@ export default function MailReceivePage() {
|
|||
) : loadingDetail ? (
|
||||
<Card className="sticky top-6">
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
<Loader2 className="w-8 h-8 animate-spin text-amber-500" />
|
||||
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="sticky top-6">
|
||||
<CardContent className="flex flex-col justify-center items-center py-16 text-center">
|
||||
<Mail className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<Mail className="w-16 h-16 mb-4 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">
|
||||
메일을 선택하면 내용이 표시됩니다
|
||||
</p>
|
||||
|
|
@ -900,10 +900,10 @@ export default function MailReceivePage() {
|
|||
</div>
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
|
||||
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-emerald-200 ">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<CheckCircle className="w-5 h-5 mr-2 text-green-600" />
|
||||
<CheckCircle className="w-5 h-5 mr-2 text-emerald-600" />
|
||||
메일 수신 기능 완성! 🎉
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -913,81 +913,81 @@ export default function MailReceivePage() {
|
|||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">📬 기본 기능</p>
|
||||
<p className="font-medium text-foreground mb-2">📬 기본 기능</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>IMAP 프로토콜 메일 수신</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>메일 목록 표시</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>읽음/안읽음 상태</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>첨부파일 유무 표시</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">📄 상세보기</p>
|
||||
<p className="font-medium text-foreground mb-2">📄 상세보기</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>HTML 본문 렌더링</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>텍스트 본문 보기</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>자동 읽음 처리</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>첨부파일 다운로드</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">🔍 고급 기능</p>
|
||||
<p className="font-medium text-foreground mb-2">🔍 고급 기능</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>통합 검색 (제목/발신자/내용)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>필터링 (읽음/첨부파일)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>정렬 (날짜/발신자)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>자동 새로고침 (30초)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">🔒 보안</p>
|
||||
<p className="font-medium text-foreground mb-2">🔒 보안</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>XSS 방지 (DOMPurify)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>비밀번호 암호화</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-emerald-500 mr-2">✓</span>
|
||||
<span>안전한 파일명 생성</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -516,12 +516,12 @@ ${data.originalBody}`;
|
|||
toast({
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
<span>메일 발송 완료!</span>
|
||||
</div>
|
||||
) as any,
|
||||
description: `${to.length}명${cc.length > 0 ? ` (참조 ${cc.length}명)` : ""}${bcc.length > 0 ? ` (숨은참조 ${bcc.length}명)` : ""}${attachments.length > 0 ? ` (첨부파일 ${attachments.length}개)` : ""}에게 메일이 성공적으로 발송되었습니다.`,
|
||||
className: "border-green-500 bg-green-50",
|
||||
className: "border-emerald-500 bg-emerald-50",
|
||||
});
|
||||
|
||||
// 알림 갱신 이벤트 발생
|
||||
|
|
@ -544,7 +544,7 @@ ${data.originalBody}`;
|
|||
toast({
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<AlertCircle className="w-5 h-5 text-destructive" />
|
||||
<span>메일 발송 실패</span>
|
||||
</div>
|
||||
) as any,
|
||||
|
|
@ -781,7 +781,7 @@ ${data.originalBody}`;
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||
<span>
|
||||
{new Date(lastSaved).toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
|
|
@ -895,7 +895,7 @@ ${data.originalBody}`;
|
|||
{to.map((email) => (
|
||||
<div
|
||||
key={email}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded-md text-sm"
|
||||
className="flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm"
|
||||
>
|
||||
<span>{email}</span>
|
||||
<button
|
||||
|
|
@ -933,12 +933,12 @@ ${data.originalBody}`;
|
|||
{cc.map((email) => (
|
||||
<div
|
||||
key={email}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded-md text-sm"
|
||||
className="flex items-center gap-1 px-2 py-1 bg-emerald-100 text-emerald-700 rounded-md text-sm"
|
||||
>
|
||||
<span>{email}</span>
|
||||
<button
|
||||
onClick={() => removeEmail(email, "cc")}
|
||||
className="hover:bg-green-200 rounded p-0.5"
|
||||
className="hover:bg-emerald-200 rounded p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
|
|
@ -1222,7 +1222,7 @@ ${data.originalBody}`;
|
|||
<div
|
||||
key={component.id}
|
||||
style={{ height: `${component.height || 20}px` }}
|
||||
className="bg-background rounded flex items-center justify-center text-xs text-gray-400"
|
||||
className="bg-background rounded flex items-center justify-center text-xs text-muted-foreground/70"
|
||||
>
|
||||
여백
|
||||
</div>
|
||||
|
|
@ -1236,7 +1236,7 @@ ${data.originalBody}`;
|
|||
{component.logoSrc && <img src={component.logoSrc} alt="로고" className="h-10" />}
|
||||
<span className="font-bold text-lg">{component.brandName}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{component.sendDate}</span>
|
||||
<span className="text-sm text-muted-foreground">{component.sendDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1245,13 +1245,13 @@ ${data.originalBody}`;
|
|||
return (
|
||||
<div key={component.id} className="border rounded-lg overflow-hidden">
|
||||
{component.tableTitle && (
|
||||
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{component.tableTitle}</div>
|
||||
<div className="bg-muted px-4 py-2 font-semibold border-b">{component.tableTitle}</div>
|
||||
)}
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{component.rows?.map((row: any, i: number) => (
|
||||
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
|
||||
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-muted'}>
|
||||
<td className="px-4 py-2 font-medium text-muted-foreground w-1/3 border-r">{row.label}</td>
|
||||
<td className="px-4 py-2">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -1263,9 +1263,9 @@ ${data.originalBody}`;
|
|||
case 'alertBox':
|
||||
return (
|
||||
<div key={component.id} className={`p-4 rounded-lg border-l-4 ${
|
||||
component.alertType === 'info' ? 'bg-blue-50 border-blue-500 text-blue-800' :
|
||||
component.alertType === 'info' ? 'bg-primary/10 border-primary text-primary' :
|
||||
component.alertType === 'warning' ? 'bg-amber-50 border-amber-500 text-amber-800' :
|
||||
component.alertType === 'danger' ? 'bg-red-50 border-red-500 text-red-800' :
|
||||
component.alertType === 'danger' ? 'bg-destructive/10 border-destructive text-red-800' :
|
||||
'bg-emerald-50 border-emerald-500 text-emerald-800'
|
||||
}`}>
|
||||
{component.alertTitle && <div className="font-bold mb-1">{component.alertTitle}</div>}
|
||||
|
|
@ -1275,13 +1275,13 @@ ${data.originalBody}`;
|
|||
|
||||
case 'divider':
|
||||
return (
|
||||
<hr key={component.id} className="border-gray-300" style={{ borderWidth: `${component.height || 1}px` }} />
|
||||
<hr key={component.id} className="border-input" style={{ borderWidth: `${component.height || 1}px` }} />
|
||||
);
|
||||
|
||||
case 'footer':
|
||||
return (
|
||||
<div key={component.id} className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
|
||||
{component.companyName && <div className="font-semibold text-gray-700">{component.companyName}</div>}
|
||||
<div key={component.id} className="text-center text-sm text-muted-foreground py-4 border-t bg-muted">
|
||||
{component.companyName && <div className="font-semibold text-foreground">{component.companyName}</div>}
|
||||
{(component.ceoName || component.businessNumber) && (
|
||||
<div className="mt-1">
|
||||
{component.ceoName && <span>대표: {component.ceoName}</span>}
|
||||
|
|
@ -1297,7 +1297,7 @@ ${data.originalBody}`;
|
|||
{component.email && <span>Email: {component.email}</span>}
|
||||
</div>
|
||||
)}
|
||||
{component.copyright && <div className="mt-2 text-xs text-gray-400">{component.copyright}</div>}
|
||||
{component.copyright && <div className="mt-2 text-xs text-muted-foreground/70">{component.copyright}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -1318,9 +1318,9 @@ ${data.originalBody}`;
|
|||
}
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-4 py-3 border-t border-green-200">
|
||||
<p className="text-sm text-green-800 flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-4 py-3 border-t border-emerald-200">
|
||||
<p className="text-sm text-emerald-800 flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||
위 내용으로 메일이 발송됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1396,7 +1396,7 @@ ${data.originalBody}`;
|
|||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Upload className="w-12 h-12 mx-auto text-gray-400 mb-3" />
|
||||
<Upload className="w-12 h-12 mx-auto text-muted-foreground/70 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
파일을 드래그하거나 클릭하여 선택하세요
|
||||
</p>
|
||||
|
|
@ -1430,7 +1430,7 @@ ${data.originalBody}`;
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFile(index)}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
className="flex-shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -1530,7 +1530,7 @@ ${data.originalBody}`;
|
|||
<div key={index} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<File className="w-3 h-3" />
|
||||
<span className="truncate">{file.name}</span>
|
||||
<span className="text-gray-400">({formatFileSize(file.size)})</span>
|
||||
<span className="text-muted-foreground/70">({formatFileSize(file.size)})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -371,8 +371,8 @@ export default function SentMailPage() {
|
|||
{stats.successCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-500/10 rounded-lg">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600" />
|
||||
<div className="p-3 bg-emerald-500/10 rounded-lg">
|
||||
<CheckCircle2 className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -387,8 +387,8 @@ export default function SentMailPage() {
|
|||
{stats.failedCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-red-500/10 rounded-lg">
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
<div className="p-3 bg-destructive/10 rounded-lg">
|
||||
<XCircle className="w-6 h-6 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -403,8 +403,8 @@ export default function SentMailPage() {
|
|||
{stats.todayCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-500/10 rounded-lg">
|
||||
<Calendar className="w-6 h-6 text-blue-600" />
|
||||
<div className="p-3 bg-primary/10 rounded-lg">
|
||||
<Calendar className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -592,19 +592,19 @@ export default function BatchManagementNewPage() {
|
|||
<div
|
||||
key={option.value}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
batchType === option.value ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300"
|
||||
batchType === option.value ? "border-primary bg-primary/10" : "border-border hover:border-input"
|
||||
}`}
|
||||
onClick={() => setBatchType(option.value)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{option.value === "restapi-to-db" ? (
|
||||
<Globe className="h-4 w-4 text-blue-600" />
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Database className="h-4 w-4 text-green-600" />
|
||||
<Database className="h-4 w-4 text-emerald-600" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{option.description}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -739,7 +739,7 @@ export default function BatchManagementNewPage() {
|
|||
</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 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
|
||||
|
|
@ -782,7 +782,7 @@ export default function BatchManagementNewPage() {
|
|||
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
|
||||
|
|
@ -801,7 +801,7 @@ export default function BatchManagementNewPage() {
|
|||
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>
|
||||
)}
|
||||
|
||||
|
|
@ -809,7 +809,7 @@ export default function BatchManagementNewPage() {
|
|||
<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>
|
||||
|
|
@ -868,26 +868,26 @@ export default function BatchManagementNewPage() {
|
|||
}
|
||||
/>
|
||||
{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">
|
||||
실제 호출: {fromEndpoint || "/api/users"}?{apiParamName || "userId"}=
|
||||
{apiParamValue || "123"}
|
||||
</div>
|
||||
|
|
@ -899,9 +899,9 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
{/* API 호출 미리보기 정보 */}
|
||||
{fromApiUrl && fromEndpoint && (
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-sm font-medium text-gray-700">API 호출 정보</div>
|
||||
<div className="mt-1 text-sm text-gray-600">
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="text-sm font-medium text-foreground">API 호출 정보</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{fromApiMethod} {fromApiUrl}
|
||||
{apiParamType === "url" && apiParamName && apiParamValue
|
||||
? fromEndpoint.replace(`{${apiParamName}}`, apiParamValue) || fromEndpoint + `/${apiParamValue}`
|
||||
|
|
@ -911,14 +911,14 @@ export default function BatchManagementNewPage() {
|
|||
: ""}
|
||||
</div>
|
||||
{((authTokenMode === "direct" && fromApiKey) || (authTokenMode === "db" && authServiceName)) && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{authTokenMode === "direct"
|
||||
? `Authorization: Bearer ${fromApiKey.substring(0, 15)}...`
|
||||
: `Authorization: DB 토큰 (${authServiceName})`}
|
||||
</div>
|
||||
)}
|
||||
{apiParamType !== "none" && apiParamName && apiParamValue && (
|
||||
<div className="mt-1 text-xs text-blue-600">
|
||||
<div className="mt-1 text-xs text-primary">
|
||||
파라미터: {apiParamName} = {apiParamValue} ({apiParamSource === "static" ? "고정값" : "동적값"})
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -988,7 +988,7 @@ export default function BatchManagementNewPage() {
|
|||
setSelectedColumns(selectedColumns.filter((col) => col !== column.column_name));
|
||||
}
|
||||
}}
|
||||
className="rounded border-gray-300"
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`col-${column.column_name}`}
|
||||
|
|
@ -1002,14 +1002,14 @@ export default function BatchManagementNewPage() {
|
|||
</div>
|
||||
|
||||
{/* 선택된 컬럼 개수 표시 */}
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
선택된 컬럼: {selectedColumns.length}개 / 전체: {fromColumns.length}개
|
||||
</div>
|
||||
|
||||
{/* 빠른 매핑 버튼들 */}
|
||||
{selectedColumns.length > 0 && toApiFields.length > 0 && (
|
||||
<div className="mt-3 rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-green-800">빠른 매핑</div>
|
||||
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-emerald-800">빠른 매핑</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1051,7 +1051,7 @@ export default function BatchManagementNewPage() {
|
|||
setDbToApiFieldMapping(mapping);
|
||||
toast.success(`${Object.keys(mapping).length}개 컬럼이 자동 매핑되었습니다.`);
|
||||
}}
|
||||
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||
className="rounded bg-primary px-3 py-1 text-xs text-white hover:bg-primary/90"
|
||||
>
|
||||
스마트 자동 매핑
|
||||
</button>
|
||||
|
|
@ -1061,7 +1061,7 @@ export default function BatchManagementNewPage() {
|
|||
setDbToApiFieldMapping({});
|
||||
toast.success("매핑이 초기화되었습니다.");
|
||||
}}
|
||||
className="rounded bg-gray-600 px-3 py-1 text-xs text-white hover:bg-gray-700"
|
||||
className="rounded bg-foreground/80 px-3 py-1 text-xs text-white hover:bg-foreground/90"
|
||||
>
|
||||
매핑 초기화
|
||||
</button>
|
||||
|
|
@ -1071,9 +1071,9 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
{/* 자동 생성된 JSON 미리보기 */}
|
||||
{selectedColumns.length > 0 && (
|
||||
<div className="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-blue-800">자동 생성된 JSON 구조</div>
|
||||
<pre className="overflow-x-auto font-mono text-xs text-blue-600">
|
||||
<div className="mt-3 rounded-lg border border-primary/20 bg-primary/10 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-primary">자동 생성된 JSON 구조</div>
|
||||
<pre className="overflow-x-auto font-mono text-xs text-primary">
|
||||
{JSON.stringify(
|
||||
selectedColumns.reduce(
|
||||
(obj, col) => {
|
||||
|
|
@ -1105,7 +1105,7 @@ export default function BatchManagementNewPage() {
|
|||
setToApiBody(autoJson);
|
||||
toast.success("Request Body에 자동 생성된 JSON이 적용되었습니다.");
|
||||
}}
|
||||
className="mt-2 rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||
className="mt-2 rounded bg-primary px-3 py-1 text-xs text-white hover:bg-primary/90"
|
||||
>
|
||||
Request Body에 적용
|
||||
</button>
|
||||
|
|
@ -1231,7 +1231,7 @@ export default function BatchManagementNewPage() {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
이 컬럼 값이 같으면 UPDATE, 없으면 INSERT 합니다. (예: device_serial_number)
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1273,7 +1273,7 @@ export default function BatchManagementNewPage() {
|
|||
placeholder="/api/users"
|
||||
/>
|
||||
{(toApiMethod === "PUT" || toApiMethod === "DELETE") && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
실제 URL: {toEndpoint}/{urlPathColumn ? `{${urlPathColumn}}` : "{ID}"}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -1310,7 +1310,7 @@ export default function BatchManagementNewPage() {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
PUT/DELETE 요청 시 URL 경로에 포함될 컬럼을 선택하세요. (예: USER_ID → /api/users/user123)
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1321,7 +1321,7 @@ export default function BatchManagementNewPage() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={previewToApiData}
|
||||
className="flex items-center space-x-2 rounded-md bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
||||
className="flex items-center space-x-2 rounded-md bg-emerald-600 px-4 py-2 text-white hover:bg-green-700"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>API 필드 미리보기</span>
|
||||
|
|
@ -1330,13 +1330,13 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
{/* TO API 필드 표시 */}
|
||||
{toApiFields.length > 0 && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-green-800">
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-emerald-800">
|
||||
API 필드 목록 ({toApiFields.length}개)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{toApiFields.map((field) => (
|
||||
<span key={field} className="rounded bg-green-100 px-2 py-1 text-xs text-green-700">
|
||||
<span key={field} className="rounded bg-emerald-100 px-2 py-1 text-xs text-emerald-700">
|
||||
{field}
|
||||
</span>
|
||||
))}
|
||||
|
|
@ -1355,7 +1355,7 @@ export default function BatchManagementNewPage() {
|
|||
placeholder='{"id": "{{id}}", "name": "{{name}}", "email": "{{email}}"}'
|
||||
className="h-24 w-full rounded-md border p-2 font-mono text-sm"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
DB 컬럼 값을 {"{{컬럼명}}"} 형태로 매핑하세요. 예: {"{{user_id}}, {{user_name}}"}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1363,15 +1363,15 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
{/* API 호출 정보 */}
|
||||
{toApiUrl && toApiKey && toEndpoint && (
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-sm font-medium text-gray-700">API 호출 정보</div>
|
||||
<div className="mt-1 text-sm text-gray-600">
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="text-sm font-medium text-foreground">API 호출 정보</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{toApiMethod} {toApiUrl}
|
||||
{toEndpoint}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">Headers: X-API-Key: {toApiKey.substring(0, 10)}...</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">Headers: X-API-Key: {toApiKey.substring(0, 10)}...</div>
|
||||
{toApiBody && (
|
||||
<div className="mt-1 text-xs text-blue-600">Body: {toApiBody.substring(0, 50)}...</div>
|
||||
<div className="mt-1 text-xs text-primary">Body: {toApiBody.substring(0, 50)}...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1394,7 +1394,7 @@ export default function BatchManagementNewPage() {
|
|||
데이터 불러오고 매핑하기
|
||||
</Button>
|
||||
{(!fromApiUrl || !fromEndpoint || !toTable) && (
|
||||
<p className="ml-4 flex items-center text-xs text-gray-500">
|
||||
<p className="ml-4 flex items-center text-xs text-muted-foreground">
|
||||
FROM 섹션과 TO 섹션의 필수 값을 모두 입력해야 합니다.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -1693,17 +1693,17 @@ const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
|||
<CardContent>
|
||||
<div className="max-h-96 space-y-3 overflow-y-auto rounded-lg border p-4">
|
||||
{selectedColumnObjects.map((column) => (
|
||||
<div key={column.column_name} className="flex items-center space-x-4 rounded-lg bg-gray-50 p-3">
|
||||
<div key={column.column_name} className="flex items-center space-x-4 rounded-lg bg-muted p-3">
|
||||
{/* DB 컬럼 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{column.column_name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
타입: {column.data_type} | NULL: {column.is_nullable ? "Y" : "N"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="text-gray-400">→</div>
|
||||
<div className="text-muted-foreground/70">→</div>
|
||||
|
||||
{/* API 필드 선택 드롭다운 */}
|
||||
<div className="flex-1">
|
||||
|
|
@ -1735,7 +1735,7 @@ const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
|||
<input
|
||||
type="text"
|
||||
placeholder="API 필드명을 직접 입력하세요"
|
||||
className="mt-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="mt-2 w-full rounded-md border border-input px-3 py-2 text-sm focus:ring-2 focus:ring-ring focus:outline-none"
|
||||
onChange={(e) => {
|
||||
setDbToApiFieldMapping((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -1745,7 +1745,7 @@ const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
|||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{dbToApiFieldMapping[column.column_name]
|
||||
? `매핑: ${column.column_name} → ${dbToApiFieldMapping[column.column_name]}`
|
||||
: `기본값: ${column.column_name} (DB 컬럼명 사용)`}
|
||||
|
|
@ -1755,32 +1755,32 @@ const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
|||
{/* 템플릿 미리보기 */}
|
||||
<div className="flex-1">
|
||||
<div className="rounded border bg-white p-2 font-mono text-sm">{`{{${column.column_name}}}`}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">실제 DB 값으로 치환됩니다</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">실제 DB 값으로 치환됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedColumns.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="text-sm font-medium text-blue-800">자동 생성된 JSON 구조</div>
|
||||
<pre className="mt-1 overflow-x-auto font-mono text-xs text-blue-600">{autoJsonPreview}</pre>
|
||||
<div className="mt-4 rounded-lg border border-primary/20 bg-primary/10 p-3">
|
||||
<div className="text-sm font-medium text-primary">자동 생성된 JSON 구조</div>
|
||||
<pre className="mt-1 overflow-x-auto font-mono text-xs text-primary">{autoJsonPreview}</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setToApiBody(autoJsonPreview);
|
||||
toast.success("Request Body에 자동 생성된 JSON이 적용되었습니다.");
|
||||
}}
|
||||
className="mt-2 rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||
className="mt-2 rounded bg-primary px-3 py-1 text-xs text-white hover:bg-primary/90"
|
||||
>
|
||||
Request Body에 적용
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="text-sm font-medium text-blue-800">매핑 사용 예시</div>
|
||||
<div className="mt-1 font-mono text-xs text-blue-600">
|
||||
<div className="mt-4 rounded-lg border border-primary/20 bg-primary/10 p-3">
|
||||
<div className="text-sm font-medium text-primary">매핑 사용 예시</div>
|
||||
<div className="mt-1 font-mono text-xs text-primary">
|
||||
{'{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}'}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -43,6 +35,8 @@ import {
|
|||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||
import BatchJobModal from "@/components/admin/BatchJobModal";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||
|
||||
export default function BatchManagementPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -54,7 +48,6 @@ export default function BatchManagementPage() {
|
|||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [jobTypes, setJobTypes] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedJob, setSelectedJob] = useState<BatchJob | null>(null);
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
|
|
@ -84,7 +77,7 @@ export default function BatchManagementPage() {
|
|||
const loadJobTypes = async () => {
|
||||
try {
|
||||
const types = await BatchAPI.getSupportedJobTypes();
|
||||
setJobTypes(types);
|
||||
setJobTypes(types.map(t => ({ value: t, label: t })));
|
||||
} catch (error) {
|
||||
console.error("작업 타입 조회 오류:", error);
|
||||
}
|
||||
|
|
@ -93,7 +86,6 @@ export default function BatchManagementPage() {
|
|||
const filterJobs = () => {
|
||||
let filtered = jobs;
|
||||
|
||||
// 검색어 필터
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(job =>
|
||||
job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
|
|
@ -101,12 +93,10 @@ export default function BatchManagementPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== "all") {
|
||||
filtered = filtered.filter(job => job.is_active === statusFilter);
|
||||
}
|
||||
|
||||
// 타입 필터
|
||||
if (typeFilter !== "all") {
|
||||
filtered = filtered.filter(job => job.job_type === typeFilter);
|
||||
}
|
||||
|
|
@ -123,12 +113,10 @@ export default function BatchManagementPage() {
|
|||
setIsBatchTypeModalOpen(false);
|
||||
|
||||
if (type === 'db-to-db') {
|
||||
// 기존 배치 생성 모달 열기
|
||||
console.log("DB → DB 배치 모달 열기");
|
||||
setSelectedJob(null);
|
||||
setIsModalOpen(true);
|
||||
} else if (type === 'restapi-to-db') {
|
||||
// 새로운 REST API 배치 페이지로 이동
|
||||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
||||
router.push('/admin/batch-management-new');
|
||||
}
|
||||
|
|
@ -170,245 +158,141 @@ export default function BatchManagementPage() {
|
|||
|
||||
const getStatusBadge = (isActive: string) => {
|
||||
return isActive === "Y" ? (
|
||||
<Badge className="bg-green-100 text-green-800">활성</Badge>
|
||||
<Badge variant="default">활성</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-100 text-red-800">비활성</Badge>
|
||||
<Badge variant="secondary">비활성</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const option = jobTypes.find(opt => opt.value === type);
|
||||
const colors = {
|
||||
collection: "bg-blue-100 text-blue-800",
|
||||
sync: "bg-purple-100 text-purple-800",
|
||||
cleanup: "bg-orange-100 text-orange-800",
|
||||
custom: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
|
||||
const icons = {
|
||||
collection: "📥",
|
||||
sync: "🔄",
|
||||
cleanup: "🧹",
|
||||
custom: "⚙️",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className={colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800"}>
|
||||
<span className="mr-1">{icons[type as keyof typeof icons] || "📋"}</span>
|
||||
{option?.label || type}
|
||||
</Badge>
|
||||
<Badge variant="outline">{option?.label || type}</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getSuccessRate = (job: BatchJob) => {
|
||||
if (job.execution_count === 0) return 100;
|
||||
return Math.round((job.success_count / job.execution_count) * 100);
|
||||
if (!job.execution_count) return 100;
|
||||
return Math.round(((job.success_count ?? 0) / job.execution_count) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">배치 관리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
스케줄된 배치 작업을 관리하고 실행 상태를 모니터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => window.open('/admin/monitoring', '_blank')}>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
모니터링
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
새 배치 작업
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
const getSuccessRateColor = (rate: number) => {
|
||||
if (rate >= 90) return 'text-success';
|
||||
if (rate >= 70) return 'text-warning';
|
||||
return 'text-destructive';
|
||||
};
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 작업</CardTitle>
|
||||
<div className="text-2xl">📋</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{jobs.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
활성: {jobs.filter(j => j.is_active === 'Y').length}개
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 실행</CardTitle>
|
||||
<div className="text-2xl">▶️</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{jobs.reduce((sum, job) => sum + job.execution_count, 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
||||
<div className="text-2xl">✅</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{jobs.reduce((sum, job) => sum + job.success_count, 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
||||
<div className="text-2xl">❌</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{jobs.reduce((sum, job) => sum + job.failure_count, 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>필터 및 검색</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="작업명, 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="Y">활성</SelectItem>
|
||||
<SelectItem value="N">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="작업 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 타입</SelectItem>
|
||||
{jobTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" onClick={loadJobs} disabled={isLoading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배치 작업 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>배치 작업 목록 ({filteredJobs.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||||
<p>배치 작업을 불러오는 중...</p>
|
||||
</div>
|
||||
) : filteredJobs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<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-sm font-semibold">마지막 실행</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredJobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>
|
||||
const columns: RDVColumn<BatchJob>[] = [
|
||||
{
|
||||
key: "job_name",
|
||||
label: "작업명",
|
||||
render: (_val, job) => (
|
||||
<div>
|
||||
<div className="font-medium">{job.job_name}</div>
|
||||
{job.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{job.description}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{job.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getTypeBadge(job.job_type)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{job.schedule_cron || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(job.is_active)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "job_type",
|
||||
label: "타입",
|
||||
hideOnMobile: true,
|
||||
render: (_val, job) => getTypeBadge(job.job_type),
|
||||
},
|
||||
{
|
||||
key: "schedule_cron",
|
||||
label: "스케줄",
|
||||
hideOnMobile: true,
|
||||
render: (_val, job) => (
|
||||
<span className="font-mono">{job.schedule_cron || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "상태",
|
||||
width: "100px",
|
||||
render: (_val, job) => getStatusBadge(job.is_active),
|
||||
},
|
||||
{
|
||||
key: "execution_count",
|
||||
label: "실행",
|
||||
hideOnMobile: true,
|
||||
render: (_val, job) => (
|
||||
<div>
|
||||
<div>총 {job.execution_count}회</div>
|
||||
<div className="text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
성공 {job.success_count} / 실패 {job.failure_count}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-sm font-medium ${
|
||||
getSuccessRate(job) >= 90 ? 'text-green-600' :
|
||||
getSuccessRate(job) >= 70 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{getSuccessRate(job)}%
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "success_rate",
|
||||
label: "성공률",
|
||||
hideOnMobile: true,
|
||||
render: (_val, job) => {
|
||||
const rate = getSuccessRate(job);
|
||||
return (
|
||||
<span className={`font-medium ${getSuccessRateColor(rate)}`}>
|
||||
{rate}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "last_executed_at",
|
||||
label: "마지막 실행",
|
||||
render: (_val, job) => (
|
||||
<span>
|
||||
{job.last_executed_at
|
||||
? new Date(job.last_executed_at).toLocaleString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const cardFields: RDVCardField<BatchJob>[] = [
|
||||
{
|
||||
label: "타입",
|
||||
render: (job) => getTypeBadge(job.job_type),
|
||||
},
|
||||
{
|
||||
label: "스케줄",
|
||||
render: (job) => (
|
||||
<span className="font-mono text-xs">{job.schedule_cron || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "실행 횟수",
|
||||
render: (job) => <span className="font-medium">{job.execution_count}회</span>,
|
||||
},
|
||||
{
|
||||
label: "성공률",
|
||||
render: (job) => {
|
||||
const rate = getSuccessRate(job);
|
||||
return (
|
||||
<span className={`font-medium ${getSuccessRateColor(rate)}`}>
|
||||
{rate}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "마지막 실행",
|
||||
render: (job) => (
|
||||
<span className="text-xs">
|
||||
{job.last_executed_at
|
||||
? new Date(job.last_executed_at).toLocaleDateString()
|
||||
: "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const renderDropdownActions = (job: BatchJob) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
|
|
@ -433,53 +317,185 @@ export default function BatchManagementPage() {
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">배치 관리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
스케줄된 배치 작업을 관리하고 실행 상태를 모니터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => window.open('/admin/monitoring', '_blank')}>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
모니터링
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
새 배치 작업
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 작업</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{jobs.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
활성: {jobs.filter(j => j.is_active === 'Y').length}개
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 실행</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{jobs.reduce((sum, job) => sum + (job.execution_count ?? 0), 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-success">
|
||||
{jobs.reduce((sum, job) => sum + (job.success_count ?? 0), 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-destructive">
|
||||
{jobs.reduce((sum, job) => sum + (job.failure_count ?? 0), 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<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="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={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="Y">활성</SelectItem>
|
||||
<SelectItem value="N">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectValue placeholder="작업 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 타입</SelectItem>
|
||||
{jobTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={loadJobs} disabled={isLoading} className="h-10 w-full sm:w-auto">
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 배치 작업 목록 제목 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredJobs.length}</span>개
|
||||
</div>
|
||||
|
||||
<ResponsiveDataView<BatchJob>
|
||||
data={filteredJobs}
|
||||
columns={columns}
|
||||
keyExtractor={(job) => String(job.id)}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."}
|
||||
skeletonCount={5}
|
||||
cardTitle={(job) => job.job_name}
|
||||
cardSubtitle={(job) => job.description ? (
|
||||
<span className="truncate text-sm text-muted-foreground">{job.description}</span>
|
||||
) : undefined}
|
||||
cardHeaderRight={(job) => getStatusBadge(job.is_active)}
|
||||
cardFields={cardFields}
|
||||
actionsLabel="작업"
|
||||
actionsWidth="80px"
|
||||
renderActions={renderDropdownActions}
|
||||
/>
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-2xl mx-4">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<Card className="mx-4 w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* DB → DB */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
|
||||
className="cursor-pointer rounded-lg border p-6 transition-all hover:border-primary hover:bg-muted/50"
|
||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Database className="w-8 h-8 text-blue-600 mr-2" />
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||||
<Database className="w-8 h-8 text-blue-600" />
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<Database className="mr-2 h-8 w-8 text-primary" />
|
||||
<ArrowRight className="mr-2 h-6 w-6 text-muted-foreground" />
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-lg mb-2">DB → DB</div>
|
||||
<div className="text-sm text-gray-500">데이터베이스 간 데이터 동기화</div>
|
||||
<div className="mb-2 text-lg font-medium">DB → DB</div>
|
||||
<div className="text-sm text-muted-foreground">데이터베이스 간 데이터 동기화</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* REST API → DB */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
|
||||
className="cursor-pointer rounded-lg border p-6 transition-all hover:border-primary hover:bg-muted/50"
|
||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Globe className="w-8 h-8 text-green-600 mr-2" />
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||||
<Database className="w-8 h-8 text-green-600" />
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<Globe className="mr-2 h-8 w-8 text-success" />
|
||||
<ArrowRight className="mr-2 h-6 w-6 text-muted-foreground" />
|
||||
<Database className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-lg mb-2">REST API → DB</div>
|
||||
<div className="text-sm text-gray-500">REST API에서 데이터베이스로 데이터 수집</div>
|
||||
<div className="mb-2 text-lg font-medium">REST API → DB</div>
|
||||
<div className="text-sm text-muted-foreground">REST API에서 데이터베이스로 데이터 수집</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@ export default function AutoFillTab() {
|
|||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -676,7 +676,7 @@ export default function AutoFillTab() {
|
|||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -577,9 +577,9 @@ export default function CascadingRelationsTab() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-700">{relation.parent_table}</span>
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 text-primary">{relation.parent_table}</span>
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||
<span className="rounded bg-green-100 px-2 py-0.5 text-green-700">
|
||||
<span className="rounded bg-emerald-100 px-2 py-0.5 text-emerald-700">
|
||||
{relation.child_table}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -619,7 +619,7 @@ export default function CascadingRelationsTab() {
|
|||
<div className="space-y-4">
|
||||
{/* Step 1: 부모 테이블 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-blue-600">1. 부모 (상위 선택)</h4>
|
||||
<h4 className="mb-3 text-sm font-semibold text-primary">1. 부모 (상위 선택)</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">테이블</Label>
|
||||
|
|
@ -696,7 +696,7 @@ export default function CascadingRelationsTab() {
|
|||
|
||||
{/* Step 2: 자식 테이블 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-green-600">2. 자식 (하위 옵션)</h4>
|
||||
<h4 className="mb-3 text-sm font-semibold text-emerald-600">2. 자식 (하위 옵션)</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">테이블</Label>
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ export default function ConditionTab() {
|
|||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{condition.conditionField}</span>
|
||||
<span className="mx-1 text-blue-600">{getOperatorLabel(condition.conditionOperator)}</span>
|
||||
<span className="mx-1 text-primary">{getOperatorLabel(condition.conditionOperator)}</span>
|
||||
<span className="font-medium">{condition.conditionValue}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
@ -329,7 +329,7 @@ export default function ConditionTab() {
|
|||
size="icon"
|
||||
onClick={() => handleDeleteConfirm(condition.conditionId!)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -491,7 +491,7 @@ export default function ConditionTab() {
|
|||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ export default function HierarchyTab() {
|
|||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -548,7 +548,7 @@ export default function HierarchyTab() {
|
|||
size="icon"
|
||||
onClick={() => handleDeleteLevel(level.levelId!, group.groupCode)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -836,7 +836,7 @@ export default function HierarchyTab() {
|
|||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ export default function MutualExclusionTab() {
|
|||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(exclusion.exclusionId!)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -404,7 +404,7 @@ export default function MutualExclusionTab() {
|
|||
/>
|
||||
{fieldList.length > 2 && (
|
||||
<Button variant="ghost" size="icon" onClick={() => removeField(index)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -571,7 +571,7 @@ export default function MutualExclusionTab() {
|
|||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ export default function DebugLayoutPage() {
|
|||
<h1 className="mb-4 text-2xl font-bold">관리자 레이아웃 디버깅</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded bg-green-100 p-4">
|
||||
<div className="rounded bg-emerald-100 p-4">
|
||||
<h2 className="mb-2 font-semibold">토큰 상태</h2>
|
||||
<p>토큰 존재: {debugInfo.hasToken ? "✅ 예" : "❌ 아니오"}</p>
|
||||
<p>토큰 길이: {debugInfo.tokenLength}</p>
|
||||
|
|
@ -142,14 +142,14 @@ export default function DebugLayoutPage() {
|
|||
<p>SessionStorage 토큰: {debugInfo.sessionToken ? "✅ 존재" : "❌ 없음"}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded bg-blue-100 p-4">
|
||||
<div className="rounded bg-primary/10 p-4">
|
||||
<h2 className="mb-2 font-semibold">페이지 정보</h2>
|
||||
<p>현재 URL: {debugInfo.currentUrl}</p>
|
||||
<p>Pathname: {debugInfo.pathname}</p>
|
||||
<p>시간: {debugInfo.timestamp}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded bg-yellow-100 p-4">
|
||||
<div className="rounded bg-amber-100 p-4">
|
||||
<h2 className="mb-2 font-semibold">토큰 관리</h2>
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
|
|
@ -157,11 +157,11 @@ export default function DebugLayoutPage() {
|
|||
const token = localStorage.getItem("authToken");
|
||||
alert(`토큰: ${token ? "존재" : "없음"}\n길이: ${token ? token.length : 0}`);
|
||||
}}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
className="rounded bg-primary px-4 py-2 text-white hover:bg-primary"
|
||||
>
|
||||
토큰 확인
|
||||
</button>
|
||||
<button onClick={handleTokenSync} className="rounded bg-green-500 px-4 py-2 text-white hover:bg-green-600">
|
||||
<button onClick={handleTokenSync} className="rounded bg-emerald-500 px-4 py-2 text-white hover:bg-emerald-600">
|
||||
토큰 동기화
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -173,13 +173,13 @@ export default function DebugLayoutPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded bg-orange-100 p-4">
|
||||
<div className="rounded bg-amber-100 p-4">
|
||||
<h2 className="mb-2 font-semibold">API 테스트</h2>
|
||||
<div className="mb-4 space-x-2">
|
||||
<button onClick={handleApiTest} className="rounded bg-orange-500 px-4 py-2 text-white hover:bg-orange-600">
|
||||
<button onClick={handleApiTest} className="rounded bg-amber-500 px-4 py-2 text-white hover:bg-orange-600">
|
||||
인증 상태 API 테스트
|
||||
</button>
|
||||
<button onClick={handleUserApiTest} className="rounded bg-red-500 px-4 py-2 text-white hover:bg-red-600">
|
||||
<button onClick={handleUserApiTest} className="rounded bg-destructive px-4 py-2 text-white hover:bg-destructive">
|
||||
사용자 API 테스트
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -194,7 +194,7 @@ export default function DebugLayoutPage() {
|
|||
<div
|
||||
className={`rounded p-3 ${
|
||||
apiTestResult.status === "success"
|
||||
? "bg-green-200"
|
||||
? "bg-emerald-200"
|
||||
: apiTestResult.status === "error"
|
||||
? "bg-red-200"
|
||||
: "bg-yellow-200"
|
||||
|
|
|
|||
|
|
@ -28,27 +28,27 @@ export default function SimpleDebugPage() {
|
|||
<h1 className="mb-4 text-2xl font-bold">간단한 토큰 디버깅</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded bg-green-100 p-4">
|
||||
<div className="rounded bg-emerald-100 p-4">
|
||||
<h2 className="mb-2 font-semibold">토큰 상태</h2>
|
||||
<p>토큰 존재: {tokenInfo.hasToken ? "✅ 예" : "❌ 아니오"}</p>
|
||||
<p>토큰 길이: {tokenInfo.tokenLength}</p>
|
||||
<p>토큰 시작: {tokenInfo.tokenStart}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded bg-blue-100 p-4">
|
||||
<div className="rounded bg-primary/10 p-4">
|
||||
<h2 className="mb-2 font-semibold">페이지 정보</h2>
|
||||
<p>현재 URL: {tokenInfo.currentUrl}</p>
|
||||
<p>시간: {tokenInfo.timestamp}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded bg-yellow-100 p-4">
|
||||
<div className="rounded bg-amber-100 p-4">
|
||||
<h2 className="mb-2 font-semibold">테스트 버튼</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
const token = localStorage.getItem("authToken");
|
||||
alert(`토큰: ${token ? "존재" : "없음"}\n길이: ${token ? token.length : 0}`);
|
||||
}}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
className="rounded bg-primary px-4 py-2 text-white hover:bg-primary"
|
||||
>
|
||||
토큰 확인
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -15,16 +15,16 @@ export default function AdminDebugPage() {
|
|||
<h1 className="mb-4 text-2xl font-bold">어드민 권한 디버깅</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded bg-gray-100 p-4">
|
||||
<div className="rounded bg-muted p-4">
|
||||
<h2 className="mb-2 font-semibold">인증 상태</h2>
|
||||
<p>로딩: {loading ? "예" : "아니오"}</p>
|
||||
<p>로그인: {isLoggedIn ? "예" : "아니오"}</p>
|
||||
<p>관리자: {isAdmin ? "예" : "아니오"}</p>
|
||||
{error && <p className="text-red-500">에러: {error}</p>}
|
||||
{error && <p className="text-destructive">에러: {error}</p>}
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="rounded bg-blue-100 p-4">
|
||||
<div className="rounded bg-primary/10 p-4">
|
||||
<h2 className="mb-2 font-semibold">사용자 정보</h2>
|
||||
<p>ID: {user.userId}</p>
|
||||
<p>이름: {user.userName}</p>
|
||||
|
|
@ -34,7 +34,7 @@ export default function AdminDebugPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded bg-green-100 p-4">
|
||||
<div className="rounded bg-emerald-100 p-4">
|
||||
<h2 className="mb-2 font-semibold">토큰 정보</h2>
|
||||
<p>
|
||||
localStorage 토큰: {typeof window !== "undefined" && localStorage.getItem("authToken") ? "존재" : "없음"}
|
||||
|
|
|
|||
|
|
@ -220,13 +220,13 @@ export default function LayoutManagementPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">레이아웃 관리</h1>
|
||||
<p className="mt-2 text-gray-600">화면 레이아웃을 생성하고 관리합니다</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">레이아웃 관리</h1>
|
||||
<p className="mt-2 text-muted-foreground">화면 레이아웃을 생성하고 관리합니다</p>
|
||||
</div>
|
||||
<Button className="flex items-center gap-2 shadow-sm" onClick={() => setCreateModalOpen(true)}>
|
||||
<Plus className="h-4 w-4" />새 레이아웃
|
||||
|
|
@ -239,7 +239,7 @@ export default function LayoutManagementPage() {
|
|||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground/70" />
|
||||
<Input
|
||||
placeholder="레이아웃 이름 또는 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
|
|
@ -276,7 +276,7 @@ export default function LayoutManagementPage() {
|
|||
{loading ? (
|
||||
<div className="py-8 text-center">로딩 중...</div>
|
||||
) : layouts.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">레이아웃이 없습니다.</div>
|
||||
<div className="py-8 text-center text-muted-foreground">레이아웃이 없습니다.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 레이아웃 그리드 */}
|
||||
|
|
@ -288,7 +288,7 @@ export default function LayoutManagementPage() {
|
|||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CategoryIcon className="h-5 w-5 text-gray-600" />
|
||||
<CategoryIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<Badge variant="secondary">
|
||||
{CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]}
|
||||
</Badge>
|
||||
|
|
@ -312,7 +312,7 @@ export default function LayoutManagementPage() {
|
|||
<Copy className="mr-2 h-4 w-4" />
|
||||
복제
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(layout)} className="text-red-600">
|
||||
<DropdownMenuItem onClick={() => handleDelete(layout)} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -321,17 +321,17 @@ export default function LayoutManagementPage() {
|
|||
</div>
|
||||
<CardTitle className="text-lg">{layout.layoutName}</CardTitle>
|
||||
{layout.description && (
|
||||
<p className="line-clamp-2 text-sm text-gray-600">{layout.description}</p>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">{layout.description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">타입:</span>
|
||||
<span className="text-muted-foreground">타입:</span>
|
||||
<Badge variant="outline">{layout.layoutType}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">존 개수:</span>
|
||||
<span className="text-muted-foreground">존 개수:</span>
|
||||
<span>{layout.zonesConfig.length}개</span>
|
||||
</div>
|
||||
{layout.isPublic === "Y" && (
|
||||
|
|
@ -397,7 +397,7 @@ export default function LayoutManagementPage() {
|
|||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-destructive hover:bg-red-700">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -59,25 +59,25 @@ export default function MonitoringPage() {
|
|||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
return <CheckCircle className="h-4 w-4 text-emerald-500" />;
|
||||
case 'failed':
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
return <AlertCircle className="h-4 w-4 text-destructive" />;
|
||||
case 'running':
|
||||
return <Play className="h-4 w-4 text-blue-500" />;
|
||||
return <Play className="h-4 w-4 text-primary" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
return <Clock className="h-4 w-4 text-amber-500" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||
return <Clock className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants = {
|
||||
completed: "bg-green-100 text-green-800",
|
||||
completed: "bg-emerald-100 text-emerald-800",
|
||||
failed: "bg-destructive/20 text-red-800",
|
||||
running: "bg-primary/20 text-blue-800",
|
||||
pending: "bg-yellow-100 text-yellow-800",
|
||||
cancelled: "bg-gray-100 text-gray-800",
|
||||
running: "bg-primary/20 text-primary",
|
||||
pending: "bg-amber-100 text-yellow-800",
|
||||
cancelled: "bg-muted text-foreground",
|
||||
};
|
||||
|
||||
const labels = {
|
||||
|
|
@ -120,7 +120,7 @@ export default function MonitoringPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
|
|
@ -191,7 +191,7 @@ export default function MonitoringPage() {
|
|||
<div className="text-2xl">✅</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
|
||||
<div className="text-2xl font-bold text-emerald-600">{monitoring.successful_jobs_today}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
성공률: {getSuccessRate()}%
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default function BarcodeLabelDesignerPage() {
|
|||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<BarcodeDesignerProvider labelId={labelId}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-gray-50">
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-muted">
|
||||
<BarcodeDesignerToolbar />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<BarcodeDesignerLeftPanel />
|
||||
|
|
|
|||
|
|
@ -32,12 +32,12 @@ export default function BarcodeLabelManagementPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">바코드 라벨 관리</h1>
|
||||
<p className="mt-2 text-gray-600">ZD421 등 바코드 프린터용 라벨을 작성하고 출력합니다</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">바코드 라벨 관리</h1>
|
||||
<p className="mt-2 text-muted-foreground">ZD421 등 바코드 프린터용 라벨을 작성하고 출력합니다</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateNew} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
|
|
@ -46,7 +46,7 @@ export default function BarcodeLabelManagementPage() {
|
|||
</div>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
검색
|
||||
|
|
@ -105,7 +105,7 @@ export default function BarcodeLabelManagementPage() {
|
|||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
바코드 라벨 목록
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { dashboardApi } from "@/lib/api/dashboard";
|
|||
import { Dashboard } from "@/lib/api/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -16,6 +15,8 @@ import {
|
|||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
||||
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
||||
|
||||
/**
|
||||
|
|
@ -30,7 +31,7 @@ export default function DashboardListPage() {
|
|||
|
||||
// 상태 관리
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
|
|
@ -83,52 +84,36 @@ export default function DashboardListPage() {
|
|||
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||
};
|
||||
|
||||
// 페이지 변경 핸들러
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
const handlePageChange = (page: number) => setCurrentPage(page);
|
||||
|
||||
// 페이지 크기 변경 핸들러
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 대시보드 삭제 확인 모달 열기
|
||||
const handleDeleteClick = (id: string, title: string) => {
|
||||
setDeleteTarget({ id, title });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 대시보드 삭제 실행
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
try {
|
||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTarget(null);
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "대시보드가 삭제되었습니다.",
|
||||
});
|
||||
toast({ title: "성공", description: "대시보드가 삭제되었습니다." });
|
||||
loadDashboards();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete dashboard:", err);
|
||||
setDeleteDialogOpen(false);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 삭제에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
toast({ title: "오류", description: "대시보드 삭제에 실패했습니다.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
// 대시보드 복사
|
||||
const handleCopy = async (dashboard: Dashboard) => {
|
||||
try {
|
||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||
|
||||
await dashboardApi.createDashboard({
|
||||
title: `${fullDashboard.title} (복사본)`,
|
||||
description: fullDashboard.description,
|
||||
|
|
@ -138,40 +123,85 @@ export default function DashboardListPage() {
|
|||
category: fullDashboard.category,
|
||||
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||
});
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "대시보드가 복사되었습니다.",
|
||||
});
|
||||
toast({ title: "성공", description: "대시보드가 복사되었습니다." });
|
||||
loadDashboards();
|
||||
} catch (err) {
|
||||
console.error("Failed to copy dashboard:", err);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
toast({ title: "오류", description: "대시보드 복사에 실패했습니다.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
// 포맷팅 헬퍼
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ko-KR", {
|
||||
const formatDate = (dateString: string) =>
|
||||
new Date(dateString).toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
// ResponsiveDataView 컬럼 정의
|
||||
const columns: RDVColumn<Dashboard>[] = [
|
||||
{
|
||||
key: "title",
|
||||
label: "제목",
|
||||
render: (_v, row) => (
|
||||
<button
|
||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${row.id}`)}
|
||||
className="hover:text-primary cursor-pointer text-left font-medium transition-colors hover:underline"
|
||||
>
|
||||
{row.title}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "설명",
|
||||
render: (_v, row) => (
|
||||
<span className="text-muted-foreground max-w-md truncate">{row.description || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdByName",
|
||||
label: "생성자",
|
||||
width: "120px",
|
||||
render: (_v, row) => row.createdByName || row.createdBy || "-",
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "생성일",
|
||||
width: "120px",
|
||||
render: (_v, row) => formatDate(row.createdAt),
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "수정일",
|
||||
width: "120px",
|
||||
render: (_v, row) => formatDate(row.updatedAt),
|
||||
},
|
||||
];
|
||||
|
||||
// 모바일 카드 필드 정의
|
||||
const cardFields: RDVCardField<Dashboard>[] = [
|
||||
{
|
||||
label: "설명",
|
||||
render: (d) => (
|
||||
<span className="max-w-[200px] truncate">{d.description || "-"}</span>
|
||||
),
|
||||
},
|
||||
{ label: "생성자", render: (d) => d.createdByName || d.createdBy || "-" },
|
||||
{ label: "생성일", render: (d) => formatDate(d.createdAt) },
|
||||
{ label: "수정일", render: (d) => formatDate(d.updatedAt) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-6">
|
||||
<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">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 액션 */}
|
||||
{/* 검색 및 액션 (반응형) */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
|
|
@ -183,7 +213,7 @@ export default function DashboardListPage() {
|
|||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<div className="text-muted-foreground hidden text-sm sm:block">
|
||||
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -194,71 +224,7 @@ export default function DashboardListPage() {
|
|||
</div>
|
||||
|
||||
{/* 대시보드 목록 */}
|
||||
{loading ? (
|
||||
<>
|
||||
{/* 데스크톱 테이블 스켈레톤 */}
|
||||
<div className="bg-card hidden shadow-sm lg:block">
|
||||
<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">생성자</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<TableRow key={index} className="border-b">
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 text-right">
|
||||
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일/태블릿 카드 스켈레톤 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : error ? (
|
||||
{error ? (
|
||||
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
|
|
@ -274,71 +240,39 @@ export default function DashboardListPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : dashboards.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||
<div className="bg-card hidden shadow-sm lg:block">
|
||||
<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">생성자</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboards.map((dashboard) => (
|
||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||
<TableCell className="h-16 text-sm font-medium">
|
||||
<button
|
||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||
>
|
||||
{dashboard.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{dashboard.createdByName || dashboard.createdBy || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 text-right">
|
||||
<ResponsiveDataView<Dashboard>
|
||||
data={dashboards}
|
||||
columns={columns}
|
||||
keyExtractor={(d) => d.id}
|
||||
isLoading={loading}
|
||||
emptyMessage="대시보드가 없습니다."
|
||||
skeletonCount={10}
|
||||
cardTitle={(d) => d.title}
|
||||
cardSubtitle={(d) => d.id}
|
||||
cardFields={cardFields}
|
||||
onRowClick={(d) => router.push(`/admin/screenMng/dashboardList/${d.id}`)}
|
||||
renderActions={(d) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${d.id}`)}
|
||||
className="gap-2 text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||
<DropdownMenuItem onClick={() => handleCopy(d)} className="gap-2 text-sm">
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||
onClick={() => handleDeleteClick(d.id, d.title)}
|
||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
|
@ -346,86 +280,10 @@ export default function DashboardListPage() {
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||
{dashboards.map((dashboard) => (
|
||||
<div
|
||||
key={dashboard.id}
|
||||
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<button
|
||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||
>
|
||||
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
||||
</button>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">설명</span>
|
||||
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">생성자</span>
|
||||
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">생성일</span>
|
||||
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">수정일</span>
|
||||
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 */}
|
||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
onClick={() => handleCopy(dashboard)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
|
||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
actionsLabel="작업"
|
||||
actionsWidth="80px"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
|
|
@ -453,6 +311,9 @@ export default function DashboardListPage() {
|
|||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export default function ReportDesignerPage() {
|
|||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ReportDesignerProvider reportId={reportId}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-gray-50">
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-muted">
|
||||
{/* 상단 툴바 */}
|
||||
<ReportDesignerToolbar />
|
||||
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ export default function ReportManagementPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">리포트 관리</h1>
|
||||
<p className="mt-2 text-gray-600">리포트를 생성하고 관리합니다</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">리포트 관리</h1>
|
||||
<p className="mt-2 text-muted-foreground">리포트를 생성하고 관리합니다</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateNew} className="gap-2">
|
||||
<Plus className="h-4 w-4" />새 리포트
|
||||
|
|
@ -45,7 +45,7 @@ export default function ReportManagementPage() {
|
|||
|
||||
{/* 검색 영역 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
검색
|
||||
|
|
@ -78,7 +78,7 @@ export default function ReportManagementPage() {
|
|||
|
||||
{/* 리포트 목록 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
📋 리포트 목록
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export default function EditWebTypePage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
|
|
@ -244,7 +244,7 @@ export default function EditWebTypePage() {
|
|||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name">
|
||||
웹타입명 <span className="text-red-500">*</span>
|
||||
웹타입명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="type_name"
|
||||
|
|
@ -267,7 +267,7 @@ export default function EditWebTypePage() {
|
|||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
카테고리 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
|
|
@ -286,7 +286,7 @@ export default function EditWebTypePage() {
|
|||
{/* 연결된 컴포넌트 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="component_name">
|
||||
연결된 컴포넌트 <span className="text-red-500">*</span>
|
||||
연결된 컴포넌트 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.component_name || "TextWidget"}
|
||||
|
|
@ -338,15 +338,15 @@ export default function EditWebTypePage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
{formData.config_panel && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm font-medium text-green-700">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-500"></div>
|
||||
<span className="text-sm font-medium text-emerald-700">
|
||||
현재 선택: {getConfigPanelInfo(formData.config_panel)?.label || formData.config_panel}
|
||||
</span>
|
||||
</div>
|
||||
{getConfigPanelInfo(formData.config_panel)?.description && (
|
||||
<p className="mt-1 ml-4 text-xs text-green-600">
|
||||
<p className="mt-1 ml-4 text-xs text-emerald-600">
|
||||
{getConfigPanelInfo(formData.config_panel)?.description}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -427,7 +427,7 @@ export default function EditWebTypePage() {
|
|||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
|
||||
{jsonErrors.default_config && <p className="text-xs text-destructive">{jsonErrors.default_config}</p>}
|
||||
</div>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
|
|
@ -441,7 +441,7 @@ export default function EditWebTypePage() {
|
|||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-destructive">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
|
|
@ -455,7 +455,7 @@ export default function EditWebTypePage() {
|
|||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
|
||||
{jsonErrors.default_style && <p className="text-xs text-destructive">{jsonErrors.default_style}</p>}
|
||||
</div>
|
||||
|
||||
{/* 입력 속성 */}
|
||||
|
|
@ -469,7 +469,7 @@ export default function EditWebTypePage() {
|
|||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
|
||||
{jsonErrors.input_properties && <p className="text-xs text-destructive">{jsonErrors.input_properties}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -498,8 +498,8 @@ export default function EditWebTypePage() {
|
|||
|
||||
{/* 에러 메시지 */}
|
||||
{updateError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
<div className="mt-4 rounded-md border border-destructive/20 bg-destructive/10 p-4">
|
||||
<p className="text-destructive">
|
||||
수정 중 오류가 발생했습니다: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export default function WebTypeDetailPage() {
|
|||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">웹타입 정보를 불러오는데 실패했습니다.</div>
|
||||
<div className="mb-2 text-lg text-destructive">웹타입 정보를 불러오는데 실패했습니다.</div>
|
||||
<Link href="/admin/standards">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
|
|
@ -80,7 +80,7 @@ export default function WebTypeDetailPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export default function NewWebTypePage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
|
|
@ -186,7 +186,7 @@ export default function NewWebTypePage() {
|
|||
{/* 웹타입 코드 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="web_type">
|
||||
웹타입 코드 <span className="text-red-500">*</span>
|
||||
웹타입 코드 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="web_type"
|
||||
|
|
@ -202,7 +202,7 @@ export default function NewWebTypePage() {
|
|||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name">
|
||||
웹타입명 <span className="text-red-500">*</span>
|
||||
웹타입명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="type_name"
|
||||
|
|
@ -225,7 +225,7 @@ export default function NewWebTypePage() {
|
|||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
카테고리 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
|
|
@ -244,7 +244,7 @@ export default function NewWebTypePage() {
|
|||
{/* 연결된 컴포넌트 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="component_name">
|
||||
연결된 컴포넌트 <span className="text-red-500">*</span>
|
||||
연결된 컴포넌트 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.component_name || "TextWidget"}
|
||||
|
|
@ -296,15 +296,15 @@ export default function NewWebTypePage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
{formData.config_panel && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm font-medium text-green-700">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-500"></div>
|
||||
<span className="text-sm font-medium text-emerald-700">
|
||||
현재 선택: {getConfigPanelInfo(formData.config_panel)?.label || formData.config_panel}
|
||||
</span>
|
||||
</div>
|
||||
{getConfigPanelInfo(formData.config_panel)?.description && (
|
||||
<p className="mt-1 ml-4 text-xs text-green-600">
|
||||
<p className="mt-1 ml-4 text-xs text-emerald-600">
|
||||
{getConfigPanelInfo(formData.config_panel)?.description}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -385,7 +385,7 @@ export default function NewWebTypePage() {
|
|||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
|
||||
{jsonErrors.default_config && <p className="text-xs text-destructive">{jsonErrors.default_config}</p>}
|
||||
</div>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
|
|
@ -399,7 +399,7 @@ export default function NewWebTypePage() {
|
|||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-destructive">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
|
|
@ -413,7 +413,7 @@ export default function NewWebTypePage() {
|
|||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
|
||||
{jsonErrors.default_style && <p className="text-xs text-destructive">{jsonErrors.default_style}</p>}
|
||||
</div>
|
||||
|
||||
{/* 입력 속성 */}
|
||||
|
|
@ -427,7 +427,7 @@ export default function NewWebTypePage() {
|
|||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
|
||||
{jsonErrors.input_properties && <p className="text-xs text-destructive">{jsonErrors.input_properties}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -448,8 +448,8 @@ export default function NewWebTypePage() {
|
|||
|
||||
{/* 에러 메시지 */}
|
||||
{createError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
<div className="mt-4 rounded-md border border-destructive/20 bg-destructive/10 p-4">
|
||||
<p className="text-destructive">
|
||||
생성 중 오류가 발생했습니다: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import React, { useState, useMemo } from "react";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -20,9 +18,11 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { Plus, Search, Edit, Trash2, Eye, Filter, RotateCcw, Settings, SortAsc, SortDesc } from "lucide-react";
|
||||
import { Plus, Search, Edit, Trash2, Eye, RotateCcw, SortAsc, SortDesc } from "lucide-react";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||
import type { WebTypeStandard } from "@/hooks/admin/useWebTypes";
|
||||
|
||||
export default function WebTypesManagePage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
|
@ -31,35 +31,29 @@ export default function WebTypesManagePage() {
|
|||
const [sortField, setSortField] = useState<string>("sort_order");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 웹타입 데이터 조회
|
||||
const { webTypes, isLoading, error, deleteWebType, isDeleting, deleteError, refetch } = useWebTypes({
|
||||
active: activeFilter === "all" ? undefined : activeFilter,
|
||||
search: searchTerm || undefined,
|
||||
category: categoryFilter === "all" ? undefined : categoryFilter,
|
||||
});
|
||||
|
||||
// 카테고리 목록 생성
|
||||
const categories = useMemo(() => {
|
||||
const uniqueCategories = Array.from(new Set(webTypes.map((wt) => wt.category).filter(Boolean)));
|
||||
return uniqueCategories.sort();
|
||||
}, [webTypes]);
|
||||
|
||||
// 필터링 및 정렬된 데이터
|
||||
const filteredAndSortedWebTypes = useMemo(() => {
|
||||
let filtered = [...webTypes];
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any = a[sortField as keyof typeof a];
|
||||
let bValue: any = b[sortField as keyof typeof b];
|
||||
|
||||
// 숫자 필드 처리
|
||||
if (sortField === "sort_order") {
|
||||
aValue = aValue || 0;
|
||||
bValue = bValue || 0;
|
||||
}
|
||||
|
||||
// 문자열 필드 처리
|
||||
if (typeof aValue === "string") {
|
||||
aValue = aValue.toLowerCase();
|
||||
}
|
||||
|
|
@ -75,17 +69,6 @@ export default function WebTypesManagePage() {
|
|||
return filtered;
|
||||
}, [webTypes, sortField, sortDirection]);
|
||||
|
||||
// 정렬 변경 핸들러
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async (webType: string, typeName: string) => {
|
||||
try {
|
||||
await deleteWebType(webType);
|
||||
|
|
@ -95,7 +78,6 @@ export default function WebTypesManagePage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const resetFilters = () => {
|
||||
setSearchTerm("");
|
||||
setCategoryFilter("all");
|
||||
|
|
@ -104,69 +86,156 @@ export default function WebTypesManagePage() {
|
|||
setSortDirection("asc");
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">웹타입 목록을 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-destructive">웹타입 목록을 불러오는데 실패했습니다.</div>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
다시 시도
|
||||
// 삭제 AlertDialog 렌더 헬퍼
|
||||
const renderDeleteDialog = (wt: WebTypeStandard) => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>웹타입 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{wt.type_name}' 웹타입을 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(wt.web_type, wt.type_name)}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: RDVColumn<WebTypeStandard>[] = [
|
||||
{
|
||||
key: "sort_order",
|
||||
label: "순서",
|
||||
width: "80px",
|
||||
render: (_val, wt) => <span className="font-mono">{wt.sort_order || 0}</span>,
|
||||
},
|
||||
{
|
||||
key: "web_type",
|
||||
label: "웹타입 코드",
|
||||
hideOnMobile: true,
|
||||
render: (_val, wt) => <span className="font-mono">{wt.web_type}</span>,
|
||||
},
|
||||
{
|
||||
key: "type_name",
|
||||
label: "웹타입명",
|
||||
render: (_val, wt) => (
|
||||
<div>
|
||||
<div className="font-medium">{wt.type_name}</div>
|
||||
{wt.type_name_eng && (
|
||||
<div className="text-xs text-muted-foreground">{wt.type_name_eng}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
label: "카테고리",
|
||||
render: (_val, wt) => <Badge variant="secondary">{wt.category}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "설명",
|
||||
hideOnMobile: true,
|
||||
render: (_val, wt) => (
|
||||
<span className="max-w-xs truncate">{wt.description || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "상태",
|
||||
render: (_val, wt) => (
|
||||
<Badge variant={wt.is_active === "Y" ? "default" : "secondary"}>
|
||||
{wt.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated_date",
|
||||
label: "최종 수정일",
|
||||
hideOnMobile: true,
|
||||
render: (_val, wt) => (
|
||||
<span className="text-muted-foreground">
|
||||
{wt.updated_date ? new Date(wt.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const cardFields: RDVCardField<WebTypeStandard>[] = [
|
||||
{
|
||||
label: "카테고리",
|
||||
render: (wt) => <Badge variant="secondary">{wt.category}</Badge>,
|
||||
},
|
||||
{
|
||||
label: "순서",
|
||||
render: (wt) => String(wt.sort_order || 0),
|
||||
},
|
||||
{
|
||||
label: "설명",
|
||||
render: (wt) => wt.description || "-",
|
||||
hideEmpty: true,
|
||||
},
|
||||
{
|
||||
label: "수정일",
|
||||
render: (wt) =>
|
||||
wt.updated_date ? new Date(wt.updated_date).toLocaleDateString("ko-KR") : "-",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-background rounded-lg shadow-sm border p-6">
|
||||
<div className="space-y-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">웹타입 관리</h1>
|
||||
<p className="mt-2 text-muted-foreground">화면관리에서 사용할 웹타입들을 관리합니다</p>
|
||||
<h1 className="text-2xl font-bold">웹타입 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">화면관리에서 사용할 웹타입들을 관리합니다</p>
|
||||
</div>
|
||||
<Link href="/admin/standards/new">
|
||||
<Button className="shadow-sm">
|
||||
<Button className="w-full sm:w-auto">
|
||||
<Plus className="mr-2 h-4 w-4" />새 웹타입 추가
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Filter className="h-5 w-5 text-muted-foreground" />
|
||||
필터 및 검색
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
{/* 에러 상태 */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="text-sm font-semibold text-destructive">웹타입 목록을 불러오는데 실패했습니다.</p>
|
||||
<Button onClick={() => refetch()} variant="outline" size="sm" className="mt-2">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 툴바 */}
|
||||
<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="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={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -179,9 +248,8 @@ export default function WebTypesManagePage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 활성화 상태 필터 */}
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -191,180 +259,93 @@ export default function WebTypesManagePage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 초기화 버튼 */}
|
||||
<Button variant="outline" onClick={resetFilters}>
|
||||
{/* 정렬 선택 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={sortField} onValueChange={(v) => { setSortField(v); }}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[140px]">
|
||||
<SelectValue placeholder="정렬 기준" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sort_order">순서</SelectItem>
|
||||
<SelectItem value="web_type">웹타입 코드</SelectItem>
|
||||
<SelectItem value="type_name">웹타입명</SelectItem>
|
||||
<SelectItem value="category">카테고리</SelectItem>
|
||||
<SelectItem value="is_active">상태</SelectItem>
|
||||
<SelectItem value="updated_date">수정일</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSortDirection(sortDirection === "asc" ? "desc" : "asc")}
|
||||
className="h-10 w-10 shrink-0"
|
||||
title={sortDirection === "asc" ? "오름차순" : "내림차순"}
|
||||
>
|
||||
{sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={resetFilters} className="h-10 w-full sm:w-auto">
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 결과 통계 */}
|
||||
<div className="bg-background rounded-lg border px-4 py-3">
|
||||
<p className="text-foreground text-sm font-medium">총 {filteredAndSortedWebTypes.length}개의 웹타입이 있습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 웹타입 목록 테이블 */}
|
||||
<div className="bg-card shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-background">
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("sort_order")}>
|
||||
<div className="flex items-center gap-2">
|
||||
순서
|
||||
{sortField === "sort_order" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("web_type")}>
|
||||
<div className="flex items-center gap-2">
|
||||
웹타입 코드
|
||||
{sortField === "web_type" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("type_name")}>
|
||||
<div className="flex items-center gap-2">
|
||||
웹타입명
|
||||
{sortField === "type_name" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("category")}>
|
||||
<div className="flex items-center gap-2">
|
||||
카테고리
|
||||
{sortField === "category" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</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 cursor-pointer hover:bg-muted/50" onClick={() => handleSort("component_name")}>
|
||||
<div className="flex items-center gap-2">
|
||||
연결된 컴포넌트
|
||||
{sortField === "component_name" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("config_panel")}>
|
||||
<div className="flex items-center gap-2">
|
||||
설정 패널
|
||||
{sortField === "config_panel" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("is_active")}>
|
||||
<div className="flex items-center gap-2">
|
||||
상태
|
||||
{sortField === "is_active" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("updated_date")}>
|
||||
<div className="flex items-center gap-2">
|
||||
최종 수정일
|
||||
{sortField === "updated_date" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedWebTypes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="py-8 text-center">
|
||||
조건에 맞는 웹타입이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredAndSortedWebTypes.map((webType) => (
|
||||
<TableRow key={webType.web_type} className="bg-background transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{webType.sort_order || 0}</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{webType.web_type}</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 font-medium text-sm">
|
||||
{webType.type_name}
|
||||
{webType.type_name_eng && (
|
||||
<div className="text-muted-foreground text-xs">{webType.type_name_eng}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant="secondary">{webType.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 max-w-xs truncate text-sm">{webType.description || "-"}</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{webType.component_name || "TextWidget"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{webType.config_panel === "none" || !webType.config_panel ? "기본 설정" : webType.config_panel}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant={webType.is_active === "Y" ? "default" : "secondary"}>
|
||||
{webType.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-muted-foreground text-sm">
|
||||
{webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/standards/${webType.web_type}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/standards/${webType.web_type}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>웹타입 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{webType.type_name}' 웹타입을 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(webType.web_type, webType.type_name)}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/* 결과 수 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredAndSortedWebTypes.length}</span>개의 웹타입
|
||||
</div>
|
||||
|
||||
{/* 삭제 에러 */}
|
||||
{deleteError && (
|
||||
<div className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 p-4">
|
||||
<p className="text-destructive">
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResponsiveDataView<WebTypeStandard>
|
||||
data={filteredAndSortedWebTypes}
|
||||
columns={columns}
|
||||
keyExtractor={(wt) => wt.web_type}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="조건에 맞는 웹타입이 없습니다."
|
||||
skeletonCount={6}
|
||||
cardTitle={(wt) => wt.type_name}
|
||||
cardSubtitle={(wt) => (
|
||||
<>
|
||||
{wt.type_name_eng && (
|
||||
<span className="text-xs text-muted-foreground">{wt.type_name_eng} / </span>
|
||||
)}
|
||||
<span className="font-mono text-xs text-muted-foreground">{wt.web_type}</span>
|
||||
</>
|
||||
)}
|
||||
cardHeaderRight={(wt) => (
|
||||
<Badge variant={wt.is_active === "Y" ? "default" : "secondary"} className="shrink-0">
|
||||
{wt.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
)}
|
||||
cardFields={cardFields}
|
||||
actionsLabel="작업"
|
||||
actionsWidth="140px"
|
||||
renderActions={(wt) => (
|
||||
<>
|
||||
<Link href={`/admin/standards/${wt.web_type}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/standards/${wt.web_type}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{renderDeleteDialog(wt)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,504 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Plus, Pencil, Trash2, Search, RefreshCw } from "lucide-react";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import {
|
||||
SystemNotice,
|
||||
CreateSystemNoticePayload,
|
||||
getSystemNotices,
|
||||
createSystemNotice,
|
||||
updateSystemNotice,
|
||||
deleteSystemNotice,
|
||||
} from "@/lib/api/systemNotice";
|
||||
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||
|
||||
function getPriorityLabel(priority: number): { label: string; variant: "default" | "secondary" | "destructive" | "outline" } {
|
||||
if (priority >= 3) return { label: "높음", variant: "destructive" };
|
||||
if (priority === 2) return { label: "보통", variant: "default" };
|
||||
return { label: "낮음", variant: "secondary" };
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return "-";
|
||||
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const EMPTY_FORM: CreateSystemNoticePayload = {
|
||||
title: "",
|
||||
content: "",
|
||||
is_active: true,
|
||||
priority: 1,
|
||||
};
|
||||
|
||||
export default function SystemNoticesPage() {
|
||||
const [notices, setNotices] = useState<SystemNotice[]>([]);
|
||||
const [filteredNotices, setFilteredNotices] = useState<SystemNotice[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<SystemNotice | null>(null);
|
||||
const [formData, setFormData] = useState<CreateSystemNoticePayload>(EMPTY_FORM);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<SystemNotice | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const loadNotices = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMsg(null);
|
||||
const result = await getSystemNotices();
|
||||
if (result.success && result.data) {
|
||||
setNotices(result.data);
|
||||
} else {
|
||||
setErrorMsg(result.message || "공지사항 목록을 불러오는 데 실패했습니다.");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotices();
|
||||
}, [loadNotices]);
|
||||
|
||||
useEffect(() => {
|
||||
let result = [...notices];
|
||||
|
||||
if (statusFilter !== "all") {
|
||||
const isActive = statusFilter === "active";
|
||||
result = result.filter((n) => n.is_active === isActive);
|
||||
}
|
||||
|
||||
if (searchText.trim()) {
|
||||
const keyword = searchText.toLowerCase();
|
||||
result = result.filter(
|
||||
(n) =>
|
||||
n.title.toLowerCase().includes(keyword) ||
|
||||
n.content.toLowerCase().includes(keyword)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredNotices(result);
|
||||
}, [notices, searchText, statusFilter]);
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditTarget(null);
|
||||
setFormData(EMPTY_FORM);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (notice: SystemNotice) => {
|
||||
setEditTarget(notice);
|
||||
setFormData({
|
||||
title: notice.title,
|
||||
content: notice.content,
|
||||
is_active: notice.is_active,
|
||||
priority: notice.priority,
|
||||
});
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.title.trim()) {
|
||||
alert("제목을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!formData.content.trim()) {
|
||||
alert("내용을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
let result;
|
||||
|
||||
if (editTarget) {
|
||||
result = await updateSystemNotice(editTarget.id, formData);
|
||||
} else {
|
||||
result = await createSystemNotice(formData);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
setIsFormOpen(false);
|
||||
await loadNotices();
|
||||
} else {
|
||||
alert(result.message || "저장에 실패했습니다.");
|
||||
}
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
const result = await deleteSystemNotice(deleteTarget.id);
|
||||
if (result.success) {
|
||||
setDeleteTarget(null);
|
||||
await loadNotices();
|
||||
} else {
|
||||
alert(result.message || "삭제에 실패했습니다.");
|
||||
}
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
const columns: RDVColumn<SystemNotice>[] = [
|
||||
{
|
||||
key: "title",
|
||||
label: "제목",
|
||||
render: (_val, notice) => (
|
||||
<span className="font-medium">{notice.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "상태",
|
||||
width: "100px",
|
||||
render: (_val, notice) => (
|
||||
<Badge variant={notice.is_active ? "default" : "secondary"}>
|
||||
{notice.is_active ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "priority",
|
||||
label: "우선순위",
|
||||
width: "100px",
|
||||
render: (_val, notice) => {
|
||||
const p = getPriorityLabel(notice.priority);
|
||||
return <Badge variant={p.variant}>{p.label}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "created_by",
|
||||
label: "작성자",
|
||||
width: "120px",
|
||||
hideOnMobile: true,
|
||||
render: (_val, notice) => (
|
||||
<span className="text-muted-foreground">{notice.created_by || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
label: "작성일",
|
||||
width: "120px",
|
||||
hideOnMobile: true,
|
||||
render: (_val, notice) => (
|
||||
<span className="text-muted-foreground">{formatDate(notice.created_at)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const cardFields: RDVCardField<SystemNotice>[] = [
|
||||
{
|
||||
label: "작성자",
|
||||
render: (notice) => notice.created_by || "-",
|
||||
},
|
||||
{
|
||||
label: "작성일",
|
||||
render: (notice) => formatDate(notice.created_at),
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-destructive">오류가 발생했습니다</p>
|
||||
<button
|
||||
onClick={() => setErrorMsg(null)}
|
||||
className="text-destructive transition-colors hover:text-destructive/80"
|
||||
aria-label="에러 메시지 닫기"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm text-destructive/80">{errorMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 툴바 */}
|
||||
<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-[160px]">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="상태 필터" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="active">활성</SelectItem>
|
||||
<SelectItem value="inactive">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<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={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredNotices.length}</span> 건
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={loadNotices}
|
||||
aria-label="새로고침"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button className="h-10 gap-2 text-sm font-medium" onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResponsiveDataView<SystemNotice>
|
||||
data={filteredNotices}
|
||||
columns={columns}
|
||||
keyExtractor={(n) => String(n.id)}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="공지사항이 없습니다."
|
||||
skeletonCount={5}
|
||||
cardTitle={(n) => n.title}
|
||||
cardHeaderRight={(n) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleOpenEdit(n)}
|
||||
aria-label="수정"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(n)}
|
||||
aria-label="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
cardSubtitle={(n) => {
|
||||
const p = getPriorityLabel(n.priority);
|
||||
return (
|
||||
<span className="flex flex-wrap gap-2 pt-1">
|
||||
<Badge variant={n.is_active ? "default" : "secondary"}>
|
||||
{n.is_active ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
<Badge variant={p.variant}>{p.label}</Badge>
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
cardFields={cardFields}
|
||||
actionsLabel="관리"
|
||||
actionsWidth="120px"
|
||||
renderActions={(notice) => (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleOpenEdit(notice)}
|
||||
aria-label="수정"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(notice)}
|
||||
aria-label="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[540px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{editTarget ? "공지사항 수정" : "공지사항 등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{editTarget ? "공지사항 내용을 수정합니다." : "새로운 공지사항을 등록합니다."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="notice-title" className="text-xs sm:text-sm">
|
||||
제목 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="notice-title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="공지사항 제목을 입력하세요"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="notice-content" className="text-xs sm:text-sm">
|
||||
내용 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notice-content"
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, content: e.target.value }))}
|
||||
placeholder="공지사항 내용을 입력하세요"
|
||||
className="mt-1 min-h-[120px] text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="notice-priority" className="text-xs sm:text-sm">
|
||||
우선순위
|
||||
</Label>
|
||||
<Select
|
||||
value={String(formData.priority)}
|
||||
onValueChange={(val) =>
|
||||
setFormData((prev) => ({ ...prev, priority: Number(val) }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="notice-priority" className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="우선순위 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">낮음</SelectItem>
|
||||
<SelectItem value="2">보통</SelectItem>
|
||||
<SelectItem value="3">높음</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="notice-active"
|
||||
checked={formData.is_active}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData((prev) => ({ ...prev, is_active: !!checked }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="notice-active" className="cursor-pointer text-xs sm:text-sm">
|
||||
활성화 (체크 시 공지사항이 사용자에게 표시됩니다)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsFormOpen(false)}
|
||||
disabled={isSaving}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">공지사항 삭제</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
아래 공지사항을 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
<br />
|
||||
<span className="mt-2 block font-medium text-foreground">
|
||||
"{deleteTarget?.title}"
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={isDeleting}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -142,28 +142,28 @@ export default function CollectionManagementPage() {
|
|||
|
||||
const getStatusBadge = (isActive: string) => {
|
||||
return isActive === "Y" ? (
|
||||
<Badge className="bg-green-100 text-green-800">활성</Badge>
|
||||
<Badge className="bg-emerald-100 text-emerald-800">활성</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-100 text-red-800">비활성</Badge>
|
||||
<Badge className="bg-destructive/10 text-red-800">비활성</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const option = collectionTypeOptions.find(opt => opt.value === type);
|
||||
const colors = {
|
||||
full: "bg-blue-100 text-blue-800",
|
||||
full: "bg-primary/10 text-primary",
|
||||
incremental: "bg-purple-100 text-purple-800",
|
||||
delta: "bg-orange-100 text-orange-800",
|
||||
delta: "bg-amber-100 text-orange-800",
|
||||
};
|
||||
return (
|
||||
<Badge className={colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800"}>
|
||||
<Badge className={colors[type as keyof typeof colors] || "bg-muted text-foreground"}>
|
||||
{option?.label || type}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ export default function DataFlowEditPage() {
|
|||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
<p className="text-gray-500">관계도 정보를 불러오는 중...</p>
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
<p className="text-muted-foreground">관계도 정보를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -68,16 +68,16 @@ export default function DataFlowEditPage() {
|
|||
<span>목록으로</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">📊 관계도 편집</h1>
|
||||
<p className="mt-1 text-gray-600">
|
||||
<span className="font-medium text-blue-600">{diagramName}</span> 관계도를 편집하고 있습니다
|
||||
<h1 className="text-2xl font-bold text-foreground">📊 관계도 편집</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
<span className="font-medium text-primary">{diagramName}</span> 관계도를 편집하고 있습니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터플로우 디자이너 */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white">
|
||||
<div className="rounded-lg border border-border bg-white">
|
||||
<DataFlowDesigner
|
||||
key={diagramId}
|
||||
selectedDiagram={diagramName}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ export default function NodeEditorPage() {
|
|||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-gray-500">제어 관리 페이지로 이동중...</div>
|
||||
<div className="flex h-screen items-center justify-center bg-muted">
|
||||
<div className="text-muted-foreground">제어 관리 페이지로 이동중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -528,14 +528,14 @@ export default function I18nPage() {
|
|||
? "공통"
|
||||
: companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
|
||||
|
||||
return <span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{companyName}</span>;
|
||||
return <span className={row.original.isActive === "N" ? "text-muted-foreground/70" : ""}>{companyName}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "menuName",
|
||||
header: "메뉴명",
|
||||
cell: ({ row }: any) => (
|
||||
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.menuName}</span>
|
||||
<span className={row.original.isActive === "N" ? "text-muted-foreground/70" : ""}>{row.original.menuName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -543,8 +543,8 @@ export default function I18nPage() {
|
|||
header: "언어 키",
|
||||
cell: ({ row }: any) => (
|
||||
<div
|
||||
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
|
||||
row.original.isActive === "N" ? "text-gray-400" : ""
|
||||
className={`cursor-pointer rounded p-1 hover:bg-muted ${
|
||||
row.original.isActive === "N" ? "text-muted-foreground/70" : ""
|
||||
}`}
|
||||
onDoubleClick={() => handleEditKey(row.original)}
|
||||
>
|
||||
|
|
@ -556,7 +556,7 @@ export default function I18nPage() {
|
|||
accessorKey: "description",
|
||||
header: "설명",
|
||||
cell: ({ row }: any) => (
|
||||
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.description}</span>
|
||||
<span className={row.original.isActive === "N" ? "text-muted-foreground/70" : ""}>{row.original.description}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -567,8 +567,8 @@ export default function I18nPage() {
|
|||
onClick={() => handleToggleStatus(row.original.keyId)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
row.original.isActive === "Y"
|
||||
? "bg-green-100 text-green-800 hover:bg-green-200"
|
||||
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
||||
? "bg-emerald-100 text-emerald-800 hover:bg-emerald-200"
|
||||
: "bg-muted text-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{row.original.isActive === "Y" ? "활성" : "비활성"}
|
||||
|
|
@ -605,8 +605,8 @@ export default function I18nPage() {
|
|||
header: "언어 코드",
|
||||
cell: ({ row }: any) => (
|
||||
<div
|
||||
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
|
||||
row.original.isActive === "N" ? "text-gray-400" : ""
|
||||
className={`cursor-pointer rounded p-1 hover:bg-muted ${
|
||||
row.original.isActive === "N" ? "text-muted-foreground/70" : ""
|
||||
}`}
|
||||
onDoubleClick={() => handleEditLanguage(row.original)}
|
||||
>
|
||||
|
|
@ -618,14 +618,14 @@ export default function I18nPage() {
|
|||
accessorKey: "langName",
|
||||
header: "언어명 (영문)",
|
||||
cell: ({ row }: any) => (
|
||||
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langName}</span>
|
||||
<span className={row.original.isActive === "N" ? "text-muted-foreground/70" : ""}>{row.original.langName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "langNative",
|
||||
header: "언어명 (원어)",
|
||||
cell: ({ row }: any) => (
|
||||
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langNative}</span>
|
||||
<span className={row.original.isActive === "N" ? "text-muted-foreground/70" : ""}>{row.original.langNative}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -636,8 +636,8 @@ export default function I18nPage() {
|
|||
onClick={() => handleToggleLanguageStatus(row.original.langCode)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
row.original.isActive === "Y"
|
||||
? "bg-green-100 text-green-800 hover:bg-green-200"
|
||||
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
||||
? "bg-emerald-100 text-emerald-800 hover:bg-emerald-200"
|
||||
: "bg-muted text-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{row.original.isActive === "Y" ? "활성" : "비활성"}
|
||||
|
|
@ -651,7 +651,7 @@ export default function I18nPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none px-4 py-8">
|
||||
<div className="container mx-auto p-2">
|
||||
{/* 탭 네비게이션 */}
|
||||
|
|
@ -659,7 +659,7 @@ export default function I18nPage() {
|
|||
<button
|
||||
onClick={() => setActiveTab("keys")}
|
||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
activeTab === "keys" ? "bg-accent0 text-white" : "bg-muted text-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
다국어 키 관리
|
||||
|
|
@ -667,7 +667,7 @@ export default function I18nPage() {
|
|||
<button
|
||||
onClick={() => setActiveTab("languages")}
|
||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
activeTab === "languages" ? "bg-accent0 text-white" : "bg-muted text-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
언어 관리
|
||||
|
|
@ -854,7 +854,7 @@ export default function I18nPage() {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg font-medium">언어 키를 선택하세요</div>
|
||||
<div className="text-sm">좌측 목록에서 편집할 언어 키를 클릭하세요</div>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
ChevronsUpDown,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -1409,9 +1410,8 @@ export default function TableManagementPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-1 gap-6 overflow-hidden">
|
||||
{/* 좌측 사이드바: 테이블 목록 (20%) */}
|
||||
<div className="flex h-full w-[20%] flex-col border-r pr-4">
|
||||
<ResponsiveSplitPanel
|
||||
left={
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
{/* 검색 */}
|
||||
<div className="flex-shrink-0">
|
||||
|
|
@ -1530,10 +1530,8 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
|
||||
<div className="flex h-full w-[80%] flex-col overflow-hidden pl-0">
|
||||
}
|
||||
right={
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{!selectedTable ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border">
|
||||
|
|
@ -1878,7 +1876,7 @@ export default function TableManagementPage() {
|
|||
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
|
||||
<CommandItem
|
||||
key={refCol.columnName}
|
||||
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
|
||||
value={`${refCol.displayName || ""} ${refCol.columnName}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
|
|
@ -1905,9 +1903,9 @@ export default function TableManagementPage() {
|
|||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{refCol.columnName}</span>
|
||||
{refCol.columnLabel && (
|
||||
{refCol.displayName && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{refCol.columnLabel}
|
||||
{refCol.displayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -2006,8 +2004,14 @@ export default function TableManagementPage() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
leftTitle="테이블 목록"
|
||||
leftWidth={20}
|
||||
minLeftWidth={10}
|
||||
maxLeftWidth={35}
|
||||
height="100%"
|
||||
className="flex-1 overflow-hidden"
|
||||
/>
|
||||
|
||||
{/* DDL 모달 컴포넌트들 */}
|
||||
{isSuperAdmin && (
|
||||
|
|
|
|||
|
|
@ -120,12 +120,12 @@ export default function TemplatesManagePage() {
|
|||
|
||||
// 간단한 아이콘 매핑 (실제로는 더 복잡한 시스템 필요)
|
||||
const iconMap: Record<string, JSX.Element> = {
|
||||
table: <div className="h-4 w-4 border border-gray-400" />,
|
||||
"mouse-pointer": <div className="h-4 w-4 rounded bg-blue-500" />,
|
||||
upload: <div className="h-4 w-4 border-2 border-dashed border-gray-400" />,
|
||||
table: <div className="h-4 w-4 border border-input" />,
|
||||
"mouse-pointer": <div className="h-4 w-4 rounded bg-primary" />,
|
||||
upload: <div className="h-4 w-4 border-2 border-dashed border-input" />,
|
||||
};
|
||||
|
||||
return iconMap[iconName] || <div className="h-4 w-4 rounded bg-gray-300" />;
|
||||
return iconMap[iconName] || <div className="h-4 w-4 rounded bg-muted/60" />;
|
||||
};
|
||||
|
||||
if (error) {
|
||||
|
|
@ -133,7 +133,7 @@ export default function TemplatesManagePage() {
|
|||
<div className="w-full max-w-none px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
<p className="mb-4 text-red-600">템플릿 목록을 불러오는 중 오류가 발생했습니다.</p>
|
||||
<p className="mb-4 text-destructive">템플릿 목록을 불러오는 중 오류가 발생했습니다.</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
다시 시도
|
||||
|
|
@ -145,13 +145,13 @@ export default function TemplatesManagePage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">템플릿 관리</h1>
|
||||
<p className="mt-2 text-gray-600">화면 디자이너에서 사용할 템플릿을 관리합니다</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">템플릿 관리</h1>
|
||||
<p className="mt-2 text-muted-foreground">화면 디자이너에서 사용할 템플릿을 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button asChild className="shadow-sm">
|
||||
|
|
@ -164,9 +164,9 @@ export default function TemplatesManagePage() {
|
|||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="flex items-center">
|
||||
<Filter className="mr-2 h-5 w-5 text-gray-600" />
|
||||
<Filter className="mr-2 h-5 w-5 text-muted-foreground" />
|
||||
필터 및 검색
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -176,7 +176,7 @@ export default function TemplatesManagePage() {
|
|||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">검색</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
||||
<Input
|
||||
placeholder="템플릿명, 설명 검색..."
|
||||
value={searchTerm}
|
||||
|
|
@ -232,7 +232,7 @@ export default function TemplatesManagePage() {
|
|||
|
||||
{/* 템플릿 목록 테이블 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle>템플릿 목록 ({filteredAndSortedTemplates.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -293,7 +293,7 @@ export default function TemplatesManagePage() {
|
|||
</TableRow>
|
||||
) : filteredAndSortedTemplates.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center text-gray-500">
|
||||
<TableCell colSpan={11} className="py-8 text-center text-muted-foreground">
|
||||
검색 조건에 맞는 템플릿이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -357,7 +357,7 @@ export default function TemplatesManagePage() {
|
|||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-red-600 hover:text-red-700">
|
||||
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
|
@ -373,7 +373,7 @@ export default function TemplatesManagePage() {
|
|||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(template.template_code, template.template_name)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
className="bg-destructive hover:bg-red-700"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
삭제
|
||||
|
|
|
|||
|
|
@ -25,27 +25,27 @@ export default function TestPage() {
|
|||
<h1 className="mb-4 text-2xl font-bold">토큰 테스트 페이지</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded bg-green-100 p-4">
|
||||
<div className="rounded bg-emerald-100 p-4">
|
||||
<h2 className="mb-2 font-semibold">토큰 상태</h2>
|
||||
<p>토큰 존재: {tokenInfo.hasToken ? "✅ 예" : "❌ 아니오"}</p>
|
||||
<p>토큰 길이: {tokenInfo.tokenLength}</p>
|
||||
<p>토큰 시작: {tokenInfo.tokenStart}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded bg-blue-100 p-4">
|
||||
<div className="rounded bg-primary/10 p-4">
|
||||
<h2 className="mb-2 font-semibold">페이지 정보</h2>
|
||||
<p>현재 URL: {tokenInfo.currentUrl}</p>
|
||||
<p>시간: {tokenInfo.timestamp}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded bg-yellow-100 p-4">
|
||||
<div className="rounded bg-amber-100 p-4">
|
||||
<h2 className="mb-2 font-semibold">테스트 버튼</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
const token = localStorage.getItem("authToken");
|
||||
alert(`토큰: ${token ? "존재" : "없음"}\n길이: ${token ? token.length : 0}`);
|
||||
}}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
className="rounded bg-primary px-4 py-2 text-white hover:bg-primary"
|
||||
>
|
||||
토큰 확인
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -31,12 +31,12 @@ export default function TokenTestPage() {
|
|||
<h1 className="mb-4 text-2xl font-bold">토큰 상태 테스트</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded bg-gray-100 p-4">
|
||||
<div className="rounded bg-muted p-4">
|
||||
<h2 className="mb-2 font-semibold">토큰 정보</h2>
|
||||
<pre className="text-sm">{JSON.stringify(tokenInfo, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div className="rounded bg-blue-100 p-4">
|
||||
<div className="rounded bg-primary/10 p-4">
|
||||
<h2 className="mb-2 font-semibold">테스트 버튼</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -44,7 +44,7 @@ export default function TokenTestPage() {
|
|||
console.log("현재 토큰:", token);
|
||||
alert(`토큰: ${token ? "존재" : "없음"}`);
|
||||
}}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
className="rounded bg-primary px-4 py-2 text-white hover:bg-primary"
|
||||
>
|
||||
토큰 확인
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
|||
</div>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
||||
roleGroup.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||
roleGroup.status === "active" ? "bg-emerald-100 text-emerald-800" : "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{roleGroup.status === "active" ? "활성" : "비활성"}
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ export default function RolesPage() {
|
|||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||
role.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||
role.status === "active" ? "bg-emerald-100 text-emerald-800" : "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{role.status === "active" ? "활성" : "비활성"}
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ export default function ValidationDemoPage() {
|
|||
<CardDescription>실시간 검증이 적용된 폼입니다. 입력하면서 검증 결과를 확인해보세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative min-h-[400px] rounded-lg border border-dashed border-gray-300 p-4">
|
||||
<div className="relative min-h-[400px] rounded-lg border border-dashed border-input p-4">
|
||||
<EnhancedInteractiveScreenViewer
|
||||
component={TEST_COMPONENTS[0]} // container
|
||||
allComponents={TEST_COMPONENTS}
|
||||
|
|
@ -485,7 +485,7 @@ export default function ValidationDemoPage() {
|
|||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">폼 데이터</h4>
|
||||
<pre className="max-h-60 overflow-auto rounded-md bg-gray-100 p-3 text-sm">
|
||||
<pre className="max-h-60 overflow-auto rounded-md bg-muted p-3 text-sm">
|
||||
{JSON.stringify(formData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
@ -493,15 +493,15 @@ export default function ValidationDemoPage() {
|
|||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">검증 통계</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-md bg-green-50 p-3">
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
<div className="rounded-md bg-emerald-50 p-3">
|
||||
<div className="text-lg font-bold text-emerald-600">
|
||||
{Object.values(validationState.fieldStates).filter((f) => f.status === "valid").length}
|
||||
</div>
|
||||
<div className="text-sm text-green-700">유효한 필드</div>
|
||||
<div className="text-sm text-emerald-700">유효한 필드</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-red-50 p-3">
|
||||
<div className="text-lg font-bold text-red-600">{validationState.errors.length}</div>
|
||||
<div className="text-sm text-red-700">오류 개수</div>
|
||||
<div className="rounded-md bg-destructive/10 p-3">
|
||||
<div className="text-lg font-bold text-destructive">{validationState.errors.length}</div>
|
||||
<div className="text-sm text-destructive">오류 개수</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const statusConfig: Record<string, { label: string; variant: "default" | "second
|
|||
const lineStatusConfig: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||
pending: { label: "진행 중", icon: <Clock className="h-3 w-3 text-primary" /> },
|
||||
approved: { label: "승인", icon: <CheckCircle2 className="h-3 w-3 text-green-600" /> },
|
||||
approved: { label: "승인", icon: <CheckCircle2 className="h-3 w-3 text-emerald-600" /> },
|
||||
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
|
||||
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -107,18 +107,18 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
return (
|
||||
<div className="h-screen">
|
||||
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */}
|
||||
{/* <div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
{/* <div className="border-b border-border bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">{dashboard.title}</h1>
|
||||
{dashboard.description && <p className="mt-1 text-sm text-gray-600">{dashboard.description}</p>}
|
||||
<h1 className="text-2xl font-bold text-foreground">{dashboard.title}</h1>
|
||||
{dashboard.description && <p className="mt-1 text-sm text-muted-foreground">{dashboard.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 새로고침 버튼 *\/}
|
||||
<button
|
||||
onClick={loadDashboard}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
|
||||
className="rounded-lg border border-input px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
|
|
@ -133,7 +133,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
|
||||
className="rounded-lg border border-input px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="전체화면"
|
||||
>
|
||||
⛶
|
||||
|
|
@ -144,7 +144,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
onClick={() => {
|
||||
router.push(`/admin/screenMng/dashboardList?load=${resolvedParams.dashboardId}`);
|
||||
}}
|
||||
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
className="rounded-lg bg-primary px-4 py-2 text-white hover:bg-primary"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
|
|
@ -152,7 +152,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
</div>
|
||||
|
||||
{/* 메타 정보 *\/}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>생성: {new Date(dashboard.createdAt).toLocaleString()}</span>
|
||||
<span>수정: {new Date(dashboard.updatedAt).toLocaleString()}</span>
|
||||
<span>요소: {dashboard.elements.length}개</span>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { FileCheck, Menu, Users, Bell, FileText, Layout, Server, Shield, Calendar } from "lucide-react";
|
||||
|
||||
const quickAccessItems = [
|
||||
{ label: "결재함", icon: FileCheck, href: "/admin/approvalBox", color: "text-primary bg-primary/10" },
|
||||
{ label: "메뉴 관리", icon: Menu, href: "/admin/menu", color: "text-violet-600 bg-violet-50" },
|
||||
{ label: "사용자 관리", icon: Users, href: "/admin/userMng", color: "text-emerald-600 bg-emerald-50" },
|
||||
{ label: "공지사항", icon: Bell, href: "/admin/system-notices", color: "text-amber-600 bg-amber-50" },
|
||||
{ label: "감사 로그", icon: FileText, href: "/admin/audit-log", color: "text-rose-600 bg-rose-50" },
|
||||
{ label: "화면 관리", icon: Layout, href: "/admin/screenMng", color: "text-cyan-600 bg-cyan-50" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 메인 페이지 컴포넌트
|
||||
* 대시보드 내용만 포함
|
||||
*/
|
||||
export default function MainPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
|
||||
const userName = user?.userName || "사용자";
|
||||
const today = new Date();
|
||||
const dateStr = today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric", weekday: "long" });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* Welcome Message */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-6 text-center">
|
||||
<h3 className="text-lg font-semibold">Vexplor에 오신 것을 환영합니다!</h3>
|
||||
<p className="text-muted-foreground">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
||||
<div className="flex justify-center space-x-2">
|
||||
<Badge variant="secondary">Node.js</Badge>
|
||||
<Badge variant="secondary">Next.js</Badge>
|
||||
<Badge variant="secondary">Shadcn/ui</Badge>
|
||||
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{userName}님, 좋은 하루 되세요
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{dateStr}</p>
|
||||
</div>
|
||||
|
||||
{/* 바로가기 */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-base font-semibold text-foreground">바로가기</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{quickAccessItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => router.push(item.href)}
|
||||
className="group flex flex-col items-center gap-2.5 rounded-lg border bg-card p-4 transition-all hover:shadow-md"
|
||||
>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${item.color} transition-transform group-hover:scale-105`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시스템 정보 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-5">
|
||||
<h2 className="mb-3 text-base font-semibold text-foreground">시스템 정보</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">플랫폼</p>
|
||||
<p className="text-sm font-medium">WACE ERP/PLM</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">버전</p>
|
||||
<p className="text-sm font-medium">v2.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">오늘 날짜</p>
|
||||
<p className="text-sm font-medium">
|
||||
{today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -385,13 +385,13 @@ export default function MultiLangPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<div className="text-sm text-gray-600">검색 결과: {filteredLangKeys.length}건</div>
|
||||
<div className="text-sm text-muted-foreground">검색 결과: {filteredLangKeys.length}건</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">전체: {filteredLangKeys.length}건</div>
|
||||
<div className="text-sm text-muted-foreground">전체: {filteredLangKeys.length}건</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
|
@ -417,7 +417,7 @@ export default function MultiLangPage() {
|
|||
{languages.map((lang) => (
|
||||
<div key={lang.langCode} className="rounded-lg border p-4">
|
||||
<div className="font-semibold">{lang.langName}</div>
|
||||
<div className="text-sm text-gray-600">{lang.langNative}</div>
|
||||
<div className="text-sm text-muted-foreground">{lang.langNative}</div>
|
||||
<Badge variant={lang.isActive === "Y" ? "default" : "secondary"} className="mt-2">
|
||||
{lang.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
|
|
@ -440,7 +440,7 @@ export default function MultiLangPage() {
|
|||
<h3 className="mb-2 font-semibold">{company.name}</h3>
|
||||
<div className="space-y-1">
|
||||
{menus.map((menu) => (
|
||||
<div key={menu.code} className="text-sm text-gray-600">
|
||||
<div key={menu.code} className="text-sm text-muted-foreground">
|
||||
{menu.name}
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,91 @@
|
|||
export default function MainHomePage() {
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* 대시보드 컨텐츠 */}
|
||||
<div className="rounded-lg border bg-background p-6 shadow-sm">
|
||||
<h3 className="mb-4 text-lg font-semibold">WACE 솔루션에 오신 것을 환영합니다!</h3>
|
||||
<p className="mb-6 text-muted-foreground">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
||||
"use client";
|
||||
|
||||
<div className="flex gap-2">
|
||||
<span className="inline-flex items-center rounded-md bg-success/10 px-2 py-1 text-xs font-medium text-success ring-1 ring-success/10 ring-inset">
|
||||
Next.js
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary ring-1 ring-primary/10 ring-inset">
|
||||
Shadcn/ui
|
||||
</span>
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { FileCheck, Menu, Users, Bell, FileText, Layout, Server, Shield, Calendar, ArrowRight } from "lucide-react";
|
||||
|
||||
const quickAccessItems = [
|
||||
{ label: "결재함", icon: FileCheck, href: "/admin/approvalBox", color: "text-primary bg-primary/10" },
|
||||
{ label: "메뉴 관리", icon: Menu, href: "/admin/menu", color: "text-violet-600 bg-violet-50" },
|
||||
{ label: "사용자 관리", icon: Users, href: "/admin/userMng", color: "text-emerald-600 bg-emerald-50" },
|
||||
{ label: "공지사항", icon: Bell, href: "/admin/system-notices", color: "text-amber-600 bg-amber-50" },
|
||||
{ label: "감사 로그", icon: FileText, href: "/admin/audit-log", color: "text-rose-600 bg-rose-50" },
|
||||
{ label: "화면 관리", icon: Layout, href: "/admin/screenMng", color: "text-cyan-600 bg-cyan-50" },
|
||||
];
|
||||
|
||||
export default function MainHomePage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
|
||||
const userName = user?.userName || "사용자";
|
||||
const today = new Date();
|
||||
const dateStr = today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric", weekday: "long" });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{userName}님, 좋은 하루 되세요
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{dateStr}</p>
|
||||
</div>
|
||||
|
||||
{/* 바로가기 */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-base font-semibold text-foreground">바로가기</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{quickAccessItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => router.push(item.href)}
|
||||
className="group flex flex-col items-center gap-2.5 rounded-lg border bg-card p-4 transition-all hover:shadow-md"
|
||||
>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${item.color} transition-transform group-hover:scale-105`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시스템 정보 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-5">
|
||||
<h2 className="mb-3 text-base font-semibold text-foreground">시스템 정보</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">플랫폼</p>
|
||||
<p className="text-sm font-medium">WACE ERP/PLM</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">버전</p>
|
||||
<p className="text-sm font-medium">v2.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">오늘 날짜</p>
|
||||
<p className="text-sm font-medium">
|
||||
{today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue